Redesign SFTP workspace and fix server monitoring

Replace Bootstrap dependencies with custom blue-accented light and dark themes and native dialogs.

Share the sidebar, top bar and responsive layout across file browsing, account settings, user management, server statistics and file previews. Add folder search, type filters, upload progress and asset cache versioning.

Fix SSH configuration usage, filesystem discovery, missing statistics and RAID health parsing. Adapt PDF sizing and fullscreen controls to the shared workspace.

Validation: PHP workspace and server statistics regression tests, JavaScript syntax checks, responsive browser checks and live XAMPP navigation.
This commit is contained in:
Michal Sedlák
2026-10-16 10:20:55 +02:00
parent d15fb27fd1
commit 565fd18379
26 changed files with 1058 additions and 1964 deletions
+36 -54
View File
@@ -10,6 +10,7 @@ ini_set('display_errors', 1);
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/workspace.php';
// $username = "UNAME"; // $username = "UNAME";
// $password = "PSWD"; // $password = "PSWD";
// $db = "usbraidlogin"; // $db = "usbraidlogin";
@@ -136,15 +137,15 @@ function CheckPassword($password) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin Panel</title> <title>Admin Panel</title>
<link rel="icon" href="../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/adminpanel.css') ?>">
<link rel="stylesheet" href="css/adminpanel.css">
<script src="../js/bootstrap.bundle.js"></script>
<script> <script>
function confirmDelete(uname) { function confirmDelete(uname) {
if (confirm('Are you sure you want to delete this user?')) { if (confirm('Are you sure you want to delete this user?')) {
@@ -152,45 +153,39 @@ function CheckPassword($password) {
} }
} }
</script> </script>
<script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
<script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'users',
<i class="bi"></i> 'section' => 'User management',
<span id="themeText"></span> 'eyebrow' => 'Workspace administration',
</button> 'title' => 'User management',
</div> 'description' => 'Manage accounts, storage locations and access permissions.',
'class' => 'users-page',
]); ?>
<section class="users-layout">
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>USB RAID Array</h1>
<div class="mb-3 p-3">
<a href="logout.php" class="btn btn-danger">Logout</a>
<a href="changepassword.php" class="btn btn-warning">Change Password</a>
<a href="ftp/index.php" class="btn btn-primary">SFTP</a>
<a href="ftp/serverstat.php" class="btn btn-primary">Server Status</a>
</div>
</header>
<section class="row">
<article class="col border border-2 border-primary rounded p-2 surface-panel">
<h2 class="mb-4">User Management</h2>
<?php if ($message): ?> <?php if ($message): ?>
<div class="alert alert-<?php echo $message_type === 'success' ? 'success' : 'danger'; ?>"> <div class="alert alert-<?php echo $message_type === 'success' ? 'success' : 'danger'; ?>">
<?php echo htmlspecialchars($message); ?> <?php echo htmlspecialchars($message); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="POST" action="" class="mb-4 add-user-form"> <div class="content-panel user-create"><div class="panel-heading"><h2>Add a member</h2><p>Set up an account and choose its access.</p></div>
<form method="POST" action="" class="add-user-form">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Username</label> <label class="form-label" for="memberUsername">Username</label>
<input type="text" name="uname" class="form-control" placeholder="Username" required> <input type="text" name="uname" id="memberUsername" class="form-control" placeholder="Username" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Password</label> <label class="form-label" for="memberPassword">Password</label>
<input type="password" name="pswd" class="form-control" placeholder="Password" required> <input type="password" name="pswd" id="memberPassword" class="form-control" placeholder="Password" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Default Path</label> <label class="form-label" for="memberPath">Default path</label>
<input type="text" name="defPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required> <input type="text" name="defPath" id="memberPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required>
</div> </div>
<div class="d-flex flex-column align-items-center mb-3"> <div class="d-flex flex-column align-items-center mb-3">
<div class="form-check mb-2"> <div class="form-check mb-2">
@@ -211,9 +206,11 @@ function CheckPassword($password) {
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form></div>
<h3 class="mb-3">Users List</h3> <div class="content-panel user-list"><div class="panel-heading"><h2>Workspace members</h2><p>Accounts and permissions for your storage.</p></div>
<table class="table table-bordered">
<div class="table-scroll"><table class="table users-table">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th>Username</th> <th>Username</th>
@@ -240,25 +237,10 @@ function CheckPassword($password) {
</tr> </tr>
<?php } ?> <?php } ?>
</tbody> </tbody>
</table> </table></div>
</article>
</section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div> </div>
</footer> </section>
<?php renderWorkspaceEnd(); ?>
<script src="../js/theme.js"></script>
</div>
</body> </body>
</html> </html>
<?php <?php
+33 -56
View File
@@ -7,6 +7,7 @@ if (!isset($_SESSION['uname'])) {
} }
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../includes/workspace.php';
// $username = "UNAME"; // $username = "UNAME";
// $password = "PSWD"; // $password = "PSWD";
// $db = "usbraidlogin"; // $db = "usbraidlogin";
@@ -93,82 +94,58 @@ function CheckPassword($password) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<title>Change Password</title> <title>Change Password</title>
<link rel="icon" href="../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../img/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/changepassword.css') ?>">
<link rel="stylesheet" href="css/changepassword.css"> <script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
<script src="../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'account',
<i class="bi"></i> 'section' => 'Account settings',
<span id="themeText"></span> 'eyebrow' => 'Your account',
</button> 'title' => 'Account settings',
</div> 'description' => 'Keep your workspace account secure and up to date.',
'class' => 'account-page',
<div class="custom-container dark-box"> ]); ?>
<header class="row border-bottom my-3 my-md-5">
<h1>USB RAID Array</h1>
<div class="mb-3 p-3"> <section class="account-layout">
<div class="btn-group-responsive"> <article class="content-panel">
<a href="logout.php" class="btn btn-danger">Logout</a> <div class="panel-heading"><h2>Change password</h2><p>Choose a strong password to protect your files.</p></div>
<a href="ftp/index.php" class="btn btn-primary">Back to Files</a>
<?php if (isset($_SESSION["admin"]) && $_SESSION["admin"] == true) { ?>
<a href="adminpanel.php" class="btn btn-primary">Admin Panel</a>
<?php } ?>
</div>
</div>
</header>
<section class="row content-section">
<article class="col-12 border border-2 border-primary rounded p-2 p-md-4 surface-panel">
<h2 class="mb-4">Change Password</h2>
<?php if ($message): ?> <?php if ($message): ?>
<div class="alert alert-<?php echo $messageType; ?> mb-4"> <div class="alert alert-<?php echo $messageType; ?> mb-4">
<?php echo $message; ?> <?php echo $message; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="POST" action="" class="mb-4 change-password-form"> <form method="POST" action="" class="mb-4 change-password-form">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Current Password</label> <label class="form-label" for="currentPassword">Current password</label>
<input type="password" name="current_password" class="form-control" required> <input type="password" id="currentPassword" autocomplete="current-password" name="current_password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">New Password</label> <label class="form-label" for="newPassword">New password</label>
<input type="password" name="new_password" class="form-control" required> <input type="password" id="newPassword" autocomplete="new-password" name="new_password" class="form-control" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Confirm New Password</label> <label class="form-label" for="confirmPassword">Confirm new password</label>
<input type="password" name="confirm_password" class="form-control" required> <input type="password" id="confirmPassword" autocomplete="new-password" name="confirm_password" class="form-control" required>
</div> </div>
<button type="submit" class="btn btn-primary">Change Password</button> <button type="submit" class="btn btn-primary">Change Password</button>
</form> </form>
</article> </article>
<aside class="account-help"><span class="eyebrow">Password requirements</span><h3>A little extra protection</h3><p>Use at least 8 characters, one uppercase letter and one number.</p><p>You can return to your files at any time using the workspace navigation.</p></aside>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3 flex-wrap justify-content-center">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
<script src="../js/theme.js"></script>
</div>
</body> </body>
</html> </html>
+13 -66
View File
@@ -1,66 +1,13 @@
html, body { .users-layout { display:grid; grid-template-columns:300px minmax(0,1fr); align-items:start; gap:22px; }
height: 100%; .users-layout > .alert { grid-column:1/-1; margin:0; }
margin: 0; .add-user-form { margin:0; text-align:left; }
} .add-user-form .form-check { width:100%; margin-bottom:12px; font-size:12px; }
.add-user-form .btn { width:100%; }
.custom-container { .users-table { min-width:650px; }
display: flex; .users-table th { font-size:10px; padding:12px 10px; white-space:nowrap; }
flex-direction: column; .users-table td { font-size:12px; padding:14px 10px; }
min-height: 100vh; .users-table td:first-child { font-weight:600; }
max-width: 60%; .users-table td:nth-child(3) { font-family:ui-monospace,monospace; white-space:nowrap; }
margin: 0 auto; .user-create .form-label { font-size:12px; }
} @media(max-width:1200px) { .users-layout { grid-template-columns:1fr; } .add-user-form { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; } .add-user-form > .d-flex { grid-column:1/-1; display:grid; grid-template-columns:1fr 1fr; gap:8px; } .add-user-form > .btn { width:auto; justify-self:start; } }
.custom-container.dark-box { @media(max-width:600px) { .add-user-form { display:block; } .add-user-form > .d-flex { display:flex; align-items:flex-start; gap:0; } }
min-height: calc(100vh - 4rem);
width: min(60%, 1200px);
}
section.row {
flex: 1;
}
@media (max-width: 1120px) {
.custom-container {
max-width: 90%;
}
.custom-container.dark-box {
width: 90%;
}
}
@media (max-width: 700px) {
.custom-container.dark-box {
width: calc(100% - 2rem);
min-height: calc(100vh - 2rem);
margin: 1rem auto;
}
body {
min-width: 0;
}
}
.add-user-form {
max-width: 400px;
margin: 0 auto;
}
.form-check {
display: flex;
align-items: center;
width: 100%;
justify-content: flex-start;
}
.form-check-input {
margin-right: 10px;
}
.form-check-label {
margin-left: 0.5rem;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
+6 -56
View File
@@ -1,56 +1,6 @@
.container-fluid { .account-layout { display:grid; grid-template-columns:minmax(0,560px) minmax(200px,1fr); align-items:start; gap:40px; }
width: 100%; .change-password-form { margin:0; }
padding-right: 15px; .change-password-form .form-label { font-size:12px; }
padding-left: 15px; .account-help { max-width:320px; padding:24px 0; color:var(--muted); font-size:13px; }
margin-right: auto; .account-help h3 { color:var(--text); margin-top:12px; font-size:18px; }
margin-left: auto; @media(max-width:1000px) { .account-layout { grid-template-columns:1fr; gap:16px; } .account-help { max-width:100%; padding:12px 4px; } }
}
.content-section {
max-width: 800px;
margin: 0 auto;
}
.change-password-form {
max-width: 100%;
margin: 0 auto;
}
@media (min-width: 768px) {
.change-password-form {
max-width: 400px;
}
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.btn-group-responsive {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
}
h1 {
font-size: 1.8rem;
}
@media (min-width: 576px) {
h1 {
font-size: 2.5rem;
}
}
+15 -141
View File
@@ -1,142 +1,16 @@
html, body { .pathbar { display:flex; align-items:center; flex-wrap:wrap; gap:12px; padding:13px 16px; background:var(--surface); border:1px solid var(--line); border-radius:10px; margin-bottom:20px; font-size:12px; overflow-wrap:anywhere; }
height: 100%; .pathbar a { color:var(--text); } .pathbar a:first-child { color:var(--blue); } .path-separator { color:var(--muted); } .path-label { margin-left:auto; color:var(--muted); font:11px ui-monospace,monospace; }
margin: 0; .file-panel { border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); overflow:hidden; }
overflow-x: hidden; .browser-toolbar { padding:20px; display:flex; align-items:center; gap:12px; border-bottom:1px solid var(--line); }
overflow-y: auto; .search-field { display:flex; align-items:center; gap:10px; margin:0; padding:0 12px; border:1px solid var(--line); border-radius:8px; width:360px; max-width:100%; background:var(--bg); color:var(--muted); font-weight:400; }
} .search-field > span { font-size:24px; } .search-field input { width:100%; min-width:0; padding:10px 0; border:0; background:transparent; color:var(--text); outline:none; } .search-field:focus-within { outline:2px solid var(--blue); } .search-field input::placeholder { color:var(--muted); } kbd { font:11px ui-monospace,monospace; padding:0 5px; border:1px solid var(--line); border-radius:3px; }
.browser-toolbar select { width:130px; margin-left:auto; font-size:12px; padding:10px; }
.action-buttons { display:flex; flex-wrap:wrap; gap:8px; padding:18px 20px 0; } .action-buttons .btn { font-size:12px; } .action-buttons .btn-primary { order:-1; }
.dropzone { margin:18px 20px 22px; border:1px dashed var(--line); background:var(--bg); border-radius:9px; display:flex; align-items:center; justify-content:center; gap:14px; padding:20px; text-align:left; }
.dropzone.dragover { border-color:var(--blue); background:var(--blue-soft); } .upload-symbol { display:grid; place-items:center; width:36px; height:36px; border:1px solid var(--line); border-radius:9px; background:var(--surface); font-size:22px; color:var(--blue); } .dropzone strong { font-weight:550; font-size:12px; } .dropzone small { display:block; color:var(--muted); font-size:11px; margin-top:3px; }
.file-table-scroll { overflow-x:auto; } .file-table { min-width:650px; } .file-table th { padding:12px 20px; } .file-table td { padding:14px 20px; font-size:12px; } .file-table th:first-child { width:48%; } .file-table th:nth-child(2) { width:13%; } .file-table th:last-child { width:39%; } .file-table td:first-child { font-weight:550; } .file-table td:first-child a { color:var(--text); } .file-table .btn { font-size:11px; padding:5px 8px; min-height:30px; background:transparent; border-color:transparent; } .file-table .btn:hover { background:var(--surface); border-color:var(--line); } .file-table td:last-child { white-space:nowrap; } .file-table td:nth-child(2) { color:var(--muted); font-family:ui-monospace,monospace; font-size:11px; }
.file-icon { display:inline-grid; place-items:center; width:30px; height:32px; margin-right:10px; color:var(--muted); background:var(--surface-alt); border-radius:7px; vertical-align:middle; font-size:18px; } .folder-icon { color:var(--blue); background:var(--blue-soft); } .file-table .badge { margin-left:5px; }
.browser-status { display:flex; justify-content:space-between; padding:13px 20px; color:var(--muted); font-size:11px; } .empty-state { text-align:center; padding:48px 20px; color:var(--muted); } .empty-state > span { font-size:38px; } .empty-state h3 { color:var(--text); margin:6px 0; } .empty-state p { margin:0; }
.file-content > .alert { margin:16px 20px; }
.custom-container { @media(max-width:760px) { .path-label { display:none; } .browser-toolbar { padding:14px; gap:8px; } .search-field { width:auto; flex:1; min-width:0; } .browser-toolbar select { width:100px; } .action-buttons { padding:14px 14px 0; } .dropzone { margin:14px; } }
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0;
padding: 0;
max-width: 100%;
overflow: visible;
}
section.row {
flex: 1;
margin: 0;
width: 100%;
justify-content: center;
}
article.col-8 {
max-width: 1600px;
}
@media (max-width: 1800px) {
article.col-8 {
flex: 0 0 auto;
width: 95%;
}
}
@media (max-width: 768px) {
.custom-container {
height: auto;
min-height: initial;
}
section.row {
flex: 0 0 auto;
}
footer {
margin-top: 20px;
position: relative;
}
body {
height: auto;
min-height: initial;
}
article {
padding-bottom: 20px;
}
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
table-layout: fixed;
}
th, td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}
.dropzone {
width: 100%;
padding: 20px;
border: 2px dashed #007bff;
border-radius: 10px;
text-align: center;
margin-bottom: 20px;
cursor: pointer;
box-sizing: border-box;
}
.dropzone.dragover {
background-color: #e0f7fa;
}
.action-buttons {
margin-bottom: 15px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.action-buttons button {
margin-right: 0;
}
body {
min-width: 500px;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
/* Give the SFTP file browser its own surface, distinct from the outer page. */
article.col-8 {
background: linear-gradient(145deg, var(--sftp-bg-start), var(--sftp-bg-end));
border-color: rgba(77, 145, 255, 0.3) !important;
border-radius: 1rem !important;
box-shadow: 0 12px 32px rgba(3, 8, 18, 0.2);
}
html[data-bs-theme="light"] article.col-8 {
background: linear-gradient(145deg, #ffffff, #edf3fb);
border-color: rgba(59, 130, 246, 0.4) !important;
box-shadow: 0 18px 48px rgba(31, 58, 92, 0.1);
}
+6 -54
View File
@@ -1,54 +1,6 @@
.editor-container { .editor-container { position:relative; border:1px solid var(--line); border-radius:10px; overflow:hidden; margin-bottom:20px; }
border: 1px solid #ccc; #editor { width:100%; min-height:55vh; padding:16px; font:13px/1.65 ui-monospace,Consolas,monospace; white-space:pre; overflow:auto; resize:vertical; tab-size:4; border:0; border-radius:0; background:var(--bg); }
border-radius: 4px; .editor-container:has(.line-numbers) #editor { padding-left:64px; }
margin-bottom: 20px; .line-numbers { position:absolute; left:0; top:0; width:48px; text-align:right; padding:16px 10px 16px 0; border-right:1px solid var(--line); color:var(--muted); user-select:none; font:13px/1.65 ui-monospace,Consolas,monospace; overflow:hidden; z-index:1; background:var(--surface-alt); }
position: relative; .readonly-notice { color:var(--muted); font-size:12px; }
} .editor-page .d-flex { flex-wrap:wrap; gap:8px; }
#editor {
width: 100%;
min-height: 400px;
font-family: monospace;
padding: 10px;
white-space: pre;
overflow: auto;
resize: vertical;
tab-size: 4;
-moz-tab-size: 4;
padding-left: 55px; /* Make room for line numbers */
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
width: 45px;
text-align: right;
padding: 10px 5px 10px 0;
border-right: 1px solid #ccc;
color: #999;
user-select: none;
font-family: monospace;
overflow: hidden;
z-index: 1;
background-color: #f8f9fa; /* Light mode default */
}
[data-bs-theme="dark"] .line-numbers {
background-color: #212529; /* Dark mode background */
border-right-color: #495057; /* Darker border for dark mode */
color: #6c757d; /* Lighter text for dark mode */
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.readonly-notice {
color: #dc3545;
font-weight: bold;
margin-left: 10px;
}
+15 -253
View File
@@ -1,253 +1,15 @@
body { .pdf-workspace { padding:16px; }
min-height: 100vh; #pdfContainer { position:relative; width:100%; min-height:380px; display:flex; flex-direction:column; align-items:center; padding:16px 0; overflow:auto; background:var(--bg); border:1px solid var(--line); border-radius:8px; }
margin: 0; #loadingMessage { position:absolute; inset:0; background:var(--bg); z-index:1; color:var(--muted); }
padding: 0; .page-container { background:white; box-shadow:0 4px 20px #0002; max-width:100%; overflow:hidden; }
} .page-container canvas { display:block; }
#pdfContainer { .controls { display:flex; justify-content:center; margin-top:16px; }
width: 100%; .controls .btn-group { flex-wrap:wrap; justify-content:center; gap:6px; }
min-height: calc(100vh - 80px); .controls .btn { font-size:12px; }
display: flex; .actionButton { display:flex; justify-content:center; margin-top:12px; }
flex-direction: column; .actionButton .btn { margin:0; }
align-items: center; .pdf-workspace.fullscreen-mode { position:fixed; inset:0; width:100%; height:100dvh; z-index:9999; background:var(--bg); padding:0; border:0; border-radius:0; display:flex; flex-direction:column; }
padding: 20px 0; .fullscreen-mode #pdfContainer { flex:1; height:100%; min-height:0; padding:0; border:0; border-radius:0; justify-content:center; }
} .fullscreen-mode .controls { position:absolute; bottom:12px; left:50%; transform:translateX(-50%); margin:0; padding:8px; border:1px solid var(--line); border-radius:10px; background:var(--surface); max-width:calc(100% - 24px); width:max-content; z-index:2; }
#loadingMessage { .fullscreen-mode .actionButton { display:none; }
position: absolute; @media(max-width:600px) { .pdf-workspace { padding:10px; } .controls .btn { padding:7px 10px; } }
top: 0;
left: 0;
z-index: 1000;
background-color: var(--bs-body-bg);
}
[data-bs-theme="dark"] #loadingMessage {
background-color: rgba(33, 37, 41, 0.9);
}
[data-bs-theme="light"] #loadingMessage {
background-color: rgba(248, 249, 250, 0.9);
}
.page-container {
background-color: white;
margin: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
border-radius: 4px;
overflow: hidden;
}
.controls {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
canvas {
display: block;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.file-info table {
table-layout: fixed;
word-wrap: break-word;
}
.actionButton {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
}
.fullscreen-mode {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-color: var(--bs-body-bg);
z-index: 9999;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fullscreen-mode header,
.fullscreen-mode footer,
.fullscreen-mode .theme-toggle,
.fullscreen-mode .actionButton {
display: none !important;
}
.fullscreen-mode #pdfContainer {
flex: 1;
margin: 0;
padding: 0;
height: 100vh;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.fullscreen-mode .controls {
position: fixed;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
margin: 0;
padding: 6px 12px;
background-color: rgba(var(--bs-body-bg-rgb), 0.3);
backdrop-filter: blur(5px);
transition: all 0.3s ease;
opacity: 0.3;
z-index: 1000;
border-radius: .375rem;
width: auto;
max-width: 95%;
}
.fullscreen-mode .controls:hover {
opacity: 1;
background-color: rgba(var(--bs-body-bg-rgb), 0.9);
box-shadow: 0 0 15px rgba(0,0,0,0.2);
}
.fullscreen-mode[data-bs-theme="light"] .btn-light {
background-color: rgba(233, 236, 239, 0.7);
border-color: rgba(222, 226, 230, 0.7);
}
.fullscreen-mode[data-bs-theme="dark"] .btn-light {
background-color: rgba(52, 58, 64, 0.7);
border-color: rgba(73, 80, 87, 0.7);
}
.fullscreen-mode .btn-light:hover {
transform: scale(1.05);
transition: transform 0.2s ease;
}
.fullscreen-mode .btn-light.disabled {
background-color: rgba(var(--bs-body-bg-rgb), 0.5);
}
.fullscreen-mode .page-container {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
height: 100vh;
width: 100%;
}
.fullscreen-mode canvas {
max-width: 100%;
max-height: 100vh;
width: 100% !important;
height: auto !important;
margin: 0;
object-fit: contain;
}
.fullscreen-mode .btn-group {
display: flex;
align-items: center;
gap: 4px;
}
.fullscreen-mode .btn-group .btn {
min-width: 44px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.fullscreen-mode .btn-group .btn.disabled {
min-width: 120px;
padding: 6px 12px;
}
/* Mobile-specific adjustments */
@media (max-width: 768px) {
.fullscreen-mode .controls {
bottom: 5px;
padding: 4px 8px;
width: auto;
max-width: calc(100% - 20px);
}
.fullscreen-mode .btn-group .btn {
min-width: 36px;
padding: 4px 6px;
}
.fullscreen-mode .btn-group .btn.disabled {
min-width: 110px;
padding: 4px 8px;
}
.fullscreen-mode #pageNum,
.fullscreen-mode #pageCount {
font-size: 0.875rem;
margin: 0 2px;
}
.fullscreen-mode .btn-group {
gap: 3px;
}
/* Add spacing between page numbers and "of" text */
.fullscreen-mode .btn-light.disabled span {
margin: 0 2px;
}
}
/* Theme-specific button styles */
[data-bs-theme="light"] .btn-light {
background-color: #e9ecef;
border-color: #dee2e6;
color: #212529;
}
[data-bs-theme="light"] .btn-light:hover {
background-color: #dde2e6;
border-color: #ced4da;
color: #000;
}
[data-bs-theme="dark"] .btn-light {
background-color: #343a40;
border-color: #495057;
color: #f8f9fa;
}
[data-bs-theme="dark"] .btn-light:hover {
background-color: #495057;
border-color: #6c757d;
color: #fff;
}
/* Button group specific styles */
.btn-group .btn-light {
margin: 0 1px;
}
.btn-group .btn-light.disabled {
opacity: 0.8;
}
+18 -124
View File
@@ -1,124 +1,18 @@
/* Layout */ .stats-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:22px; }
body { .stats-cell { min-width:0; }
min-height: 100vh; .monitor-card { padding:0; overflow:hidden; box-shadow:var(--shadow); }
margin: 0; .monitor-card .card-header { padding:18px 22px; background:var(--surface); border-bottom:1px solid var(--line); }
padding: 0; .monitor-card h3 { font-size:14px; }
display: flex; .monitor-card .card-body { padding:24px; min-height:235px; }
flex-direction: column; .monitor-card .card-body > .text-muted { overflow-wrap:anywhere; }
} .monitor-card .row { flex-wrap:nowrap; gap:10px; }
.monitor-card .col-4 { min-width:0; }
footer { .monitor-card .fw-bold { font-size:13px; overflow-wrap:anywhere; margin-top:6px; }
margin-top: auto; .monitor-card .small { font-size:11px; }
padding: 20px; .cpu-gauge { min-height:185px; display:grid; place-items:center; }
border-top: 1px solid var(--bs-border-color); .raid-summary { text-align:center; padding:20px 0; }
width: 100%; .raid-summary > .ui-icon { font-size:30px; margin-bottom:12px; }
} .monitor-card .progress { margin-top:8px; }
.monitor-card .resource-section + .resource-section { margin-top:24px; }
/* Monitor Cards */ @media(max-width:1000px) { .stats-grid { grid-template-columns:1fr; } }
.monitor-card { @media(max-width:600px) { .monitor-card .card-body { padding:18px; } .monitor-card .row { gap:6px; } .monitor-card .p-2 { padding:10px 6px; } }
transition: transform 0.2s ease-in-out;
height: 100%;
}
.monitor-card:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.monitor-card .card-body {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.5rem;
min-height: 320px;
}
.monitor-header {
background-color: var(--bs-body-bg);
border-bottom: 1px solid var(--bs-border-color);
}
.progress {
background-color: var(--bs-tertiary-bg);
}
/* CPU Gauge */
.cpu-gauge {
position: relative;
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.cpu-gauge canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.cpu-gauge .position-relative {
position: absolute !important;
top: 50%;
left: 50%;
transform: translate(-50%, -55%);
z-index: 2;
width: auto;
text-align: center;
line-height: 1.2;
}
.cpu-gauge .h3 {
font-size: 1.75rem;
line-height: 1;
margin: 0;
}
.cpu-gauge .small {
opacity: 0.75;
margin-top: 3px;
}
/* Theme & Logo */
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
/* Grid Spacing */
.row.g-4 {
--bs-gutter-y: 2rem;
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 0.5rem;
}
.h4 {
font-size: 1.25rem;
}
.monitor-card .card-body {
padding: 1rem;
min-height: 280px;
}
.row.g-4 > [class*="col-"] {
margin-bottom: 1rem;
}
}
+11 -86
View File
@@ -1,86 +1,11 @@
html, body { .media-container { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:12px; padding:24px; background:var(--bg); border:1px solid var(--line); border-radius:10px; min-height:200px; }
height: 100%; .media-container img,.media-container video { display:block; max-width:100%; max-height:65vh; object-fit:contain; flex-basis:100%; }
margin: 0; .media-container audio { display:block; width:100%; max-width:680px; margin:28px auto; flex-basis:100%; }
overflow-x: hidden; .media-container .btn { margin:0; }
} .file-info { max-width:760px; }
.file-info h4 { font-size:16px; }
.custom-container { .file-info table { width:100%; table-layout:fixed; }
display: flex; .file-info th { width:160px; }
flex-direction: column; .file-info td { overflow-wrap:anywhere; font-size:12px; }
min-height: 100vh; .media-page .input-group { flex-wrap:wrap; } .media-page .input-group input { flex:1; min-width:160px; }
margin: 0; @media(max-width:600px) { .media-container { padding:12px; } .file-info th { width:110px; } }
padding: 0;
max-width: 100%;
}
.media-container {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
max-height: 80vh;
margin: 0 auto;
overflow: visible;
flex-direction: column;
}
.media-container img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.media-container video {
max-width: 100%;
max-height: 70vh;
}
.media-container audio {
width: 100%;
max-width: 600px;
margin: 20px 0;
}
.controls {
margin: 20px 0;
text-align: center;
}
footer {
margin-top: auto;
padding: 20px;
border-top: 1px solid #ddd;
width: 100%;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
.file-info table {
table-layout: fixed;
word-wrap: break-word;
}
@media (max-width: 576px) {
.file-info table {
width: 95% !important;
}
.file-info th {
width: 40%;
}
.file-info td {
width: 60%;
}
}
+66 -95
View File
@@ -13,19 +13,20 @@
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath; $currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath;
if (strpos($currentPath, $defaultPath) !== 0) { if (strpos($currentPath, $defaultPath) !== 0) {
$currentPath = $defaultPath; $currentPath = $defaultPath;
} }
if (!$sftp->chdir($currentPath)) { if (!$sftp->chdir($currentPath)) {
die("Failed to navigate to the selected folder: $currentPath"); die("Failed to navigate to the selected folder: $currentPath");
} }
$items = $sftp->nlist(); $items = $sftp->nlist();
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']; $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'];
$videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv']; $videoExtensions = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'];
$audioExtensions = ['mp3', 'wav', 'm4a', 'flac', 'aac']; $audioExtensions = ['mp3', 'wav', 'm4a', 'flac', 'aac'];
@@ -35,51 +36,41 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>FTP</title> <title>Files · USB RAID</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/index.css') ?>">
<link rel="stylesheet" href="css/index.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'Files',
<span id="themeText"></span> 'eyebrow' => 'Your remote storage',
</button> 'title' => 'All files',
</div> 'description' => 'Everything in its place. Browse, manage and transfer your files.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="row border-bottom m-5"> ]); ?>
<h1>USB RAID Array</h1> <nav class="pathbar" aria-label="Directory path"><a href="index.php" title="Home directory">⌂ <span>Home</span></a><?php
<div class="mb-3 p-3"> $relativePath = trim(substr($currentPath, strlen(rtrim($defaultPath, '/'))), '/');
<a href="../logout.php" class="btn btn-danger">Logout</a> $crumbPath = rtrim($defaultPath, '/');
<a href="../changepassword.php" class="btn btn-warning">Change Password</a> foreach (array_filter(explode('/', $relativePath), 'strlen') as $segment):
<?php if (isset($_SESSION["admin"]) && $_SESSION["admin"] == true) { ?> $crumbPath .= '/' . $segment;
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a> ?><span class="path-separator" aria-hidden="true">/</span><a href="?path=<?= urlencode($crumbPath) ?>"><?= htmlspecialchars($segment) ?></a><?php endforeach; ?><span class="path-label" title="<?= htmlspecialchars($currentPath) ?>"><?= htmlspecialchars($currentPath) ?></span></nav>
<a href="serverstat.php" class="btn btn-primary">Server Status</a> <section class="file-panel" aria-label="File browser">
<?php } ?> <div class="browser-toolbar"><label class="search-field"><span aria-hidden="true">⌕</span><input id="fileSearch" type="search" placeholder="Search this folder…" aria-label="Search this folder"><kbd>/</kbd></label><select id="typeFilter" class="form-select" aria-label="Filter by type"><option value="all">All types</option><option value="folder">Folders</option><option value="file">Files</option></select></div>
</div> <div class="file-content">
</header>
<section class="row">
<article class="col-8 border border-2 border-primary rounded p-2">
<div class="col">
<h4>Current Path: <?= htmlspecialchars($currentPath) ?></h4>
</div>
<div class="col">
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createFileModal">Create File</button> <button type="button" class="btn" data-dialog="createFileModal">+ New file</button>
<button type="button" class="btn btn-success" onclick="document.getElementById('fileInput').click()">Upload Files</button> <button type="button" class="btn btn-primary" onclick="document.getElementById('fileInput').click()">↑ Upload files</button>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createDirModal">Create Directory</button> <button type="button" class="btn" data-dialog="createDirModal">+ New folder</button>
<button type="button" class="btn btn-info" onclick="document.getElementById('dirInput').click()">Upload Directory</button> <button type="button" class="btn btn-info" onclick="document.getElementById('dirInput').click()">Upload folder</button>
<input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileSelect(event)"> <input type="file" id="fileInput" multiple style="display: none;" onchange="handleFileSelect(event)">
<input type="file" id="dirInput" webkitdirectory directory multiple style="display: none;" onchange="handleFileSelect(event)"> <input type="file" id="dirInput" webkitdirectory directory multiple style="display: none;" onchange="handleFileSelect(event)">
<?php } ?> <?php } ?>
@@ -87,57 +78,57 @@
<!-- Modal Forms --> <!-- Modal Forms -->
<!-- Create Directory Dialog --> <!-- Create Directory Dialog -->
<div class="modal fade" id="createDirModal" tabindex="-1" aria-labelledby="createDirModalLabel" aria-hidden="true"> <dialog class="modal" id="createDirModal" tabindex="-1" aria-labelledby="createDirModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="createDirModalLabel">Create New Directory</h5> <h5 class="modal-title" id="createDirModalLabel">Create New Directory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="dirName" class="form-label">Directory Name</label> <label for="dirName" class="form-label">Directory Name</label>
<input type="text" class="form-control" id="dirName" placeholder="Enter directory name"> <input type="text" class="form-control" id="dirName" autofocus placeholder="Enter directory name">
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="button" class="btn btn-primary" onclick="createDirectory()">Create</button> <button type="button" class="btn btn-primary" onclick="createDirectory()">Create</button>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<!-- Create File Dialog --> <!-- Create File Dialog -->
<div class="modal fade" id="createFileModal" tabindex="-1" aria-labelledby="createFileModalLabel" aria-hidden="true"> <dialog class="modal" id="createFileModal" tabindex="-1" aria-labelledby="createFileModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="createFileModalLabel">Create New File</h5> <h5 class="modal-title" id="createFileModalLabel">Create New File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="fileName" class="form-label">File Name (with extension)</label> <label for="fileName" class="form-label">File Name (with extension)</label>
<input type="text" class="form-control" id="fileName" placeholder="example.txt"> <input type="text" class="form-control" id="fileName" autofocus placeholder="example.txt">
<div class="form-text">Supported extensions: .txt, .html, .css, .js, .php, etc.</div> <div class="form-text">Supported extensions: .txt, .html, .css, .js, .php, etc.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="button" class="btn btn-primary" onclick="createFile()">Create</button> <button type="button" class="btn btn-primary" onclick="createFile()">Create</button>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<!-- Rename Confirmation Dialog --> <!-- Rename Confirmation Dialog -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true"> <dialog class="modal" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="renameModalLabel">Rename Item</h5> <h5 class="modal-title" id="renameModalLabel">Rename Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<form method="POST" action="rename.php"> <form method="POST" action="rename.php">
<div class="modal-body"> <div class="modal-body">
@@ -149,31 +140,31 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="newName" class="form-label">New Name:</label> <label for="newName" class="form-label">New Name:</label>
<input type="text" class="form-control" id="newName" name="newName" required> <input type="text" class="form-control" id="newName" autofocus name="newName" required>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="submit" class="btn btn-primary">Rename</button> <button type="submit" class="btn btn-primary">Rename</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </dialog>
<!-- End of Modal Forms --> <!-- End of Modal Forms -->
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?>
<div class="dropzone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)"> <div class="dropzone" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)">
Drop files or folders here to upload <span class="upload-symbol" aria-hidden="true">↑</span><span><strong>Drop files or folders here</strong><small>Or use the upload buttons to browse your device</small></span>
</div> </div>
<?php } ?> <?php } ?>
<table> <div class="file-table-scroll"><table class="file-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th scope="col">Name</th>
<th>Size</th> <th scope="col">Size</th>
<th>Actions</th> <th scope="col">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -197,15 +188,14 @@
if ($currentPath !== $defaultPath) : ?> if ($currentPath !== $defaultPath) : ?>
<tr> <tr>
<td colspan="3"><a class="text-danger" href="?path=<?= urlencode(dirname($currentPath)) ?>"><b>.. (Go Back)</b></a></td> <td colspan="3"><a class="text-danger" href="?path=<?= urlencode(dirname($currentPath)) ?>"><b>↑ Parent folder</b></a></td>
</tr> </tr>
<?php endif; <?php endif;
foreach ($directories as $directory) : ?> foreach ($directories as $directory) : ?>
<tr> <tr data-file-name="<?= htmlspecialchars($directory) ?>" data-kind="folder">
<td colspan="2"> <td><span class="file-icon folder-icon" aria-hidden="true">▰</span>
<a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?>/</a> <a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?></a></td><td class="size-cell">—</td>
</td>
<td> <td>
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?> <?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
<form method="POST" action="delete.php" style="display:inline;"> <form method="POST" action="delete.php" style="display:inline;">
@@ -221,7 +211,7 @@
</form> </form>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) : ?>
<button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $directory)) ?>')">Rename</button> <button type="button" class="btn btn-warning mt-1 mb-1" onclick="openRenameModal('<?= htmlspecialchars(addslashes($directory)) ?>', '<?= htmlspecialchars(addslashes($currentPath . '/' . $directory)) ?>', true)">Rename</button>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -229,7 +219,7 @@
foreach ($files as $file) : ?> foreach ($files as $file) : ?>
<?php <?php
$fileExtension = pathinfo($file, PATHINFO_EXTENSION); $fileExtension = pathinfo($file, PATHINFO_EXTENSION);
$isPDF = strtolower($fileExtension) === 'pdf'; $isPDF = strtolower($fileExtension) === 'pdf';
$isImage = in_array($fileExtension, $imageExtensions); $isImage = in_array($fileExtension, $imageExtensions);
@@ -242,9 +232,8 @@
$isArchive = true; $isArchive = true;
} }
?> ?>
<tr> <tr data-file-name="<?= htmlspecialchars($file) ?>" data-kind="file">
<td> <td><span class="file-icon" aria-hidden="true">▤</span>
<?php if ($isMedia): ?> <?php if ($isMedia): ?>
<a class="text-info-emphasis" href="view.php?file=<?= urlencode($currentPath . '/' . $file) ?>"> <a class="text-info-emphasis" href="view.php?file=<?= urlencode($currentPath . '/' . $file) ?>">
<?= htmlspecialchars($file) ?> <?= htmlspecialchars($file) ?>
@@ -301,36 +290,18 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table></div>
<div id="emptySearch" class="empty-state" hidden><span aria-hidden="true">⌕</span><h3>No files to show</h3><p>Try another search or upload files to this folder.</p></div>
<div class="browser-status"><span id="fileCount" role="status"></span><span>Folders first · A–Z</span></div>
</div> </div>
</article> </section>
<?php renderWorkspaceEnd(); ?>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script>
// Keep Bootstrap modals outside the filtered SFTP container. A parent
// backdrop-filter creates a stacking context that can block modal input.
document.querySelectorAll('.modal').forEach(function (modal) {
document.body.appendChild(modal);
});
</script>
<script> <script>
// Pass PHP variables to JavaScript // Pass PHP variables to JavaScript
var currentPath = "<?php echo $currentPath; ?>"; const currentPath = <?= json_encode($currentPath, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
</script> </script>
<script src="js/index.js"></script> <script src="<?= workspaceAsset('../../', 'content/ftp/js/index.js') ?>"></script>
</body> </body>
</html> </html>
+32 -47
View File
@@ -158,6 +158,10 @@ function uploadFiles(files) {
} }
}; };
uploadStatus.setAttribute('role', 'status');
xhr.upload.addEventListener('progress', event => {
if (event.lengthComputable) uploadStatus.textContent = 'Uploading ' + filesAdded + ' file(s) · ' + Math.round(event.loaded / event.total * 100) + '%';
});
xhr.send(formData); xhr.send(formData);
} }
@@ -178,8 +182,7 @@ function createDirectory() {
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 200) { if (xhr.status === 200) {
alert('Directory created successfully!'); alert('Directory created successfully!');
const modal = bootstrap.Modal.getInstance(document.getElementById('createDirModal')); document.getElementById('createDirModal').close();
if (modal) modal.hide();
window.location.reload(); window.location.reload();
} else { } else {
alert('Failed to create directory: ' + xhr.responseText || xhr.statusText); alert('Failed to create directory: ' + xhr.responseText || xhr.statusText);
@@ -221,8 +224,7 @@ function createFile() {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
if (response.success) { if (response.success) {
alert('File created successfully!'); alert('File created successfully!');
const modal = bootstrap.Modal.getInstance(document.getElementById('createFileModal')); document.getElementById('createFileModal').close();
if (modal) modal.hide();
window.location.reload(); window.location.reload();
} else { } else {
alert('Failed to create file: ' + (response.message || 'Unknown error')); alert('Failed to create file: ' + (response.message || 'Unknown error'));
@@ -236,53 +238,36 @@ function createFile() {
}; };
xhr.send(formData); xhr.send(formData);
} }
function openRenameModal(name, path) { function openRenameModal(name, path, isDirectory = false) {
document.getElementById('originalName').value = name; document.getElementById('originalName').value = name;
document.getElementById('fullPath').value = path; document.getElementById('fullPath').value = path;
document.getElementById('isDirectory').value = name.endsWith('/') ? '1' : '0'; document.getElementById('isDirectory').value = isDirectory ? '1' : '0';
document.getElementById('newName').value = name.endsWith('/') ? name.slice(0, -1) : name; document.getElementById('newName').value = name.endsWith('/') ? name.slice(0, -1) : name;
const renameModal = new bootstrap.Modal(document.getElementById('renameModal')); document.getElementById('renameModal').showModal();
renameModal.show();
} }
document.addEventListener('DOMContentLoaded', function() {
const themeToggle = document.getElementById('themeToggle'); // Filtering is local to the current directory; no remote request is needed.
const html = document.documentElement; const search = document.getElementById('fileSearch');
const themeText = document.getElementById('themeText'); const typeFilter = document.getElementById('typeFilter');
const themeIcon = themeToggle.querySelector('.bi'); function filterFiles() {
const query = search.value.trim().toLocaleLowerCase();
function setTheme(theme) { const rows = [...document.querySelectorAll('[data-file-name]')];
html.setAttribute('data-bs-theme', theme); let visible = 0;
document.body.classList.remove('theme-light', 'theme-dark'); rows.forEach(row => {
document.body.classList.add('theme-' + theme); row.hidden = !row.dataset.fileName.toLocaleLowerCase().includes(query) || (typeFilter.value !== 'all' && row.dataset.kind !== typeFilter.value);
localStorage.setItem('theme', theme); if (!row.hidden) visible++;
if (theme === 'dark') {
themeText.textContent = 'Light Mode';
themeIcon.className = 'bi bi-sun';
themeToggle.classList.remove('btn-dark');
themeToggle.classList.add('btn-light');
} else {
themeText.textContent = 'Dark Mode';
themeIcon.className = 'bi bi-moon';
themeToggle.classList.remove('btn-light');
themeToggle.classList.add('btn-dark');
}
}
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme) {
setTheme(savedTheme);
} else {
setTheme(prefersDark ? 'dark' : 'light');
}
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}); });
}); document.getElementById('fileCount').textContent = visible + ' of ' + rows.length + ' items';
document.getElementById('emptySearch').hidden = visible !== 0;
}
search?.addEventListener('input', filterFiles);
typeFilter?.addEventListener('change', filterFiles);
if (search) filterFiles();
document.addEventListener('keydown', event => {
if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey && !event.target.closest('input, textarea, select, [contenteditable], dialog')) {
event.preventDefault();
search?.focus();
}
});
+108
View File
@@ -0,0 +1,108 @@
<?php
// Pure parsers are kept separate so unavailable or malformed SSH output can be tested.
function getDiskData($ssh, $path) {
// The command runs on Linux even when PHP runs on Windows. escapeshellarg()
// follows the local OS, so quote explicitly for the remote POSIX shell.
$quotedPath = "'" . str_replace("'", "'\\''", $path) . "'";
return parseDiskData($ssh->exec('LC_ALL=C df -Pk -- ' . $quotedPath . ' 2>/dev/null'));
}
function getSystemData($ssh, $storagePath) {
$meminfo = $ssh->exec("LC_ALL=C free -m | awk '/^Mem:/{printf \"%s,%s,%s,\", \$2,\$3,\$7} /^Swap:/{printf \"%s,%s,%s\", \$2,\$3,\$4}'");
$cpuIdle = $ssh->exec("LC_ALL=C vmstat 1 2 | tail -1 | awk '{print \$15}'");
return [
'storage' => [
'system' => getDiskData($ssh, '/'),
'data' => getDiskData($ssh, $storagePath),
],
'raid' => parseRaidStatus($ssh->exec('cat /proc/mdstat 2>/dev/null')),
'resources' => parseSystemStats($cpuIdle, $meminfo),
'lastUpdate' => date('Y-m-d H:i:s'),
];
}
function parseDiskData($output) {
$unavailable = ['available_data' => false, 'size' => 'N/A', 'used' => 'N/A', 'available' => 'N/A', 'usage' => 'N/A', 'filesystem' => ''];
if (!is_string($output)) return $unavailable;
foreach (preg_split('/\r?\n/', trim($output)) as $line) {
// POSIX df -Pk gives one filesystem per line and sizes in KiB.
if (!preg_match('/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/', trim($line), $parts)) continue;
return [
'available_data' => true,
'filesystem' => $parts[1],
'size' => formatBytes((float) $parts[2] * 1024),
'used' => formatBytes((float) $parts[3] * 1024),
'available' => formatBytes((float) $parts[4] * 1024),
'usage' => $parts[5] . '%',
];
}
return $unavailable;
}
function parseRaidStatus($output) {
$status = ['active' => false, 'status' => 'unavailable', 'type' => 'N/A', 'arrays' => []];
if (!is_string($output) || trim($output) === '') return $status;
preg_match_all('/^(md\S+)\s*:\s*(active|inactive)\s+([^\r\n]+)((?:\r?\n[ \t]+[^\r\n]*)*)/m', $output, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$details = $match[3] . $match[4];
preg_match('/\b(raid\d+|linear|multipath)\b/', $match[3], $level);
$state = $match[2] === 'active' ? 'unknown' : 'inactive';
if ($state !== 'inactive') {
if (preg_match('/\[([U_]+)\]/', $details, $members)) {
$state = strpos($members[1], '_') !== false ? 'degraded' : 'healthy';
} elseif (in_array($level[1] ?? '', ['raid0', 'linear'], true)) {
$state = 'active'; // Non-redundant arrays do not expose a [UU] health bitmap.
}
if (preg_match('/\b(recovery|resync|reshape|check|repair)\s*=/', $details)) {
if ($state !== 'degraded') $state = 'syncing';
}
if (strpos($details, '(F)') !== false) $state = 'degraded';
}
$status['arrays'][] = ['name' => $match[1], 'type' => $level[1] ?? 'N/A', 'status' => $state];
}
if (!$status['arrays']) {
$status['status'] = strpos($output, 'Personalities :') !== false ? 'not configured' : 'unavailable';
return $status;
}
$states = array_column($status['arrays'], 'status');
$status['active'] = !in_array('inactive', $states, true);
$status['type'] = implode(', ', array_unique(array_column($status['arrays'], 'type')));
foreach (['inactive', 'degraded', 'unknown', 'syncing', 'active', 'healthy'] as $state) {
if (in_array($state, $states, true)) { $status['status'] = $state; break; }
}
return $status;
}
function parseSystemStats($cpuOutput, $memOutput) {
$idle = is_string($cpuOutput) ? str_replace(',', '.', trim($cpuOutput)) : '';
$cpuAvailable = is_numeric($idle) && (float) $idle >= 0 && (float) $idle <= 100;
$cpu = ['available_data' => $cpuAvailable, 'usage' => $cpuAvailable ? number_format(100 - (float) $idle, 1, '.', '') : null];
$memory = array_fill_keys(['total', 'used', 'available', 'usage', 'swap_total', 'swap_used', 'swap_free', 'swap_usage'], 'N/A');
$memory['available_data'] = false;
$parts = is_string($memOutput) ? explode(',', trim($memOutput)) : [];
if (count($parts) === 6 && count(array_filter($parts, 'is_numeric')) === 6 && min(array_map('floatval', $parts)) >= 0 && (float) $parts[0] > 0) {
[$total, $used, $available, $swapTotal, $swapUsed, $swapFree] = array_map('floatval', $parts);
$memory = [
'available_data' => true,
'total' => formatBytes($total * 1024 * 1024),
'used' => formatBytes($used * 1024 * 1024),
'available' => formatBytes($available * 1024 * 1024),
'usage' => min(100, round($used / $total * 100, 1)) . '%',
'swap_total' => formatBytes($swapTotal * 1024 * 1024),
'swap_used' => formatBytes($swapUsed * 1024 * 1024),
'swap_free' => formatBytes($swapFree * 1024 * 1024),
'swap_usage' => ($swapTotal > 0 ? min(100, round($swapUsed / $swapTotal * 100, 1)) : 0) . '%',
];
}
return ['cpu' => $cpu, 'memory' => $memory];
}
function formatBytes($bytes) {
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
$bytes = max((float) $bytes, 0);
$power = min($bytes > 0 ? (int) floor(log($bytes, 1024)) : 0, count($units) - 1);
return round($bytes / pow(1024, $power), 2) . ' ' . $units[$power];
}
+40 -39
View File
@@ -13,6 +13,7 @@ if(!isset($_SESSION['uname'])){
} }
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
$filePath = isset($_GET['file']) ? $_GET['file'] : ''; $filePath = isset($_GET['file']) ? $_GET['file'] : '';
@@ -37,7 +38,7 @@ if (!empty($filePath) && $sftp->file_exists($filePath) && !$sftp->is_dir($filePa
$saveError = "Failed to save changes. Check permissions."; $saveError = "Failed to save changes. Check permissions.";
} }
} }
$content = $sftp->get($filePath); $content = $sftp->get($filePath);
if ($content === false) { if ($content === false) {
$error = "Failed to read file contents."; $error = "Failed to read file contents.";
@@ -54,42 +55,41 @@ $showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css',
$parentDir = dirname($filePath); $parentDir = dirname($filePath);
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Edit File - <?= htmlspecialchars($fileName) ?></title> <title>Edit File - <?= htmlspecialchars($fileName) ?></title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/open.css') ?>">
<link rel="stylesheet" href="css/open.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'File editor',
<span id="themeText"></span> 'eyebrow' => 'File editor',
</button> 'title' => $fileName,
</div> 'description' => 'View and edit text files in your remote workspace.',
<div class="custom-container dark-box"> 'connected' => true,
<div class="header-container"> 'back' => 'index.php?path=' . urlencode($parentDir),
<h1>Edit File: <?= htmlspecialchars($fileName) ?></h1> 'class' => 'editor-page',
<a href="index.php?path=<?= urlencode($parentDir) ?>" class="btn btn-secondary">Back to File List</a> ]); ?>
</div> <section class="content-panel">
<?php if (isset($error)): ?> <?php if (isset($error)): ?>
<div class="alert alert-danger"><?= $error ?></div> <div class="alert alert-danger"><?= $error ?></div>
<?php elseif ($editable): ?> <?php elseif ($editable): ?>
<?php if (isset($saveSuccess)): ?> <?php if (isset($saveSuccess)): ?>
<div class="alert alert-success">File saved successfully!</div> <div class="alert alert-success">File saved successfully!</div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($saveError)): ?> <?php if (isset($saveError)): ?>
<div class="alert alert-danger"><?= $saveError ?></div> <div class="alert alert-danger"><?= $saveError ?></div>
<?php endif; ?> <?php endif; ?>
<form method="POST" id="editorForm"> <form method="POST" id="editorForm">
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
@@ -105,7 +105,7 @@ $parentDir = dirname($filePath);
<textarea id="editor" name="content" class="form-control" <?= (!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) ? 'readonly' : '' ?>><?= htmlspecialchars($content) ?></textarea> <textarea id="editor" name="content" class="form-control" <?= (!isset($_SESSION["upPer"]) || $_SESSION["upPer"] != true) ? 'readonly' : '' ?>><?= htmlspecialchars($content) ?></textarea>
</div> </div>
</div> </div>
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true): ?> <?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true): ?>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary">Save Changes</button>
@@ -113,54 +113,55 @@ $parentDir = dirname($filePath);
<?php endif; ?> <?php endif; ?>
</form> </form>
<?php endif; ?> <?php endif; ?>
</div> </section>
<script src="../../js/theme.js"></script> <?php renderWorkspaceEnd(); ?>
<script> <script>
function updateLineNumbers() { function updateLineNumbers() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (!lineNumbers) return; if (!lineNumbers) return;
const lines = editor.value.split('\n'); const lines = editor.value.split('\n');
const lineCount = lines.length; const lineCount = lines.length;
let html = ''; let html = '';
for (let i = 1; i <= lineCount; i++) { for (let i = 1; i <= lineCount; i++) {
html += i + '<br>'; html += i + '<br>';
} }
lineNumbers.innerHTML = html; lineNumbers.innerHTML = html;
syncLineNumbersHeight(); syncLineNumbersHeight();
lineNumbers.scrollTop = editor.scrollTop; lineNumbers.scrollTop = editor.scrollTop;
} }
function syncLineNumbersHeight() { function syncLineNumbersHeight() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (!lineNumbers || !editor) return; if (!lineNumbers || !editor) return;
lineNumbers.style.height = editor.clientHeight + 'px'; lineNumbers.style.height = editor.clientHeight + 'px';
} }
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const editor = document.getElementById('editor'); const editor = document.getElementById('editor');
const lineNumbers = document.getElementById('lineNumbers'); const lineNumbers = document.getElementById('lineNumbers');
if (editor && lineNumbers) { if (editor && lineNumbers) {
setTimeout(() => { setTimeout(() => {
updateLineNumbers(); updateLineNumbers();
syncLineNumbersHeight(); syncLineNumbersHeight();
}, 0); }, 0);
editor.addEventListener('input', updateLineNumbers); editor.addEventListener('input', updateLineNumbers);
editor.addEventListener('keydown', function(e) { editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
const start = this.selectionStart; const start = this.selectionStart;
const end = this.selectionEnd; const end = this.selectionEnd;
@@ -183,10 +184,10 @@ $parentDir = dirname($filePath);
const observer = new MutationObserver(function(mutations) { const observer = new MutationObserver(function(mutations) {
syncLineNumbersHeight(); syncLineNumbersHeight();
}); });
observer.observe(editor, { observer.observe(editor, {
attributes: true, attributes: true,
attributeFilter: ['style'] attributeFilter: ['style']
}); });
if (typeof ResizeObserver === 'function') { if (typeof ResizeObserver === 'function') {
+75 -84
View File
@@ -8,6 +8,7 @@ if(!isset($_SESSION['uname'])){
} }
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if (!isset($_GET['file'])) { if (!isset($_GET['file'])) {
@@ -38,11 +39,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -77,13 +78,13 @@ if (isset($_GET['stream'])) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
} }
@@ -111,35 +112,34 @@ if (isset($_GET['stream'])) {
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Viewer</title> <title>PDF Viewer</title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/pdf.css') ?>">
<link rel="stylesheet" href="css/pdf.css">
<script src="../../js/bootstrap.bundle.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.js"></script>
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'PDF viewer',
<span id="themeText"></span> 'eyebrow' => 'Document preview',
</button> 'title' => $fileName,
</div> 'description' => 'Browse your document without leaving the workspace.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="text-center border-bottom m-5"> 'back' => 'index.php?path=' . urlencode(dirname($filePath)),
<h1 class="mb-4">PDF Viewer</h1> 'class' => 'pdf-page',
<div class="mb-3"> ]); ?>
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
</div>
</header>
<section class="content-panel pdf-workspace" aria-label="PDF document">
<div id="pdfContainer" class="container-fluid position-relative"> <div id="pdfContainer" class="container-fluid position-relative">
<div id="loadingMessage" class="d-flex align-items-center justify-content-center w-100 h-100"> <div id="loadingMessage" class="d-flex align-items-center justify-content-center w-100 h-100">
<div class="bg-dark bg-opacity-75 text-white p-3 rounded"> <div class="bg-dark bg-opacity-75 text-white p-3 rounded">
@@ -151,17 +151,17 @@ if (isset($_GET['stream'])) {
<div class="controls"> <div class="controls">
<div class="btn-group" role="group" aria-label="PDF Navigation"> <div class="btn-group" role="group" aria-label="PDF Navigation">
<button id="prev" class="btn btn-light border"> <button id="prev" class="btn btn-light border" disabled>
<i class="bi bi-chevron-left"></i> Previous <i class="ui-icon icon-chevron-left"></i> Previous
</button> </button>
<button class="btn btn-light border disabled"> <span class="btn btn-light border" role="status">
Page <span id="pageNum"></span> of <span id="pageCount"></span> Page <span id="pageNum"></span> of <span id="pageCount"></span>
</span>
<button id="next" class="btn btn-light border" disabled>
Next <i class="ui-icon icon-chevron-right"></i>
</button> </button>
<button id="next" class="btn btn-light border"> <button id="fullscreen" aria-label="Toggle fullscreen" class="btn btn-light border">
Next <i class="bi bi-chevron-right"></i> <i class="ui-icon icon-fullscreen"></i>
</button>
<button id="fullscreen" class="btn btn-light border">
<i class="bi bi-fullscreen"></i>
</button> </button>
</div> </div>
</div> </div>
@@ -171,26 +171,14 @@ if (isset($_GET['stream'])) {
<?php endif; ?> <?php endif; ?>
</div> </div>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> </section>
<span class="text-muted">Developed by Michal Sedlák</span> <?php renderWorkspaceEnd(); ?>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script> <script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const url = 'pdf.php?file=<?= urlencode($filePath) ?>&stream=1'; const url = 'pdf.php?file=<?= urlencode($filePath) ?>&stream=1';
const pdfFileName = '<?= htmlspecialchars($fileName) ?>'; const pdfFileName = <?= json_encode($fileName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
let currentPage = 1; let currentPage = 1;
let pdfDoc = null; let pdfDoc = null;
@@ -198,46 +186,47 @@ if (isset($_GET['stream'])) {
getStorageKey() { getStorageKey() {
return `pdf_page_${pdfFileName}`; return `pdf_page_${pdfFileName}`;
}, },
savePage(pageNum) { savePage(pageNum) {
localStorage.setItem(this.getStorageKey(), pageNum.toString()); localStorage.setItem(this.getStorageKey(), pageNum.toString());
}, },
loadPage() { loadPage() {
return parseInt(localStorage.getItem(this.getStorageKey())) || 1; return parseInt(localStorage.getItem(this.getStorageKey())) || 1;
} }
}; };
async function loadPDF() { async function loadPDF() {
try { try {
pdfDoc = await pdfjsLib.getDocument(url).promise; pdfDoc = await pdfjsLib.getDocument(url).promise;
document.getElementById('pageCount').textContent = pdfDoc.numPages; document.getElementById('pageCount').textContent = pdfDoc.numPages;
currentPage = Math.min(PdfPageStorage.loadPage(), pdfDoc.numPages); currentPage = Math.min(PdfPageStorage.loadPage(), pdfDoc.numPages);
renderPage(currentPage); await renderPage(currentPage);
document.getElementById('loadingMessage').classList.add('d-none');
} catch (error) { } catch (error) {
console.error('Error loading PDF:', error); console.error('Error loading PDF:', error);
const loadingMessage = document.getElementById('loadingMessage'); const loadingMessage = document.getElementById('loadingMessage');
loadingMessage.querySelector('div').classList.add('bg-danger'); if (loadingMessage) {
loadingMessage.querySelector('div').innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error loading PDF'; loadingMessage.querySelector('div').className = 'alert alert-danger';
loadingMessage.querySelector('div').textContent = 'Unable to load this PDF.';
}
} }
} }
async function renderPage(pageNumber) { async function renderPage(pageNumber) {
const page = await pdfDoc.getPage(pageNumber); const page = await pdfDoc.getPage(pageNumber);
const pageContainer = document.createElement('div'); const pageContainer = document.createElement('div');
pageContainer.className = 'page-container'; pageContainer.className = 'page-container';
const windowWidth = window.innerWidth; const windowWidth = document.getElementById('pdfContainer').clientWidth - 32;
const windowHeight = window.innerHeight; const windowHeight = window.innerHeight;
const viewport = page.getViewport({ scale: 1.0 }); const viewport = page.getViewport({ scale: 1.0 });
const pixelRatio = window.devicePixelRatio || 1; const pixelRatio = window.devicePixelRatio || 1;
const widthScale = (windowWidth / viewport.width); const widthScale = (windowWidth / viewport.width);
const heightScale = (windowHeight / viewport.height); const heightScale = (windowHeight / viewport.height);
let scale; let scale;
if (isFullscreen) { if (isFullscreen) {
scale = Math.min(widthScale, heightScale) * 0.95; scale = Math.min(widthScale, heightScale) * 0.95;
@@ -246,28 +235,30 @@ if (isset($_GET['stream'])) {
} else { } else {
scale = Math.min(widthScale, heightScale, 2); scale = Math.min(widthScale, heightScale, 2);
} }
const scaledViewport = page.getViewport({ scale: scale * pixelRatio }); const scaledViewport = page.getViewport({ scale: scale * pixelRatio });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
canvas.height = scaledViewport.height; canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width; canvas.width = scaledViewport.width;
canvas.style.width = `${scaledViewport.width / pixelRatio}px`; canvas.style.width = `${scaledViewport.width / pixelRatio}px`;
canvas.style.height = `${scaledViewport.height / pixelRatio}px`; canvas.style.height = `${scaledViewport.height / pixelRatio}px`;
pageContainer.appendChild(canvas); pageContainer.appendChild(canvas);
document.getElementById('pdfContainer').innerHTML = ''; document.getElementById('pdfContainer').innerHTML = '';
document.getElementById('pdfContainer').appendChild(pageContainer); document.getElementById('pdfContainer').appendChild(pageContainer);
document.getElementById('pageNum').textContent = pageNumber; document.getElementById('pageNum').textContent = pageNumber;
document.getElementById('prev').disabled = pageNumber <= 1;
document.getElementById('next').disabled = pageNumber >= pdfDoc.numPages;
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
viewport: scaledViewport // Use scaledViewport instead of viewport viewport: scaledViewport // Use scaledViewport instead of viewport
}).promise; }).promise;
PdfPageStorage.savePage(pageNumber); PdfPageStorage.savePage(pageNumber);
} }
document.getElementById('prev').addEventListener('click', () => { document.getElementById('prev').addEventListener('click', () => {
@@ -276,9 +267,9 @@ if (isset($_GET['stream'])) {
renderPage(currentPage); renderPage(currentPage);
} }
}); });
document.getElementById('next').addEventListener('click', () => { document.getElementById('next').addEventListener('click', () => {
if (currentPage < pdfDoc.numPages) { if (pdfDoc && currentPage < pdfDoc.numPages) {
currentPage++; currentPage++;
renderPage(currentPage); renderPage(currentPage);
} }
@@ -290,14 +281,14 @@ if (isset($_GET['stream'])) {
} else if (e.key === 'ArrowLeft' && currentPage > 1) { } else if (e.key === 'ArrowLeft' && currentPage > 1) {
currentPage--; currentPage--;
renderPage(currentPage); renderPage(currentPage);
} else if (e.key === 'ArrowRight' && currentPage < pdfDoc.numPages) { } else if (e.key === 'ArrowRight' && pdfDoc && currentPage < pdfDoc.numPages) {
currentPage++; currentPage++;
renderPage(currentPage); renderPage(currentPage);
} }
}); });
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (currentPage) { if (pdfDoc) {
renderPage(currentPage); renderPage(currentPage);
} }
}); });
@@ -305,28 +296,28 @@ if (isset($_GET['stream'])) {
let isFullscreen = false; let isFullscreen = false;
document.getElementById('fullscreen').addEventListener('click', () => { document.getElementById('fullscreen').addEventListener('click', () => {
const container = document.querySelector('.custom-container'); const container = document.querySelector('.pdf-workspace');
const fullscreenBtn = document.getElementById('fullscreen'); const fullscreenBtn = document.getElementById('fullscreen');
const fullscreenIcon = fullscreenBtn.querySelector('i'); const fullscreenIcon = fullscreenBtn.querySelector('i');
if (!isFullscreen) { if (!isFullscreen) {
container.classList.add('fullscreen-mode'); container.classList.add('fullscreen-mode');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
fullscreenIcon.classList.remove('bi-fullscreen'); fullscreenIcon.classList.remove('icon-fullscreen');
fullscreenIcon.classList.add('bi-fullscreen-exit'); fullscreenIcon.classList.add('icon-fullscreen-exit');
} else { } else {
container.classList.remove('fullscreen-mode'); container.classList.remove('fullscreen-mode');
document.body.style.overflow = ''; document.body.style.overflow = '';
fullscreenIcon.classList.remove('bi-fullscreen-exit'); fullscreenIcon.classList.remove('icon-fullscreen-exit');
fullscreenIcon.classList.add('bi-fullscreen'); fullscreenIcon.classList.add('icon-fullscreen');
} }
isFullscreen = !isFullscreen; isFullscreen = !isFullscreen;
renderPage(currentPage); if (pdfDoc) renderPage(currentPage);
}); });
loadPDF(); loadPDF();
</script> </script>
<script src="../../js/theme.js"></script>
</body> </body>
</html> </html>
+103 -377
View File
@@ -12,47 +12,20 @@ if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) {
exit(); exit();
} }
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
use phpseclib3\Net\SSH2;
// $ssh_host = 'localhost';
// $ssh_username = 'uname';
// $ssh_password = 'pswd';
// The configuration helper authenticates using SSH_HOST, SSH_USER and SSH_PASS.
$ssh = getSSHConnection(); $ssh = getSSHConnection();
if (!$ssh->login($ssh_username, $ssh_password)) {
die('SSH login failed');
}
function getDiskData($ssh, $device) { require_once __DIR__ . '/lib/serverstats.php';
return $ssh->exec("df -h $device");
}
function getSystemData($ssh) { $systemData = getSystemData($ssh, $defaultPath);
// Get memory info using awk to clean up the output
$meminfo = $ssh->exec("free -m | awk 'NR==2{printf \"%s,%s,%s,\", $2,$3,$7} NR==3{printf \"%s,%s,%s\", $2,$3,$4}'");
// Use vmstat to get CPU idle percentage, more reliable and commonly available
$cpuIdle = $ssh->exec("vmstat 1 2 | tail -1 | awk '{print $15}'");
return [
'storage' => [
'system' => parseDiskData($ssh->exec('df -h /dev/ubuntu-vg/ubuntu-lv')),
'data' => parseDiskData($ssh->exec('df -h /dev/sdb1'))
],
'raid' => parseRaidStatus($ssh->exec('cat /proc/mdstat')),
'resources' => parseSystemStats($cpuIdle, $meminfo),
'lastUpdate' => date('Y-m-d H:i:s')
];
}
$systemData = getSystemData($ssh);
$monitors = [ $monitors = [
[ [
'id' => 'SystemDisk', 'id' => 'SystemDisk',
'title' => 'System Disk (sda)', 'title' => 'System filesystem',
'icon' => 'hdd', 'icon' => 'hdd',
'color' => 'primary', 'color' => 'primary',
'type' => 'storage', 'type' => 'storage',
@@ -60,7 +33,7 @@ $monitors = [
], ],
[ [
'id' => 'DataDisk', 'id' => 'DataDisk',
'title' => 'Data Disk (sdb)', 'title' => 'Storage filesystem',
'icon' => 'hdd', 'icon' => 'hdd',
'color' => 'primary', 'color' => 'primary',
'type' => 'storage', 'type' => 'storage',
@@ -91,349 +64,101 @@ $monitors = [
] ]
]; ];
function parseDiskData($output) {
$lines = explode("\n", trim($output));
if (count($lines) < 2) return null;
$values = preg_split('/\s+/', trim($lines[1]));
return [
'size' => $values[1] ?? 'N/A',
'used' => $values[2] ?? 'N/A',
'available' => $values[3] ?? 'N/A',
'usage' => $values[4] ?? '0%'
];
}
function parseRaidStatus($output) {
if (empty($output)) {
return [
'active' => false,
'status' => 'unknown',
'type' => 'N/A'
];
}
$status = [
'active' => false,
'status' => 'unknown',
'type' => 'N/A'
];
$lines = explode("\n", trim($output));
foreach ($lines as $line) {
if (preg_match('/active\s+(\w+)/', $line, $matches)) {
$status['active'] = true;
$status['type'] = $matches[1] ?? 'N/A';
}
if (strpos($line, '[UU]') !== false) {
$status['status'] = 'healthy';
} elseif (strpos($line, '_') !== false) {
$status['status'] = 'degraded';
}
}
return $status;
}
function parseSystemStats($cpuOutput, $memOutput) {
// Parse CPU stats - convert idle percentage to usage percentage
$idlePercent = floatval(trim(str_replace(',', '.', $cpuOutput)));
$cpuUsage = 100 - $idlePercent; // Convert idle to usage percentage
if (is_nan($cpuUsage) || $cpuUsage < 0 || $cpuUsage > 100) {
$cpuUsage = 0; // Default to 0% if parsing fails
}
$cpu = ['usage' => number_format($cpuUsage, 1)];
// Parse memory stats
$parts = explode(',', $memOutput);
if (count($parts) >= 6) {
$memTotal = intval($parts[0]);
$memUsed = intval($parts[1]);
$memAvail = intval($parts[2]);
$swapTotal = intval($parts[3]);
$swapUsed = intval($parts[4]);
$swapFree = intval($parts[5]);
$memory = [
'total' => number_format($memTotal / 1024, 2) . 'G',
'used' => number_format($memUsed / 1024, 2) . 'G',
'available' => number_format($memAvail / 1024, 2) . 'G',
'usage' => ($memTotal > 0 ? round(($memUsed / $memTotal) * 100, 1) : 0) . '%',
'swap_total' => number_format($swapTotal / 1024, 2) . 'G',
'swap_used' => number_format($swapUsed / 1024, 2) . 'G',
'swap_free' => number_format($swapFree / 1024, 2) . 'G',
'swap_usage' => ($swapTotal > 0 ? round(($swapUsed / $swapTotal) * 100, 1) : 0) . '%'
];
} else {
$memory = [
'total' => '0G',
'used' => '0G',
'available' => '0G',
'usage' => '0%',
'swap_total' => '0G',
'swap_used' => '0G',
'swap_free' => '0G',
'swap_usage' => '0%'
];
}
return ['cpu' => $cpu, 'memory' => $memory];
}
function convertToBytes($size) {
if (preg_match('/^([\d.]+)([KMGT]?)i?B?$/', trim($size), $matches)) {
$value = floatval($matches[1]);
$unit = strtoupper($matches[2]);
switch ($unit) {
case 'P': $value *= 1024;
case 'T': $value *= 1024;
case 'G': $value *= 1024;
case 'M': $value *= 1024;
case 'K': $value *= 1024;
}
return $value;
}
return 0;
}
function formatBytes($bytes, $forceUnit = '') {
$units = ['B', 'K', 'M', 'G', 'T', 'P'];
$bytes = max($bytes, 0);
if ($forceUnit) {
$unitIndex = array_search($forceUnit, $units);
if ($unitIndex !== false) {
$bytes /= pow(1024, $unitIndex);
return round($bytes, 2) . $forceUnit;
}
}
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2) . $units[$pow];
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Server Status</title> <title>Server Status</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/serverstat.css') ?>">
<link rel="stylesheet" href="css/serverstat.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="container-fluid text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'server',
<i class="bi"></i> 'section' => 'Server status',
<span id="themeText"></span> 'eyebrow' => 'Server overview',
</button> 'title' => 'Server status',
</div> 'description' => 'Keep an eye on your storage, RAID health and system resources.',
'connected' => true,
'class' => 'server-page',
]); ?>
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>Server Status</h1>
<div class="mb-3 p-3">
<a href="../logout.php" class="btn btn-danger">Logout</a>
<a href="../changepassword.php" class="btn btn-warning">Change Password</a>
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a>
<a href="index.php" class="btn btn-primary">SFTP</a>
</div>
</header>
<div class="container py-4"> <div class="content-toolbar"><p>Live statistics · refreshes every 5 seconds</p><span class="badge bg-light text-dark"><i class="ui-icon icon-clock" aria-hidden="true"></i> <?= htmlspecialchars($systemData['lastUpdate']) ?></span></div>
<div class="row justify-content-center"> <div class="stats-grid">
<div class="col-lg-10"> <?php foreach ($monitors as $monitor): ?>
<div class="card shadow-sm bg-body-tertiary"> <section class="stats-cell" aria-label="<?= htmlspecialchars($monitor['title']) ?>">
<div class="card-header monitor-header"> <div class="monitor-card card h-100">
<div class="d-flex justify-content-between align-items-center"> <div class="card-header"><h3 class="mb-0"><i class="ui-icon icon-<?= $monitor['icon'] ?> text-primary me-2" aria-hidden="true"></i><?= htmlspecialchars($monitor['title']) ?></h3></div>
<h1 class="h4 mb-0"> <div class="card-body">
<i class="bi bi-server text-primary me-2"></i> <?php if ($monitor['type'] === 'storage'):
Server Disk Status $diskData = $systemData['storage'][$monitor['source']];
</h1> ?>
<span class="badge bg-light text-dark"> <?php if (!$diskData['available_data']): ?>
<i class="bi bi-clock me-1"></i> <p class="text-muted" role="status">Storage statistics are unavailable for this path.</p>
<?= htmlspecialchars($systemData['lastUpdate']) ?> <?php else:
</span> $usagePercent = min(100, (int) rtrim($diskData['usage'], '%'));
</div> ?>
</div> <p class="small text-muted"><?= htmlspecialchars($diskData['filesystem']) ?> · <?= htmlspecialchars($monitor['source'] === 'system' ? '/' : $defaultPath) ?></p>
<div class="d-flex justify-content-between"><span>Storage usage</span><strong><?= htmlspecialchars($diskData['usage']) ?></strong></div>
<div class="card-body"> <div class="progress mb-4"><div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>" role="progressbar" style="width:<?= $usagePercent ?>%" aria-label="Storage usage" aria-valuenow="<?= $usagePercent ?>" aria-valuemin="0" aria-valuemax="100"></div></div>
<div class="row g-4"> <div class="row text-center">
<?php foreach ($monitors as $monitor): ?> <?php foreach (['size' => 'Total', 'used' => 'Used', 'available' => 'Free'] as $key => $label): ?>
<div class="col-md-6"> <div class="col-4"><div class="p-2 border rounded"><div class="small text-muted"><?= $label ?></div><div class="fw-bold"><?= htmlspecialchars($diskData[$key]) ?></div></div></div>
<div class="monitor-card card h-100 border-<?= $monitor['color'] ?>"> <?php endforeach; ?>
<div class="card-header bg-<?= $monitor['color'] ?> bg-opacity-10"> </div>
<h3 class="h5 mb-0"> <?php endif; ?>
<i class="bi bi-<?= $monitor['icon'] ?> text-<?= $monitor['color'] ?> me-2"></i> <?php elseif ($monitor['type'] === 'raid'): ?>
<?= $monitor['title'] ?> <div class="raid-summary">
</h3> <i class="ui-icon icon-<?= $systemData['raid']['status'] === 'healthy' ? 'check-circle-fill text-success' : 'x-circle-fill text-warning' ?>" aria-hidden="true"></i>
</div> <h4><?= $systemData['raid']['arrays'] ? htmlspecialchars($systemData['raid']['type']) . ' Array' : 'Linux MD RAID' ?></h4>
<div class="card-body"> <span class="badge"><?= htmlspecialchars(ucfirst($systemData['raid']['status'])) ?></span>
<?php if ($monitor['type'] === 'storage'): <?php foreach ($systemData['raid']['arrays'] as $array): ?>
$diskData = $systemData['storage'][$monitor['source']]; <p class="small text-muted mt-2"><?= htmlspecialchars($array['name'] . ' · ' . $array['type'] . ' · ' . $array['status']) ?></p>
$usagePercent = intval(rtrim($diskData['usage'], '%'));
?>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span>Storage Usage</span>
<span><?= $diskData['usage'] ?></span>
</div>
<div class="progress" style="height: 10px">
<div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>"
role="progressbar"
style="width: <?= $usagePercent ?>%"
aria-valuenow="<?= $usagePercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $diskData['size'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $diskData['used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Free</div>
<div class="fw-bold"><?= $diskData['available'] ?></div>
</div>
</div>
</div>
<?php elseif ($monitor['type'] === 'raid'): ?>
<div class="text-center">
<div class="display-4 mb-2">
<i class="bi bi-<?= $systemData['raid']['active'] ? 'check-circle-fill text-success' : 'x-circle-fill text-danger' ?>"></i>
</div>
<h4 class="h6"><?= $systemData['raid']['type'] ?> Array</h4>
<span class="badge bg-<?= $systemData['raid']['status'] === 'healthy' ? 'success' : 'warning' ?>">
<?= ucfirst($systemData['raid']['status']) ?>
</span>
</div>
<?php elseif ($monitor['type'] === 'resources' && $monitor['source'] === 'cpu'): ?>
<div class="cpu-gauge">
<canvas id="cpuGauge" width="150" height="150" data-value="<?= $systemData['resources']['cpu']['usage'] ?>"></canvas>
</div>
<?php elseif ($monitor['type'] === 'resources' && $monitor['source'] === 'memory'):
$memData = $systemData['resources']['memory'];
$usagePercent = floatval(rtrim($memData['usage'], '%'));
$swapPercent = floatval(rtrim($memData['swap_usage'], '%'));
?>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span>Memory Usage</span>
<span><?= $memData['usage'] ?></span>
</div>
<div class="progress" style="height: 10px">
<div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>"
role="progressbar"
style="width: <?= $usagePercent ?>%"
aria-valuenow="<?= $usagePercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2 mb-3">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $memData['total'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $memData['used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Available</div>
<div class="fw-bold"><?= $memData['available'] ?></div>
</div>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span>Swap Usage</span>
<span><?= $memData['swap_usage'] ?></span>
</div>
<div class="progress" style="height: 10px">
<div class="progress-bar bg-<?= $swapPercent > 90 ? 'danger' : ($swapPercent > 75 ? 'warning' : 'success') ?>"
role="progressbar"
style="width: <?= $swapPercent ?>%"
aria-valuenow="<?= $swapPercent ?>"
aria-valuemin="0"
aria-valuemax="100">
</div>
</div>
</div>
<div class="row text-center g-2">
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Total</div>
<div class="fw-bold"><?= $memData['swap_total'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Used</div>
<div class="fw-bold"><?= $memData['swap_used'] ?></div>
</div>
</div>
<div class="col-4">
<div class="p-2 border rounded">
<div class="small text-muted">Free</div>
<div class="fw-bold"><?= $memData['swap_free'] ?></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> <?php elseif ($monitor['source'] === 'cpu'): ?>
<?php if (!$systemData['resources']['cpu']['available_data']): ?>
<p class="text-muted" role="status">CPU statistics are unavailable.</p>
<?php else: ?>
<div class="cpu-gauge"><canvas id="cpuGauge" width="170" height="170" data-value="<?= $systemData['resources']['cpu']['usage'] ?>" aria-label="CPU usage <?= $systemData['resources']['cpu']['usage'] ?> percent"></canvas></div>
<?php endif; ?>
<?php else:
$memData = $systemData['resources']['memory'];
?>
<?php if (!$memData['available_data']): ?>
<p class="text-muted" role="status">Memory statistics are unavailable.</p>
<?php else: ?>
<?php foreach (['usage' => 'Memory usage', 'swap_usage' => 'Swap usage'] as $usageKey => $label):
$usagePercent = (float) rtrim($memData[$usageKey], '%');
$fields = $usageKey === 'usage' ? ['total' => 'Total', 'used' => 'Used', 'available' => 'Available'] : ['swap_total' => 'Total', 'swap_used' => 'Used', 'swap_free' => 'Free'];
?>
<div class="resource-section">
<div class="d-flex justify-content-between"><span><?= $label ?></span><strong><?= htmlspecialchars($memData[$usageKey]) ?></strong></div>
<div class="progress mb-3"><div class="progress-bar bg-<?= $usagePercent > 90 ? 'danger' : ($usagePercent > 75 ? 'warning' : 'success') ?>" role="progressbar" style="width:<?= $usagePercent ?>%" aria-label="<?= $label ?>" aria-valuenow="<?= $usagePercent ?>" aria-valuemin="0" aria-valuemax="100"></div></div>
<div class="row text-center">
<?php foreach ($fields as $key => $fieldLabel): ?>
<div class="col-4"><div class="p-2 border rounded"><div class="small text-muted"><?= $fieldLabel ?></div><div class="fw-bold"><?= htmlspecialchars($memData[$key]) ?></div></div></div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </section>
<?php endforeach; ?>
</div> </div>
<?php renderWorkspaceEnd(); ?>
</div>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
<script src="../../js/theme.js"></script>
<script> <script>
let refreshInterval; let refreshInterval;
let isRefreshing = false; let isRefreshing = false;
@@ -441,13 +166,13 @@ let isRefreshing = false;
function updateData(html) { function updateData(html) {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html'); const doc = parser.parseFromString(html, 'text/html');
// Update monitor cards // Update monitor cards
document.querySelectorAll('.monitor-card').forEach((card, index) => { document.querySelectorAll('.monitor-card').forEach((card, index) => {
const newCard = doc.querySelectorAll('.monitor-card')[index]; const newCard = doc.querySelectorAll('.monitor-card')[index];
if (newCard && card.querySelector('.card-body')) { if (newCard && card.querySelector('.card-body')) {
card.querySelector('.card-body').innerHTML = newCard.querySelector('.card-body').innerHTML; card.querySelector('.card-body').innerHTML = newCard.querySelector('.card-body').innerHTML;
// Redraw CPU gauge if this is the CPU card // Redraw CPU gauge if this is the CPU card
if (card.querySelector('.cpu-gauge')) { if (card.querySelector('.cpu-gauge')) {
const cpuValue = parseFloat(newCard.querySelector('canvas').getAttribute('data-value') || '0'); const cpuValue = parseFloat(newCard.querySelector('canvas').getAttribute('data-value') || '0');
@@ -455,7 +180,7 @@ function updateData(html) {
} }
} }
}); });
// Update last update time // Update last update time
const newLastUpdate = doc.querySelector('.badge.bg-light.text-dark')?.innerHTML; const newLastUpdate = doc.querySelector('.badge.bg-light.text-dark')?.innerHTML;
const currentLastUpdate = document.querySelector('.badge.bg-light.text-dark'); const currentLastUpdate = document.querySelector('.badge.bg-light.text-dark');
@@ -463,11 +188,6 @@ function updateData(html) {
currentLastUpdate.innerHTML = newLastUpdate; currentLastUpdate.innerHTML = newLastUpdate;
} }
// Update CPU gauge if needed
const cpuValue = document.querySelector('.cpu-gauge .h4')?.textContent;
if (cpuValue) {
drawCpuGauge(parseFloat(cpuValue));
}
} }
function startRefresh() { function startRefresh() {
@@ -476,7 +196,10 @@ function startRefresh() {
if (!isRefreshing) { if (!isRefreshing) {
isRefreshing = true; isRefreshing = true;
fetch(window.location.href) fetch(window.location.href)
.then(response => response.text()) .then(response => {
if (!response.ok || response.redirected) throw new Error('Status refresh failed');
return response.text();
})
.then(html => { .then(html => {
updateData(html); updateData(html);
isRefreshing = false; isRefreshing = false;
@@ -506,17 +229,16 @@ document.addEventListener('visibilitychange', () => {
}); });
// Initial setup // Initial setup
startRefresh(); if (!document.hidden) startRefresh();
drawCpuGauge(parseFloat(document.querySelector('.cpu-gauge .h4')?.textContent || '0'));
function drawCpuGauge(value) { function drawCpuGauge(value) {
const canvas = document.getElementById('cpuGauge'); const canvas = document.getElementById('cpuGauge');
if (!canvas) return; if (!canvas) return;
// Ensure value is a valid number between 0 and 100 // Ensure value is a valid number between 0 and 100
value = parseFloat(value) || 0; value = parseFloat(value) || 0;
value = Math.max(0, Math.min(100, value)); value = Math.max(0, Math.min(100, value));
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const centerX = canvas.width / 2; const centerX = canvas.width / 2;
const centerY = canvas.height / 2; const centerY = canvas.height / 2;
@@ -527,14 +249,14 @@ function drawCpuGauge(value) {
// Draw background circle // Draw background circle
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.strokeStyle = getComputedStyle(document.body).getPropertyValue('--bs-border-color'); ctx.strokeStyle = getComputedStyle(document.body).getPropertyValue('--line');
ctx.lineWidth = 10; ctx.lineWidth = 10;
ctx.stroke(); ctx.stroke();
// Draw value arc // Draw value arc
const startAngle = -Math.PI / 2; const startAngle = -Math.PI / 2;
const endAngle = startAngle + (Math.PI * 2 * value / 100); const endAngle = startAngle + (Math.PI * 2 * value / 100);
ctx.beginPath(); ctx.beginPath();
ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.arc(centerX, centerY, radius, startAngle, endAngle);
ctx.strokeStyle = value > 90 ? '#dc3545' : value > 75 ? '#ffc107' : '#198754'; ctx.strokeStyle = value > 90 ? '#dc3545' : value > 75 ? '#ffc107' : '#198754';
@@ -543,7 +265,7 @@ function drawCpuGauge(value) {
// Draw percentage text inside the circle // Draw percentage text inside the circle
ctx.font = 'bold 20px Arial'; ctx.font = 'bold 20px Arial';
ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--bs-body-color'); ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--text');
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillText(`${value.toFixed(1)}%`, centerX, centerY - 10); ctx.fillText(`${value.toFixed(1)}%`, centerX, centerY - 10);
@@ -558,6 +280,10 @@ window.addEventListener('load', function() {
const cpuValue = parseFloat(document.querySelector('.cpu-gauge canvas')?.getAttribute('data-value') || '0'); const cpuValue = parseFloat(document.querySelector('.cpu-gauge canvas')?.getAttribute('data-value') || '0');
drawCpuGauge(cpuValue); drawCpuGauge(cpuValue);
}); });
new MutationObserver(() => {
const cpuValue = document.getElementById('cpuGauge')?.dataset.value;
if (cpuValue !== undefined) drawCpuGauge(cpuValue);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
</script> </script>
</body> </body>
</html> </html>
+34 -49
View File
@@ -5,6 +5,7 @@ ini_set('display_errors', 1);
error_reporting(E_ALL); error_reporting(E_ALL);
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
// getDatabaseConnection relocated to central config // getDatabaseConnection relocated to central config
@@ -98,11 +99,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -135,13 +136,13 @@ if (isset($_GET['stream'])) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
} }
@@ -175,25 +176,25 @@ if (isset($_GET['download'])) {
if (!$downloadAllowed) { if (!$downloadAllowed) {
die("Download not permitted for this share."); die("Download not permitted for this share.");
} }
// Download file code similar to download.php // Download file code similar to download.php
header("Content-Type: application/octet-stream"); header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"$fileName\""); header("Content-Disposition: attachment; filename=\"$fileName\"");
header("Content-Length: $fileSize"); header("Content-Length: $fileSize");
$minChunkSize = 64 * 1024; $minChunkSize = 64 * 1024;
$maxChunkSize = 2 * 1024 * 1024; $maxChunkSize = 2 * 1024 * 1024;
$chunkSize = 256 * 1024; $chunkSize = 256 * 1024;
$currentPosition = 0; $currentPosition = 0;
$bytesRemaining = $fileSize; $bytesRemaining = $fileSize;
set_time_limit(0); set_time_limit(0);
while ($bytesRemaining > 0) { while ($bytesRemaining > 0) {
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesRead = strlen($chunkData); $bytesRead = strlen($chunkData);
echo $chunkData; echo $chunkData;
@@ -218,35 +219,32 @@ function formatBytes($bytes, $precision = 2) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Shared Media - <?= htmlspecialchars($fileName) ?></title> <title>Shared Media - <?= htmlspecialchars($fileName) ?></title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
<link rel="stylesheet" href="css/view.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'shared',
<i class="bi"></i> 'section' => 'Shared file',
<span id="themeText"></span> 'eyebrow' => 'Shared with you',
</button> 'title' => $fileName,
</div> 'description' => 'Preview and access the file shared with you.',
<div class="custom-container dark-box"> 'guest' => true,
<header class="row border-bottom m-5"> 'class' => 'media-page',
<h1>Shared Media</h1> ]); ?>
<div class="mb-3">
<span class="badge bg-info">Shared Link</span>
<p class="text-muted small">This link will expire on <?= date('F j, Y, g:i a', strtotime($share['expiration'])) ?></p>
</div>
</header>
<section class="row">
<div class="content-toolbar"><span class="badge">Shared link</span><p>Available until <?= htmlspecialchars(date('F j, Y, g:i a', strtotime($share['expiration']))) ?></p></div>
<section class="content-panel media-panel">
<article class="col-12"> <article class="col-12">
<div class="media-container position-relative"> <div class="media-container position-relative">
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -293,33 +291,20 @@ function formatBytes($bytes, $precision = 2) {
</article> </article>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script src="../../js/theme.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const mediaElement = document.querySelector('video, audio'); const mediaElement = document.querySelector('video, audio');
if (mediaElement) { if (mediaElement) {
const savedVolume = localStorage.getItem('mediaVolume'); const savedVolume = localStorage.getItem('mediaVolume');
if (savedVolume !== null) { if (savedVolume !== null) {
mediaElement.volume = parseFloat(savedVolume); mediaElement.volume = parseFloat(savedVolume);
} }
mediaElement.addEventListener('volumechange', () => { mediaElement.addEventListener('volumechange', () => {
localStorage.setItem('mediaVolume', mediaElement.volume); localStorage.setItem('mediaVolume', mediaElement.volume);
}); });
+47 -61
View File
@@ -14,6 +14,7 @@ error_reporting(E_ALL);
ini_set('memory_limit', '512M'); ini_set('memory_limit', '512M');
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../includes/workspace.php';
$sftp = initializeSFTP(); $sftp = initializeSFTP();
if (!isset($_GET['file'])) { if (!isset($_GET['file'])) {
@@ -34,9 +35,9 @@ if (isset($_POST['createShare'])) {
$conn = getDatabaseConnection(); $conn = getDatabaseConnection();
$stmt = $conn->prepare("INSERT INTO share (token, file_path, expiration, download_allowed) VALUES (?, ?, ?, ?)"); $stmt = $conn->prepare("INSERT INTO share (token, file_path, expiration, download_allowed) VALUES (?, ?, ?, ?)");
$stmt->bind_param("sssi", $token, $filePath, $expiration, $downloadAllowed); $stmt->bind_param("sssi", $token, $filePath, $expiration, $downloadAllowed);
if ($stmt->execute()) { if ($stmt->execute()) {
$shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" . $shareUrl = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]" .
dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token"; dirname($_SERVER['PHP_SELF']) . "/shared.php?token=$token";
$_SESSION['shareCreated'] = true; $_SESSION['shareCreated'] = true;
@@ -50,7 +51,7 @@ if (isset($_POST['createShare'])) {
header("Location: view.php?file=" . urlencode($filePath) . "&shared=0"); header("Location: view.php?file=" . urlencode($filePath) . "&shared=0");
exit; exit;
} }
$stmt->close(); $stmt->close();
$conn->close(); $conn->close();
} }
@@ -142,11 +143,11 @@ if (isset($_GET['stream'])) {
$matches = []; $matches = [];
if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) { if (preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $matches)) {
$start = intval($matches[1]); $start = intval($matches[1]);
if (!empty($matches[2])) { if (!empty($matches[2])) {
$end = intval($matches[2]); $end = intval($matches[2]);
} }
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
@@ -193,18 +194,18 @@ if (isset($_GET['stream'])) {
// Calculate adaptive chunk size // Calculate adaptive chunk size
$readSize = min($chunkSize, $bytesRemaining); $readSize = min($chunkSize, $bytesRemaining);
// Get chunk from SFTP // Get chunk from SFTP
$chunkData = $sftp->get($filePath, false, $currentPosition, $readSize); $chunkData = $sftp->get($filePath, false, $currentPosition, $readSize);
if ($chunkData !== false) { if ($chunkData !== false) {
$bytesSent = strlen($chunkData); $bytesSent = strlen($chunkData);
// Output chunk // Output chunk
echo $chunkData; echo $chunkData;
$bytesRemaining -= $bytesSent; $bytesRemaining -= $bytesSent;
$currentPosition += $bytesSent; $currentPosition += $bytesSent;
// Flush buffers // Flush buffers
if (ob_get_level()) { if (ob_get_level()) {
ob_flush(); ob_flush();
@@ -253,34 +254,32 @@ if (isset($_GET['stream'])) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>"> <html lang="en">
<head> <head>
<title>Media Viewer - <?= htmlspecialchars($fileName) ?></title> <title>Media Viewer - <?= htmlspecialchars($fileName) ?></title>
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon"> <link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../../css/bootstrap.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
<link rel="stylesheet" href="../../css/modern.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
<link rel="stylesheet" href="css/view.css"> <script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
<script src="../../js/bootstrap.bundle.js"></script> <script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
</head> </head>
<body class="text-center"> <?php renderWorkspaceStart([
<div class="d-flex justify-content-end p-3"> 'root' => '../../',
<button id="themeToggle" class="btn btn-sm theme-toggle"> 'active' => 'files',
<i class="bi"></i> 'section' => 'Media preview',
<span id="themeText"></span> 'eyebrow' => 'Media preview',
</button> 'title' => $fileName,
</div> 'description' => 'Preview your media, download a copy or create a shared link.',
<div class="custom-container dark-box"> 'connected' => true,
<header class="row border-bottom m-5"> 'back' => 'index.php?path=' . urlencode(dirname($filePath)),
<h1>Media Viewer</h1> 'class' => 'media-page',
<div class="mb-3 p-3"> ]); ?>
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
</div>
</header>
<section class="row">
<section class="content-panel media-panel">
<article class="col-12"> <article class="col-12">
<div class="media-container position-relative"> <div class="media-container position-relative">
<?php if ($isImage): ?> <?php if ($isImage): ?>
@@ -305,13 +304,13 @@ if (isset($_GET['stream'])) {
<?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?> <?php if (isset($_SESSION["downPer"]) && $_SESSION["downPer"] == true) : ?>
<a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a> <a href="download.php?file=<?= urlencode($filePath) ?>" class="btn btn-success m-3">Download</a>
<?php endif; ?> <?php endif; ?>
<!-- Add Share Button --> <!-- Add Share Button -->
<button type="button" class="btn btn-info m-3" data-bs-toggle="modal" data-bs-target="#shareModal"> <button type="button" class="btn btn-info m-3" data-dialog="shareModal">
<i class="bi bi-share"></i> Share <i class="ui-icon icon-share"></i> Share
</button> </button>
</div> </div>
<!-- Share success message --> <!-- Share success message -->
<?php if ($shareCreated): ?> <?php if ($shareCreated): ?>
<div class="alert alert-success mt-3"> <div class="alert alert-success mt-3">
@@ -319,7 +318,7 @@ if (isset($_GET['stream'])) {
<div class="input-group mb-3"> <div class="input-group mb-3">
<input type="text" class="form-control" id="shareUrl" value="<?= htmlspecialchars($shareUrl) ?>" readonly> <input type="text" class="form-control" id="shareUrl" value="<?= htmlspecialchars($shareUrl) ?>" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="copyShareUrl()"> <button class="btn btn-outline-secondary" type="button" onclick="copyShareUrl()">
<i class="bi bi-clipboard"></i> Copy <i class="ui-icon icon-clipboard"></i> Copy
</button> </button>
</div> </div>
<p class="small text-muted">This link will expire on <?= date('F j, Y, g:i a', strtotime($shareExpiration)) ?></p> <p class="small text-muted">This link will expire on <?= date('F j, Y, g:i a', strtotime($shareExpiration)) ?></p>
@@ -356,33 +355,20 @@ if (isset($_GET['stream'])) {
</article> </article>
</section> </section>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3 m-3"> <?php renderWorkspaceEnd(); ?>
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="../../img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="../../img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="../../img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<!-- Share Modal --> <!-- Share Modal -->
<div class="modal fade" id="shareModal" tabindex="-1" aria-labelledby="shareModalLabel" aria-hidden="true"> <dialog class="modal" id="shareModal" tabindex="-1" aria-labelledby="shareModalLabel">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="shareModalLabel">Share "<?= htmlspecialchars($fileName) ?>"</h5> <h5 class="modal-title" id="shareModalLabel">Share "<?= htmlspecialchars($fileName) ?>"</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-dialog-close aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form method="post" action=""> <form method="post" action="">
<input type="hidden" name="filePath" value="<?= htmlspecialchars($filePath) ?>"> <input type="hidden" name="filePath" value="<?= htmlspecialchars($filePath) ?>">
<div class="mb-3"> <div class="mb-3">
<label for="duration" class="form-label">Link expires after</label> <label for="duration" class="form-label">Link expires after</label>
<select class="form-select" id="duration" name="duration"> <select class="form-select" id="duration" name="duration">
@@ -393,45 +379,45 @@ if (isset($_GET['stream'])) {
<option value="720">30 days</option> <option value="720">30 days</option>
</select> </select>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="downloadAllowed" name="downloadAllowed"> <input type="checkbox" class="form-check-input" id="downloadAllowed" name="downloadAllowed">
<label class="form-check-label" for="downloadAllowed">Allow downloading</label> <label class="form-check-label" for="downloadAllowed">Allow downloading</label>
</div> </div>
<div class="text-end"> <div class="text-end">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-dialog-close>Cancel</button>
<button type="submit" name="createShare" class="btn btn-primary">Create Share Link</button> <button type="submit" name="createShare" class="btn btn-primary">Create Share Link</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
</div> </dialog>
<script> <script>
function copyShareUrl() { function copyShareUrl() {
const shareUrl = document.getElementById('shareUrl'); const shareUrl = document.getElementById('shareUrl');
shareUrl.select(); shareUrl.select();
document.execCommand('copy'); document.execCommand('copy');
// Show feedback // Show feedback
const button = shareUrl.nextElementSibling; const button = shareUrl.nextElementSibling;
const originalText = button.innerHTML; const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Copied!'; button.innerHTML = '<i class="ui-icon icon-check"></i> Copied!';
setTimeout(() => { setTimeout(() => {
button.innerHTML = originalText; button.innerHTML = originalText;
}, 2000); }, 2000);
} }
</script> </script>
<script src="../../js/theme.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const mediaElement = document.querySelector('video, audio'); const mediaElement = document.querySelector('video, audio');
if (mediaElement) { if (mediaElement) {
// Restore volume from localStorage // Restore volume from localStorage
const savedVolume = localStorage.getItem('mediaVolume'); const savedVolume = localStorage.getItem('mediaVolume');
+11 -47
View File
@@ -1,47 +1,11 @@
html, body { .login-page { display:flex; min-height:100dvh; flex-direction:column; }
height: 100%; .login-top,.login-footer { display:flex; align-items:center; justify-content:space-between; padding:28px 5vw; gap:16px; }
margin: 0; .login-brand { display:flex; align-items:center; gap:12px; font-size:19px; color:var(--text); font-weight:750; letter-spacing:-.03em; } .login-brand > span { display:grid; place-items:center; background:#2563eb; color:white; width:36px; height:36px; border-radius:9px; font-size:25px; }
} .login-layout { flex:1; width:min(1060px,90%); margin:48px auto; display:grid; grid-template-columns:1.1fr 1fr; align-items:center; gap:100px; }
.login-story h1 { font-size:clamp(44px,5vw,64px); margin:16px 0 20px; line-height:1.08; letter-spacing:-.055em; } .login-story h1 > span { color:var(--blue); } .login-story > p { max-width:370px; color:var(--muted); font-size:15px; line-height:1.8; }
body { .login-card { background:var(--surface); padding:40px; border:1px solid var(--line); border-radius:18px; box-shadow:var(--shadow); } .login-card h2 { margin:12px 0 8px; font-size:24px; } .login-card form { margin-top:30px; } .login-card label { font-size:12px; } .login-card .btn { width:100%; justify-content:space-between; padding:13px 16px; } .login-note { margin-top:28px; padding-top:22px; border-top:1px solid var(--line); font-size:11px; color:var(--muted); text-align:center; }
display: flex; .storage-illustration { max-width:310px; margin-top:35px; padding:18px; border:1px solid var(--line); border-radius:14px; background:var(--surface); transform:rotate(-3deg); box-shadow:12px 14px 0 var(--blue-soft); }
flex-direction: column; .storage-top { display:flex; justify-content:space-between; font-size:10px; font-weight:700; letter-spacing:.12em; margin-bottom:14px; } .storage-top span { color:var(--blue); } .storage-drive { display:flex; align-items:center; gap:16px; height:38px; padding:0 12px; border:1px solid var(--line); border-radius:6px; margin:6px 0; background:var(--bg); } .storage-drive b { font:10px ui-monospace,monospace; color:var(--muted); } .storage-drive > span { flex:1; height:5px; border-radius:4px; background:var(--line); } .storage-drive i { width:5px; height:5px; border-radius:50%; background:var(--blue); } .storage-bottom { color:var(--muted); font-size:7px; letter-spacing:.14em; margin-top:14px; }
} .login-footer { font-size:11px; } .login-footer a { color:var(--muted); }
@media(max-width:800px) { .login-layout { grid-template-columns:1fr 1fr; gap:30px; } .login-card { padding:26px; } }
main { @media(max-width:620px) { .login-layout { display:block; max-width:430px; margin:24px auto; } .login-story { margin-bottom:30px; } .login-story h1 { font-size:38px; } .login-story h1 br { display:none; } .storage-illustration { display:none; } .login-story > p { font-size:13px; } .login-top,.login-footer { padding:20px; } .login-footer { flex-wrap:wrap; } }
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding-top: 7%;
}
.fixed {
-ms-flex: 0 0 300px;
flex: 0 0 300px;
min-width: 300px;
}
.hover-effect {
transition: opacity 0.3s ease;
}
.hover-effect:hover {
opacity: 0.8;
}
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.theme-light .dark-logo {
display: none;
}
.theme-dark .light-logo {
display: none;
}
+71 -181
View File
@@ -1,181 +1,71 @@
/* Modern dark theme with blue accents */ /* Shared UI primitives, independent of a CSS framework. */
:root { :root { color-scheme: light; --bg:#f5f7fb; --surface:#fff; --surface-alt:#f0f4fa; --text:#17243b; --muted:#64748b; --line:#e0e7f0; --blue:#2563eb; --blue-soft:#eaf1ff; --danger:#c83b50; --success:#168364; --shadow:0 16px 48px #16294a09; --body-bg-rgb:245,247,251; }
--bg-dark: #171b22; :root[data-theme="dark"] { color-scheme:dark; --bg:#0c1220; --surface:#121c2d; --surface-alt:#19253a; --text:#e4ecfa; --muted:#96a6bf; --line:#26334a; --blue:#79a7ff; --blue-soft:#1c3053; --danger:#ff8797; --success:#64d3ae; --shadow:0 16px 48px #0002; --body-bg-rgb:12,18,32; }
--bg-dark2: #1a202c; * { box-sizing:border-box; }
--primary-blue: #3b82f6; body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.55 'Segoe UI',system-ui,sans-serif; }
--text-light: #e5e7eb; button,input,select,textarea { font:inherit; }
--bg-darkblue: #101722; a { color:var(--blue); text-decoration:none; } a:hover { text-decoration:underline; }
--container-bg-start: #1a2029; :focus-visible { outline:3px solid var(--blue); outline-offset:3px; }
--container-bg-end: #151d29; [hidden] { display:none !important; }
--sftp-bg-start: #151b25; h1,h2,h3,h4,h5,p { margin:0 0 1rem; }
--sftp-bg-end: #101722; h1 { font-size:clamp(26px,3vw,36px); letter-spacing:-.045em; line-height:1.2; }
--container-border: rgba(77, 145, 255, 0.7); h2 { font-size:23px; letter-spacing:-.03em; } h3 { font-size:18px; } h4,h5 { font-size:16px; }
--panel-text: #e5e7eb; small,.small,.form-text { font-size:12px; }
--panel-muted: #aab4c3; .text-muted,.form-text { color:var(--muted); } .text-danger { color:var(--danger); } .text-success { color:var(--success); }
--control-bg: #101722; .text-center { text-align:center; } .text-end { text-align:right; } .text-break,.text-wrap { overflow-wrap:anywhere; } .fw-bold { font-weight:650; }
--control-border: #39485d; .btn { display:inline-flex; justify-content:center; align-items:center; gap:8px; min-height:38px; padding:8px 14px; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); cursor:pointer; font-weight:600; line-height:1.4; text-decoration:none; transition:background .15s,border-color .15s; }
} .btn:hover { background:var(--surface-alt); border-color:var(--muted); text-decoration:none; }
.btn-primary { background:#2563eb; border-color:#2563eb; color:#fff; } .btn-primary:hover { background:#1d4ed8; border-color:#1d4ed8; }
body { .btn-danger { color:var(--danger); } .btn-success,.btn-info { color:var(--blue); }
background: linear-gradient(135deg, var(--bg-dark), var(--bg-darkblue)); .btn-sm { min-height:32px; padding:6px 10px; font-size:12px; } .btn:disabled,.btn.disabled { opacity:.5; cursor:default; }
color: var(--text-light); .btn-group,.input-group { display:flex; gap:8px; }
min-height: 100vh; .form-control,.form-select { display:block; width:100%; min-width:0; border:1px solid var(--line); border-radius:8px; background:var(--surface); color:var(--text); padding:11px 13px; }
font-family: system-ui, sans-serif; .form-control::placeholder { color:var(--muted); } .form-control:focus,.form-select:focus { border-color:var(--blue); outline:3px solid var(--blue-soft); }
margin: 0; label,.form-label { display:inline-block; margin-bottom:6px; font-weight:600; } input[type="checkbox"] { accent-color:var(--blue); width:16px; height:16px; }
padding: 0; .form-check { display:flex; align-items:center; gap:8px; } .form-check label { margin:0; }
} .surface-panel,.card { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:24px; }
.card-header { border-bottom:1px solid var(--line); padding:12px; } .card-body { padding:20px; }
a, .nav-link { .custom-container.dark-box { width:calc(100% - 48px); max-width:1280px; min-height:calc(100vh - 100px); margin:0 auto 24px; padding:24px; background:var(--surface); border:1px solid var(--line); border-radius:16px; box-shadow:var(--shadow); }
color: var(--primary-blue) !important; .custom-container > header { padding-bottom:20px; margin:8px 0 24px; }
transition: opacity .2s ease-in-out; table { width:100%; border-collapse:collapse; text-align:left; } th { color:var(--muted); font-size:11px; font-weight:650; letter-spacing:.06em; text-transform:uppercase; background:var(--surface-alt); }
} th,td { padding:14px 16px; border-bottom:1px solid var(--line); overflow-wrap:anywhere; } tbody tr:hover { background:var(--surface-alt); }
.alert { padding:14px 16px; margin:16px 0; border:1px solid var(--line); border-radius:8px; background:var(--blue-soft); text-align:left; } .alert-danger { color:var(--danger); } .alert-success { color:var(--success); }
body, button, input, select, textarea { .badge { display:inline-block; padding:2px 7px; border-radius:5px; background:var(--surface-alt); color:var(--muted); font-size:10px; font-weight:600; }
font-family: system-ui, sans-serif; .progress { height:8px; background:var(--surface-alt); border-radius:8px; overflow:hidden; } .progress-bar { height:100%; background:var(--blue); }
} .bg-success { background:var(--success); } .bg-warning { background:#d99b32; } .bg-danger { background:var(--danger); }
.badge.bg-danger,.badge.bg-success,.badge.bg-warning { background:var(--surface-alt); color:var(--muted); }
/* Keep text, fields and tables readable on every page that uses the shared theme. */ .img-fluid { max-width:100%; height:auto; }
.surface-panel, html:not([data-theme="dark"]) .dark-logo,[data-theme="dark"] .light-logo { display:none; }
.dark-box { footer { color:var(--muted); font-size:12px; } footer img { opacity:.7; max-height:22px; object-fit:contain; }
color: var(--panel-text); .theme-toggle { white-space:nowrap; }
} .d-flex { display:flex; } .flex-column { flex-direction:column; } .flex-wrap { flex-wrap:wrap; } .justify-content-end { justify-content:flex-end; } .justify-content-center { justify-content:center; } .justify-content-between { justify-content:space-between; } .align-items-center { align-items:center; } .gap-3 { gap:16px; }
.row { display:flex; flex-wrap:wrap; gap:20px; } .col { flex:1; min-width:0; } .col-12 { width:100%; } .col-4 { flex:1; }
.surface-panel .text-muted, .container,.container-fluid { width:100%; margin-inline:auto; } .container { max-width:1280px; }
.dark-box .text-muted { .border { border:1px solid var(--line); } .border-top { border-top:1px solid var(--line); } .border-bottom { border-bottom:1px solid var(--line); } .rounded { border-radius:10px; }
color: var(--panel-muted) !important; .position-relative { position:relative; } .w-100 { width:100%; } .h-100 { height:100%; } .w-auto { width:auto; } .mx-auto { margin-inline:auto; }
} .p-2 { padding:12px; } .p-3 { padding:20px; } .m-3 { margin:20px; } .m-5 { margin:32px; }
.mb-1,.mt-1 { margin-block:4px; } .mb-2 { margin-bottom:8px; } .mb-3 { margin-bottom:16px; } .mb-4 { margin-bottom:24px; } .mt-2 { margin-top:8px; } .mt-3 { margin-top:16px; } .mt-4 { margin-top:24px; } .me-2 { margin-right:8px; }
.surface-panel .form-control, dialog.modal { width:min(480px,calc(100% - 32px)); padding:0; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); box-shadow:0 24px 100px #0005; max-height:calc(100dvh - 40px); }
.surface-panel .form-select, dialog::backdrop { background:#080f2199; backdrop-filter:blur(4px); }
.dark-box .form-control, .modal-header,.modal-footer { padding:20px 24px; display:flex; align-items:center; justify-content:space-between; gap:12px; } .modal-header { border-bottom:1px solid var(--line); } .modal-title { margin:0; } .modal-body { padding:24px; } .modal-footer { justify-content:flex-end; border-top:1px solid var(--line); }
.dark-box .form-select { .btn-close { border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:24px; } .btn-close::before { content:'×'; } body:has(dialog[open]) { overflow:hidden; }
color: var(--panel-text); .eyebrow { color:var(--muted); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; }
background-color: var(--control-bg); @media(min-width:900px) { .col-md-6 { width:calc(50% - 10px); } .col-lg-4 { width:calc(33.333% - 14px); } }
border-color: var(--control-border); @media(max-width:700px) { .custom-container.dark-box { width:calc(100% - 24px); padding:16px; } .surface-panel { overflow-x:auto; } .row { gap:12px; } .col-md-6,.col-lg-4 { width:100%; } th,td { padding:10px; } .m-5 { margin:16px; } }
} @media(prefers-reduced-motion:reduce) { *,*::before,*::after { transition:none !important; animation:none !important; } }
/* Small local icon vocabulary used by secondary screens. */
.surface-panel .form-control::placeholder, .ui-icon { font-style:normal; display:inline-block; }
.dark-box .form-control::placeholder { .icon-chevron-left::before { content:'‹'; } .icon-chevron-right::before { content:'›'; }
color: var(--panel-muted); .icon-fullscreen::before { content:'⛶'; } .icon-fullscreen-exit::before { content:'⊡'; }
} .icon-share::before { content:'↗'; } .icon-clipboard::before { content:'▤'; }
.icon-check::before,.icon-check-circle-fill::before { content:'✓'; }
.surface-panel .form-control:focus, .icon-x-circle-fill::before { content:'×'; } .icon-exclamation-triangle::before { content:'!'; }
.surface-panel .form-select:focus, .icon-server::before,.icon-hdd::before,.icon-hdd-stack::before { content:'▤'; }
.dark-box .form-control:focus, .icon-clock::before { content:'◷'; } .icon-cpu::before { content:'▦'; } .icon-memory::before { content:'▥'; }
.dark-box .form-select:focus { .text-primary { color:var(--blue); } .text-warning { color:#c98d22; }
border-color: var(--primary-blue); .spinner-border { display:inline-block; width:14px; height:14px; border:2px solid var(--line); border-top-color:var(--blue); border-radius:50%; animation:spin .8s linear infinite; }
box-shadow: 0 0 0 .2rem rgba(59, 130, 246, .2); @keyframes spin { to { transform:rotate(360deg); } }
} /* Secondary screens share the same surfaces and spacing. */
.col-lg-10 { width:100%; } .py-4 { padding-block:24px; } .mb-0 { margin-bottom:0; } .h4 { font-size:22px; } .h5 { font-size:16px; }
.surface-panel .table, .card-header.bg-success,.card-header.bg-warning,.card-header.bg-danger,.card-header.bg-primary,.card-header.bg-info { background:var(--surface-alt); }
.surface-panel .table th, .custom-container.dark-box.fullscreen-mode { max-width:none; width:100%; height:100dvh; margin:0; border:0; border-radius:0; padding:0; }
.surface-panel .table td {
color: inherit;
}
a:hover, .btn:hover, .nav-link:hover {
opacity: 0.85;
}
/* Ensure action buttons have white text for visibility */
.btn-primary,
.btn-danger,
.btn-warning,
.btn-success,
.btn-info,
.btn-secondary {
color: var(--text-light) !important;
}
/* Override specific actions (update password, upload directory, rename) to have black text */
.btn-primary,
.btn-warning,
.btn-info {
color: #212529 !important;
}
.dark-box {
background: rgba(0,0,0,0.6);
border-radius: var(--border-radius);
padding: 1rem;
backdrop-filter: blur(8px);
}
.surface-panel {
background: linear-gradient(145deg, var(--sftp-bg-start), var(--sftp-bg-end));
color: var(--text-light);
border: 1px solid rgba(77, 145, 255, 0.3);
border-radius: 1rem;
box-shadow: 0 12px 32px rgba(3, 8, 18, 0.2);
}
.modal-content {
background: rgba(20, 20, 30, 0.9) !important;
color: var(--text-light) !important;
border: none;
}
.table {
color: var(--text-light) !important;
}
.table th, .table td {
border-color: rgba(255,255,255,0.1) !important;
}
/* Main window styling (window within window) */
.custom-container.dark-box {
box-sizing: border-box;
margin: 2rem auto;
background: linear-gradient(135deg, var(--container-bg-start), var(--container-bg-end));
border: 2px solid var(--container-border);
border-radius: var(--border-radius);
padding: 1rem;
backdrop-filter: blur(6px);
}
/* Light mode overrides */
html[data-bs-theme="light"] {
--bg-light: #f8f9fa;
--bg-light2: #ffffff;
--container-bg: rgba(255,255,255,0.85);
--container-border: var(--primary-blue);
--text-dark: #212529;
--panel-text: #212529;
--panel-muted: #596579;
--control-bg: #ffffff;
--control-border: #c8d2df;
}
html[data-bs-theme="light"] body {
background: var(--bg-light);
color: var(--text-dark);
}
html[data-bs-theme="light"] .dark-box {
background: rgba(255, 255, 255, 0.8);
color: var(--text-dark);
}
html[data-bs-theme="light"] .surface-panel .form-control,
html[data-bs-theme="light"] .surface-panel .form-select,
html[data-bs-theme="light"] .dark-box .form-control,
html[data-bs-theme="light"] .dark-box .form-select {
color: var(--panel-text);
background-color: var(--control-bg);
border-color: var(--control-border);
}
html[data-bs-theme="light"] .form-control::placeholder {
color: var(--panel-muted);
}
html[data-bs-theme="light"] .surface-panel {
background: linear-gradient(145deg, #ffffff, #edf2f8);
color: var(--text-dark);
border-color: rgba(59, 130, 246, 0.3);
box-shadow: 0 12px 32px rgba(31, 58, 92, 0.08);
}
html[data-bs-theme="light"] .modal-content {
background: #fff !important;
color: var(--text-dark) !important;
}
html[data-bs-theme="light"] .table {
color: var(--text-dark) !important;
}
+62
View File
@@ -0,0 +1,62 @@
/* Persistent workspace frame shared by every signed-in page and shared previews. */
.file-app { display:grid; grid-template-columns:236px minmax(0,1fr); text-align:left; }
.skip-link { position:fixed; left:250px; top:-60px; z-index:10000; padding:10px 16px; background:var(--surface); border:1px solid var(--blue); border-radius:8px; }
.skip-link:focus { top:12px; }
.sidebar { position:sticky; top:0; height:100dvh; padding:30px 20px 16px; display:flex; flex-direction:column; background:var(--surface); border-right:1px solid var(--line); overflow-y:auto; }
.brand { display:flex; align-items:center; gap:12px; color:var(--text); font-size:19px; font-weight:750; letter-spacing:-.04em; text-decoration:none; }
.brand-mark { display:grid; place-items:center; width:40px; height:40px; border-radius:11px; background:#2563eb; color:white; font-size:27px; }
.brand-caption { display:block; font-size:9px; font-weight:600; letter-spacing:.16em; color:var(--muted); margin-top:1px; }
.nav-heading { margin:48px 12px 12px; }
.nav-item { display:flex; align-items:center; gap:12px; padding:11px 12px; margin:4px 0; color:var(--muted); border-radius:8px; font-weight:550; }
.nav-item:hover { text-decoration:none; background:var(--surface-alt); color:var(--text); }
.nav-item.active { background:var(--blue-soft); color:var(--blue); }
.nav-item > span { font-size:20px; width:21px; text-align:center; }
.sidebar-bottom { margin-top:auto; padding-top:40px; }
.connection-card { border:1px solid var(--line); border-radius:10px; padding:14px 12px; font-size:12px; font-weight:600; margin-bottom:18px; }
.status-dot { display:inline-block; width:6px; height:6px; background:var(--success); border-radius:50%; margin-right:7px; }
.connection-card small { display:block; margin-top:5px; color:var(--muted); font-weight:400; font-size:10px; }
.user-card { display:flex; gap:10px; align-items:center; padding:16px 4px 0; margin-top:14px; border-top:1px solid var(--line); }
.avatar { display:grid; place-items:center; background:var(--blue-soft); color:var(--blue); width:34px; height:34px; border-radius:50%; font-weight:700; flex-shrink:0; }
.user-name { flex:1; overflow-wrap:anywhere; font-weight:600; font-size:12px; }
.user-name small { display:block; font-size:10px; font-weight:400; color:var(--muted); }
.workspace { min-width:0; display:flex; flex-direction:column; min-height:100dvh; }
.topbar { min-height:76px; display:flex; justify-content:space-between; align-items:center; gap:12px; padding:16px 40px; border-bottom:1px solid var(--line); background:var(--surface); color:var(--muted); font-size:12px; }
.topbar strong { color:var(--text); font-weight:500; }
.topbar-divider { padding:0 14px; opacity:.5; }
.file-main { flex:1; width:100%; max-width:1560px; margin:0 auto; padding:42px 40px 0; display:flex; flex-direction:column; min-width:0; }
.workspace-content { flex:1; min-width:0; }
.page-heading { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:30px; }
.page-heading .eyebrow { margin-bottom:9px; }
.page-heading h1 { margin-bottom:10px; font-size:36px; overflow-wrap:anywhere; }
.heading-dot { color:var(--blue); }
.page-heading p { color:var(--muted); margin:0; font-size:13px; }
.heading-back { white-space:nowrap; font-size:12px; flex-shrink:0; }
.protocol-badge { border:1px solid var(--line); border-radius:6px; padding:5px 10px; font:11px ui-monospace,monospace; background:var(--surface); white-space:nowrap; }
.workspace-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:22px 0; font-size:10px; margin:0; border:0; }
.workspace-footer a { color:var(--muted); }
.content-panel { padding:24px; border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); min-width:0; }
.panel-heading { margin-bottom:22px; } .panel-heading h2 { margin-bottom:6px; font-size:18px; } .panel-heading p { margin:0; color:var(--muted); font-size:12px; }
.content-toolbar { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; margin-bottom:20px; font-size:12px; color:var(--muted); }
.content-toolbar p { margin:0; }
.table-scroll { overflow-x:auto; max-width:100%; }
@media(min-width:1500px) { .file-main { padding-top:56px; } }
@media(max-width:1100px) { .file-app { grid-template-columns:200px minmax(0,1fr); } .sidebar { padding-inline:14px; } .nav-item { font-size:13px; } .file-main { padding:28px 24px 0; } .topbar { padding-inline:24px; } }
@media(max-width:760px) {
.file-app { display:block; }
.sidebar { position:static; height:auto; padding:16px; border-right:0; border-bottom:1px solid var(--line); overflow:visible; }
.brand-caption,.nav-heading,.connection-card { display:none; }
.sidebar nav { display:flex; flex-wrap:wrap; gap:6px; margin-top:14px; }
.nav-item { font-size:12px; padding:7px 10px; margin:0; }
.sidebar-bottom { margin:8px 0 0; padding:0; display:flex; }
.sidebar-bottom .nav-item { padding:4px 10px; }
.sidebar-bottom .user-card { display:flex; margin:0 0 0 auto; padding:0 10px; border:0; }
.sidebar-bottom .user-name,.sidebar-bottom .avatar { display:none; }
.topbar { min-height:58px; padding:12px 16px; }
.file-main { padding:26px 16px 0; }
.page-heading { align-items:flex-start; flex-wrap:wrap; }
.page-heading h1 { font-size:30px; }
.protocol-badge { display:none; }
.workspace-footer { flex-wrap:wrap; }
.content-panel { padding:18px; }
.skip-link { left:16px; }
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/** Shared navigation and page frame for the SFTP workspace. */
function workspaceEscape($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function workspaceAsset($root, $path) {
return workspaceEscape($root . $path . '?v=' . filemtime(__DIR__ . '/../' . $path));
}
function renderWorkspaceStart(array $page) {
$root = $page['root'];
$active = $page['active'] ?? 'files';
$guest = $page['guest'] ?? false;
$username = $_SESSION['uname'] ?? '';
$admin = !$guest && !empty($_SESSION['admin']);
$filesUrl = $root . 'content/ftp/index.php';
$nav = [
'files' => ['All files', '▱', $filesUrl],
'server' => ['Server status', '◴', $root . 'content/ftp/serverstat.php'],
'users' => ['User management', '♙', $root . 'content/adminpanel.php'],
];
?>
<body class="file-app">
<a class="skip-link" href="#workspace-content">Skip to content</a>
<aside class="sidebar">
<a class="brand" href="<?= workspaceEscape($guest ? $root . 'index.php' : $filesUrl) ?>"><span class="brand-mark" aria-hidden="true">▤</span><span>USB RAID<span class="brand-caption">SFTP WORKSPACE</span></span></a>
<div class="eyebrow nav-heading"><?= $guest ? 'Shared workspace' : 'Workspace' ?></div>
<nav aria-label="Main navigation">
<?php if ($guest): ?>
<a class="nav-item active" href="#workspace-content" aria-current="page"><span aria-hidden="true">↗</span> Shared file</a>
<?php else: ?>
<?php foreach ($nav as $key => [$label, $icon, $url]): ?>
<?php if ($key !== 'files' && !$admin) continue; ?>
<a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= workspaceEscape($url) ?>"<?= $active === $key ? ' aria-current="page"' : '' ?>><span aria-hidden="true"><?= $icon ?></span> <?= $label ?></a>
<?php endforeach; ?>
<?php endif; ?>
</nav>
<div class="sidebar-bottom">
<div class="connection-card"><?php if (!empty($page['connected'])): ?><span class="status-dot"></span><?php endif; ?><?= $guest ? 'Shared access' : (!empty($page['connected']) ? 'Server connected' : 'Remote storage') ?><small><?= $guest ? 'Access through a shared link' : 'Your SFTP workspace' ?></small></div>
<?php if ($guest): ?>
<a class="nav-item" href="<?= workspaceEscape($root . 'index.php') ?>">Sign in to your workspace ↗</a>
<?php else: ?>
<a class="nav-item<?= $active === 'account' ? ' active' : '' ?>" href="<?= workspaceEscape($root . 'content/changepassword.php') ?>"<?= $active === 'account' ? ' aria-current="page"' : '' ?>>Account settings</a>
<div class="user-card"><span class="avatar"><?= workspaceEscape(strtoupper(substr($username, 0, 1))) ?></span><span class="user-name"><?= workspaceEscape($username) ?><small><?= $admin ? 'Administrator' : 'Member' ?></small></span><a href="<?= workspaceEscape($root . 'content/logout.php') ?>" title="Sign out" aria-label="Sign out">↗</a></div>
<?php endif; ?>
</div>
</aside>
<div class="workspace">
<header class="topbar"><span><?= $guest ? 'Shared workspace' : 'Workspace' ?><span class="topbar-divider">/</span><strong><?= workspaceEscape($page['section']) ?></strong></span><button id="themeToggle" class="btn btn-sm theme-toggle"><i aria-hidden="true"></i><span id="themeText">Theme</span></button></header>
<main class="file-main" id="workspace-content" tabindex="-1">
<div class="page-heading"><div><div class="eyebrow"><?= workspaceEscape($page['eyebrow']) ?></div><h1><?= workspaceEscape($page['title']) ?><span class="heading-dot">.</span></h1><p><?= workspaceEscape($page['description']) ?></p></div>
<?php if (isset($page['back'])): ?><a class="btn heading-back" href="<?= workspaceEscape($page['back']) ?>">← Back to files</a><?php elseif (!empty($page['connected'])): ?><span class="protocol-badge"><span class="status-dot"></span> SFTP</span><?php endif; ?>
</div>
<div class="workspace-content <?= workspaceEscape($page['class'] ?? '') ?>">
<?php
}
function renderWorkspaceEnd() {
?>
</div>
<footer class="workspace-footer"><span>USB RAID Array <span aria-hidden="true">·</span> Developed by Michal Sedlák</span><a href="https://github.com/michalcz10/USB-RAID-Array" target="_blank" rel="noopener noreferrer">View on GitHub ↗</a></footer>
</main>
</div>
<?php
}
+12 -58
View File
@@ -15,68 +15,22 @@ if (empty($_SESSION)) {
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cz"> <html lang="en">
<head> <head>
<title>Login</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="img/favicon.ico" type="image/x-icon"> <title>Sign in · USB RAID</title>
<link rel="stylesheet" href="css/bootstrap.css"> <link rel="icon" href="img/favicon.ico">
<link rel="stylesheet" href="css/modern.css"> <link rel="stylesheet" href="css/modern.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="stylesheet" href="css/index.css"> <link rel="stylesheet" href="css/index.css">
<script src="js/bootstrap.bundle.js"></script>
</head>
<body class="container-fluid text-center">
<div class="d-flex justify-content-end p-3">
<button id="themeToggle" class="btn btn-sm theme-toggle">
<i class="bi"></i>
<span id="themeText"></span>
</button>
</div>
<div class="custom-container dark-box">
<header class="row border-bottom m-5">
<h1>USB RAID Array</h1>
</header>
<main>
<section class="row m-3">
<article class="col border border-2 border-primary rounded p-2 fixed surface-panel">
<form action="content/login.php" method="post">
<div class="mb-3">
<label for="uname">Username:</label>
<br>
<input type="text" name="uname" class="form-control" required>
</div>
<div class="mb-3">
<label for="pswd">Password:</label>
<br>
<input type="password" name="pswd" class="form-control" required>
</div>
<div class="mb-3">
<input type="submit" class="btn btn-primary" value="Login">
</div>
</form>
</article>
</section>
</main>
<footer class="d-flex flex-column justify-content-center align-items-center p-3 border-top gap-3">
<span class="text-muted">Developed by Michal Sedlák</span>
<div class="d-flex gap-3">
<a href="https://github.com/michalcz10/USB-RAID-Array" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="img/GitHub_Logo.png" alt="GitHub Logo" class="img-fluid hover-effect light-logo" style="height: 32px;">
<img src="img/GitHub_Logo_White.png" alt="GitHub Logo" class="img-fluid hover-effect dark-logo" style="height: 32px;">
</a>
<a href="https://app.freelo.io/public/shared-link-view/?a=81efbcb4df761b3f29cdc80855b41e6d&b=4519c717f0729cc8e953af661e9dc981" class="text-decoration-none" target="_blank" rel="noopener noreferrer">
<img src="img/freelo-logo-rgb.png" alt="Freelo Logo" class="img-fluid hover-effect light-logo" style="height: 24px;">
<img src="img/freelo-logo-rgb-on-dark.png" alt="Freelo Logo" class="img-fluid hover-effect dark-logo" style="height: 24px;">
</a>
</div>
</footer>
</div>
<script src="js/theme.js"></script> <script src="js/theme.js"></script>
</head>
<body class="login-page">
<header class="login-top"><a class="login-brand" href="index.php"><span>▤</span> USB RAID</a><button id="themeToggle" class="btn btn-sm"><i aria-hidden="true"></i><span id="themeText">Theme</span></button></header>
<main class="login-layout">
<section class="login-story"><div class="eyebrow">Your storage. Your workspace.</div><h1>A home for<br>every file<span>.</span></h1><p>Connect to your RAID storage and keep your files within reach. One simple workspace, powered by SFTP.</p><div class="storage-illustration" aria-hidden="true"><div class="storage-top">USB RAID <span>SFTP</span></div><div class="storage-drive"><b>01</b><span></span><i></i></div><div class="storage-drive"><b>02</b><span></span><i></i></div><div class="storage-drive"><b>03</b><span></span><i></i></div><div class="storage-bottom">REMOTE STORAGE / LOCAL CONTROL</div></div></section>
<section class="login-card"><div class="eyebrow">Welcome back</div><h2>Sign in to your workspace</h2><p class="text-muted">Use your account to access your files.</p><form action="content/login.php" method="post"><div class="mb-3"><label for="uname">Username</label><input id="uname" name="uname" class="form-control" autocomplete="username" placeholder="Enter your username" required></div><div class="mb-4"><label for="pswd">Password</label><input id="pswd" type="password" name="pswd" class="form-control" autocomplete="current-password" placeholder="Enter your password" required></div><button type="submit" class="btn btn-primary">Sign in <span aria-hidden="true">→</span></button></form><div class="login-note">Need access? Contact your administrator.</div></section>
</main>
<footer class="login-footer"><span>Developed by Michal Sedlák</span><a href="https://github.com/michalcz10/USB-RAID-Array" target="_blank" rel="noopener noreferrer">Project on GitHub ↗</a></footer>
</body> </body>
</html> </html>
+22 -36
View File
@@ -1,40 +1,26 @@
document.addEventListener('DOMContentLoaded', function() { (() => {
const themeToggle = document.getElementById('themeToggle'); const preference = window.matchMedia('(prefers-color-scheme: dark)');
const html = document.documentElement; let saved;
const themeText = document.getElementById('themeText'); try { saved = localStorage.getItem('theme'); } catch (_) { /* Storage may be disabled. */ }
const themeIcon = themeToggle.querySelector('.bi');
function setTheme(theme) { function setTheme(theme) {
html.setAttribute('data-bs-theme', theme); document.documentElement.dataset.theme = theme;
document.body.classList.remove('theme-light', 'theme-dark'); const button = document.getElementById('themeToggle');
document.body.classList.add('theme-' + theme); if (button) {
localStorage.setItem('theme', theme); button.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
const label = document.getElementById('themeText');
if (theme === 'dark') { if (label) label.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
themeText.textContent = 'Light Mode'; const icon = button.querySelector('i');
themeIcon.className = 'bi bi-sun'; if (icon) { icon.textContent = theme === 'dark' ? '☀' : '◐'; icon.setAttribute('aria-hidden', 'true'); }
themeToggle.classList.remove('btn-dark');
themeToggle.classList.add('btn-light');
} else {
themeText.textContent = 'Dark Mode';
themeIcon.className = 'bi bi-moon';
themeToggle.classList.remove('btn-light');
themeToggle.classList.add('btn-dark');
} }
} }
setTheme(saved === 'dark' || saved === 'light' ? saved : preference.matches ? 'dark' : 'light');
const savedTheme = localStorage.getItem('theme'); document.addEventListener('DOMContentLoaded', () => {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; setTheme(document.documentElement.dataset.theme);
document.getElementById('themeToggle')?.addEventListener('click', () => {
if (savedTheme) { saved = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
setTheme(savedTheme); setTheme(saved);
} else { try { localStorage.setItem('theme', saved); } catch (_) { /* Keep this session usable. */ }
setTheme(prefersDark ? 'dark' : 'light'); });
}
themeToggle.addEventListener('click', function() {
const currentTheme = html.getAttribute('data-bs-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
}); });
}); preference.addEventListener('change', () => { if (saved !== 'dark' && saved !== 'light') setTheme(preference.matches ? 'dark' : 'light'); });
})();
+10
View File
@@ -0,0 +1,10 @@
// Native dialogs supply focus containment, Escape and focus restoration.
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-dialog]');
if (trigger) document.getElementById(trigger.dataset.dialog)?.showModal();
if (event.target.closest('[data-dialog-close]')) event.target.closest('dialog')?.close();
if (event.target instanceof HTMLDialogElement) {
const rect = event.target.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close();
}
});
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php';
require_once __DIR__ . '/../Web/includes/workspace.php';
// Treat warnings and deprecations as failures, including when rendering missing data.
set_error_handler(function ($severity, $message, $file, $line) {
throw new ErrorException($message, 0, $severity, $file, $line);
});
function check($condition, $message) {
if (!$condition) throw new RuntimeException($message);
}
$disk = parseDiskData("Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/mapper/vg-root 1048576 262144 786432 25% /\n");
check($disk['available_data'] && $disk['size'] === '1 GiB' && $disk['usage'] === '25%', 'Parse POSIX df sizes and usage');
foreach ([false, null, '', 'df: /dev/sdb1: No such file or directory', "Filesystem 1024-blocks Used Available Capacity Mounted on\n"] as $output) {
check(!parseDiskData($output)['available_data'], 'Missing disk output must be unavailable');
}
$healthy = "Personalities : [raid1] [raid5]\nmd0 : active raid5 sda[0] sdb[1] sdc[2]\n 1000 blocks [3/3] [UUU]\n\nunused devices: <none>\n";
check(parseRaidStatus($healthy)['status'] === 'healthy', 'Three-disk RAID5 should be healthy');
check(parseRaidStatus(str_replace('[UUU]', '[U_U]', $healthy))['status'] === 'degraded', 'Missing RAID member should be degraded');
check(parseRaidStatus($healthy . "md1 : active raid1 sdd[0] sde[1]\n 1000 blocks [2/1] [U_]\n")['status'] === 'degraded', 'One failed array must make overall status degraded');
check(parseRaidStatus(str_replace('[UUU]', "[UUU]\n resync = 42.0%", $healthy))['status'] === 'syncing', 'RAID resync is visible');
check(parseRaidStatus("md0 : active raid0 sda[0] sdb[1]\n 1000 blocks super 1.2 512k chunks\n")['status'] === 'active', 'RAID0 does not require a redundancy bitmap');
check(parseRaidStatus("md0 : inactive sda[0](S)\n 1000 blocks\n")['status'] === 'inactive', 'Inactive array remains inactive');
check(parseRaidStatus("Personalities : [raid1]\nunused devices: <none>\n")['status'] === 'not configured', 'No configured Linux MD array');
check(parseRaidStatus(false)['status'] === 'unavailable', 'Unreadable mdstat is unavailable');
$resources = parseSystemStats('97,5', '1024,256,768,0,0,0');
check($resources['cpu']['usage'] === '2.5' && $resources['memory']['usage'] === '25%' && $resources['memory']['swap_usage'] === '0%', 'Resource values and zero swap');
check(!parseSystemStats(false, false)['cpu']['available_data'], 'Missing CPU output must not show 100%');
check(!parseSystemStats('not a number', '')['memory']['available_data'], 'Missing memory output must be unavailable');
// Check remote command construction and path quoting with a harmless stub.
$ssh = new class {
public $commands = [];
function exec($command) { $this->commands[] = $command; return false; }
};
$defaultPath = "/storage/owner's files";
$systemData = getSystemData($ssh, $defaultPath);
check(in_array("LC_ALL=C df -Pk -- '/' 2>/dev/null", $ssh->commands, true), 'System filesystem uses root instead of a device name');
check(in_array("LC_ALL=C df -Pk -- '/storage/owner'\\''s files' 2>/dev/null", $ssh->commands, true), 'Storage path is quoted for remote Linux even on Windows');
// Exercise the actual page template with missing output and with valid output.
$source = file_get_contents(__DIR__ . '/../Web/content/ftp/serverstat.php');
$template = substr($source, strpos($source, '$monitors ='));
foreach ([false, true] as $available) {
if ($available) {
$systemData['storage'] = ['system' => $disk, 'data' => $disk];
$systemData['resources'] = $resources;
$systemData['raid'] = parseRaidStatus($healthy);
}
ob_start();
eval($template);
$html = ob_get_clean();
check(strpos($html, 'Server status') !== false, 'Status page renders');
check($available ? strpos($html, '1 GiB') !== false : strpos($html, 'Storage statistics are unavailable') !== false, 'Template handles available and missing data');
}
echo "Server statistics: parser, RAID, command quoting and page rendering checks passed.\n";
+89
View File
@@ -0,0 +1,89 @@
<?php
require_once __DIR__ . '/../Web/includes/workspace.php';
require_once __DIR__ . '/../Web/content/ftp/lib/serverstats.php';
set_error_handler(function ($severity, $message, $file, $line) {
if (error_reporting() & $severity) throw new ErrorException($message, 0, $severity, $file, $line);
});
function expectWorkspace($condition, $message) {
if (!$condition) throw new RuntimeException($message);
}
function renderWorkspaceFixture($name, $admin = true) {
$_SESSION = ['uname' => 'michal', 'admin' => $admin, 'upPer' => true, 'downPer' => true, 'delPer' => true];
$defaultPath = '/mnt/raid'; $currentPath = '/mnt/raid/Workspace';
$filePath = $currentPath . '/Example.pdf'; $fileName = basename($filePath); $parentDir = $currentPath;
$fileExtension = 'png'; $fileSize = 245760; $mimeType = 'image/png';
$isImage = true; $isVideo = false; $isAudio = false;
$editable = true; $showLineNumbers = true; $content = "{\n \"workspace\": \"USB RAID\",\n \"status\": \"connected\"\n}\n";
$message = ''; $messageType = ''; $message_type = ''; $shareCreated = false; $shareError = '';
$share = ['expiration' => '2030-01-01']; $downloadAllowed = false; $token = 'test-only';
$items = ['Documents', 'Photos', 'README.md', 'Example.pdf'];
$imageExtensions = ['png']; $videoExtensions = []; $audioExtensions = []; $editableExtensions = ['md']; $archiveExtensions = [];
$sftp = new class {
function is_dir($name) { return strpos($name, '.') === false; }
function stat($path) { return ['size' => 245760]; }
};
$result = new class {
private $rows = [
['uname' => 'michal', 'admin' => true, 'defPath' => '/mnt/raid', 'delPer' => true, 'downPer' => true, 'upPer' => true],
['uname' => 'guest', 'admin' => false, 'defPath' => '/mnt/raid/shared', 'delPer' => false, 'downPer' => true, 'upPer' => false],
];
function fetch_assoc() { return array_shift($this->rows); }
};
$systemData = [
'storage' => array_fill_keys(['system', 'data'], parseDiskData("Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/md0 1048576 262144 786432 25% /mnt/raid\n")),
'raid' => parseRaidStatus("md0 : active raid5 sda[0] sdb[1] sdc[2]\n 1000 blocks [3/3] [UUU]\n"),
'resources' => parseSystemStats('93', '8192,2048,6144,1024,0,1024'), 'lastUpdate' => '2026-10-16 10:00:00',
];
$top = in_array($name, ['adminpanel', 'changepassword'], true);
$source = file_get_contents(__DIR__ . '/../Web/content/' . ($top ? '' : 'ftp/') . $name . '.php');
if ($name === 'serverstat') {
$start = strpos($source, '$monitors =');
eval(substr($source, $start, strpos($source, '?>', $start) - $start));
}
$start = strpos($source, '<!DOCTYPE html>');
$template = substr($source, $start, strpos($source, '</html>', $start) + 7 - $start);
ob_start(); eval('?>' . $template); return ob_get_clean();
}
$preview = in_array('--preview', $argv, true);
$directory = __DIR__ . '/../Web/.layout-preview';
if ($preview && !is_dir($directory)) mkdir($directory);
$pages = ['index', 'serverstat', 'adminpanel', 'changepassword', 'open', 'view', 'pdf', 'shared'];
foreach ($pages as $name) {
$html = renderWorkspaceFixture($name);
$dom = new DOMDocument();
@$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
expectWorkspace($xpath->query('//body')->length === 1, "$name has one body");
expectWorkspace($xpath->query('//main')->length === 1, "$name has one main region");
expectWorkspace($xpath->query('//aside[contains(@class,"sidebar")]')->length === 1, "$name has shared sidebar");
expectWorkspace($xpath->query('//*[@id="themeToggle"]')->length === 1, "$name has one theme switch");
expectWorkspace($xpath->query('//a[@aria-current="page"]')->length === 1, "$name has one active navigation item");
expectWorkspace($xpath->query('//main//footer')->length === 1, "$name footer is inside the main region");
if ($name === 'shared') expectWorkspace(strpos($html, 'User management') === false && strpos($html, 'Account settings') === false, 'Public shared preview does not expose account navigation');
if ($preview) {
$base = $name === 'adminpanel' || $name === 'changepassword' ? '../content/' : '../content/ftp/';
$html = str_replace('<head>', '<head><base href="' . $base . '">', $html);
// Browser previews never request a real remote file or perform mutations.
$image = 'data:image/svg+xml,' . rawurlencode('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="380"><rect width="800" height="380" fill="#1c3053"/><circle cx="400" cy="150" r="70" fill="#2563eb"/><text x="400" y="290" text-anchor="middle" fill="white" font-size="28" font-family="sans-serif">USB RAID / Workspace preview</text></svg>');
$html = preg_replace('/src="(?:view|shared)\.php\?[^"\n]+"/', 'src="' . $image . '"', $html);
$html = str_replace("const url = 'pdf.php?file=%2Fmnt%2Fraid%2FWorkspace%2FExample.pdf&stream=1';", "const url = '/Web/.layout-preview/sample.pdf';", $html);
file_put_contents($directory . '/' . $name . '.html', $html);
}
}
$member = renderWorkspaceFixture('index', false);
expectWorkspace(strpos($member, 'User management') === false && strpos($member, '> Server status</a>') === false, 'Member navigation omits admin pages');
expectWorkspace(strpos($member, 'Account settings') !== false, 'Members can access account settings');
if ($preview) {
// One-page PDF fixture for viewer navigation and fullscreen checks.
$stream = "BT /F1 24 Tf 60 700 Td (USB RAID workspace preview) Tj ET";
$objects = ['<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', '<< /Length ' . strlen($stream) . ">>\nstream\n" . $stream . "\nendstream"];
$pdf = "%PDF-1.4\n"; $offsets = [0];
foreach ($objects as $i => $object) { $offsets[] = strlen($pdf); $pdf .= ($i + 1) . " 0 obj\n$object\nendobj\n"; }
$xref = strlen($pdf); $pdf .= "xref\n0 6\n0000000000 65535 f \n";
foreach (array_slice($offsets, 1) as $offset) $pdf .= sprintf("%010d 00000 n \n", $offset);
$pdf .= "trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xref\n%%EOF";
file_put_contents($directory . '/sample.pdf', $pdf);
}
echo "Workspace: all eight page templates, navigation roles and shared layout checks passed.\n";