mirror of
https://github.com/michalcz10/USB-RAID-Array.git
synced 2026-11-11 16:09:03 +00:00
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:
+36
-54
@@ -10,6 +10,7 @@ ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/../includes/workspace.php';
|
||||
// $username = "UNAME";
|
||||
// $password = "PSWD";
|
||||
// $db = "usbraidlogin";
|
||||
@@ -136,15 +137,15 @@ function CheckPassword($password) {
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Panel</title>
|
||||
<link rel="icon" href="../img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="../css/bootstrap.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/adminpanel.css">
|
||||
<script src="../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/adminpanel.css') ?>">
|
||||
<script>
|
||||
function confirmDelete(uname) {
|
||||
if (confirm('Are you sure you want to delete this user?')) {
|
||||
@@ -152,45 +153,39 @@ function CheckPassword($password) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="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>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../',
|
||||
'active' => 'users',
|
||||
'section' => 'User management',
|
||||
'eyebrow' => 'Workspace administration',
|
||||
'title' => 'User management',
|
||||
'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): ?>
|
||||
<div class="alert alert-<?php echo $message_type === 'success' ? 'success' : 'danger'; ?>">
|
||||
<?php echo htmlspecialchars($message); ?>
|
||||
</div>
|
||||
<?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">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="uname" class="form-control" placeholder="Username" required>
|
||||
<label class="form-label" for="memberUsername">Username</label>
|
||||
<input type="text" name="uname" id="memberUsername" class="form-control" placeholder="Username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="pswd" class="form-control" placeholder="Password" required>
|
||||
<label class="form-label" for="memberPassword">Password</label>
|
||||
<input type="password" name="pswd" id="memberPassword" class="form-control" placeholder="Password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Default Path</label>
|
||||
<input type="text" name="defPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required>
|
||||
<label class="form-label" for="memberPath">Default path</label>
|
||||
<input type="text" name="defPath" id="memberPath" class="form-control" placeholder="Default Path (e.g., /mnt/raid)" required>
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-center mb-3">
|
||||
<div class="form-check mb-2">
|
||||
@@ -211,9 +206,11 @@ function CheckPassword($password) {
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add User</button>
|
||||
</form>
|
||||
<h3 class="mb-3">Users List</h3>
|
||||
<table class="table table-bordered">
|
||||
</form></div>
|
||||
<div class="content-panel user-list"><div class="panel-heading"><h2>Workspace members</h2><p>Accounts and permissions for your storage.</p></div>
|
||||
|
||||
|
||||
<div class="table-scroll"><table class="table users-table">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
@@ -240,25 +237,10 @@ function CheckPassword($password) {
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</table></div>
|
||||
</div>
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
<script src="../js/theme.js"></script>
|
||||
</div>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
|
||||
@@ -7,6 +7,7 @@ if (!isset($_SESSION['uname'])) {
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/../includes/workspace.php';
|
||||
// $username = "UNAME";
|
||||
// $password = "PSWD";
|
||||
// $db = "usbraidlogin";
|
||||
@@ -93,42 +94,32 @@ function CheckPassword($password) {
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Change Password</title>
|
||||
<link rel="icon" href="../img/favicon.ico" type="image/x-icon">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="../css/bootstrap.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/changepassword.css">
|
||||
<script src="../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../', 'content/css/changepassword.css') ?>">
|
||||
<script src="<?= workspaceAsset('../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="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>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../',
|
||||
'active' => 'account',
|
||||
'section' => 'Account settings',
|
||||
'eyebrow' => 'Your account',
|
||||
'title' => 'Account settings',
|
||||
'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">
|
||||
<div class="btn-group-responsive">
|
||||
<a href="logout.php" class="btn btn-danger">Logout</a>
|
||||
<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>
|
||||
<section class="account-layout">
|
||||
<article class="content-panel">
|
||||
<div class="panel-heading"><h2>Change password</h2><p>Choose a strong password to protect your files.</p></div>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-<?php echo $messageType; ?> mb-4">
|
||||
@@ -138,37 +129,23 @@ function CheckPassword($password) {
|
||||
|
||||
<form method="POST" action="" class="mb-4 change-password-form">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Password</label>
|
||||
<input type="password" name="current_password" class="form-control" required>
|
||||
<label class="form-label" for="currentPassword">Current password</label>
|
||||
<input type="password" id="currentPassword" autocomplete="current-password" name="current_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" name="new_password" class="form-control" required>
|
||||
<label class="form-label" for="newPassword">New password</label>
|
||||
<input type="password" id="newPassword" autocomplete="new-password" name="new_password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
<label class="form-label" for="confirmPassword">Confirm new password</label>
|
||||
<input type="password" id="confirmPassword" autocomplete="new-password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<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 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>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,66 +1,13 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
max-width: 60%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.custom-container.dark-box {
|
||||
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;
|
||||
}
|
||||
.users-layout { display:grid; grid-template-columns:300px minmax(0,1fr); align-items:start; gap:22px; }
|
||||
.users-layout > .alert { grid-column:1/-1; 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%; }
|
||||
.users-table { min-width:650px; }
|
||||
.users-table th { font-size:10px; padding:12px 10px; white-space:nowrap; }
|
||||
.users-table td { font-size:12px; padding:14px 10px; }
|
||||
.users-table td:first-child { font-weight:600; }
|
||||
.users-table td:nth-child(3) { font-family:ui-monospace,monospace; white-space:nowrap; }
|
||||
.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; } }
|
||||
@media(max-width:600px) { .add-user-form { display:block; } .add-user-form > .d-flex { display:flex; align-items:flex-start; gap:0; } }
|
||||
|
||||
@@ -1,56 +1,6 @@
|
||||
.container-fluid {
|
||||
width: 100%;
|
||||
padding-right: 15px;
|
||||
padding-left: 15px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.account-layout { display:grid; grid-template-columns:minmax(0,560px) minmax(200px,1fr); align-items:start; gap:40px; }
|
||||
.change-password-form { margin:0; }
|
||||
.change-password-form .form-label { font-size:12px; }
|
||||
.account-help { max-width:320px; padding:24px 0; color:var(--muted); font-size:13px; }
|
||||
.account-help h3 { color:var(--text); margin-top:12px; font-size:18px; }
|
||||
@media(max-width:1000px) { .account-layout { grid-template-columns:1fr; gap:16px; } .account-help { max-width:100%; padding:12px 4px; } }
|
||||
|
||||
+15
-141
@@ -1,142 +1,16 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
.file-panel { border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:var(--shadow); overflow:hidden; }
|
||||
.browser-toolbar { padding:20px; display:flex; align-items:center; gap:12px; border-bottom:1px solid var(--line); }
|
||||
.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 {
|
||||
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);
|
||||
}
|
||||
@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; } }
|
||||
|
||||
@@ -1,54 +1,6 @@
|
||||
.editor-container {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
.editor-container { position:relative; border:1px solid var(--line); border-radius:10px; overflow:hidden; margin-bottom:20px; }
|
||||
#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); }
|
||||
.editor-container:has(.line-numbers) #editor { padding-left:64px; }
|
||||
.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); }
|
||||
.readonly-notice { color:var(--muted); font-size:12px; }
|
||||
.editor-page .d-flex { flex-wrap:wrap; gap:8px; }
|
||||
|
||||
+15
-253
@@ -1,253 +1,15 @@
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#pdfContainer {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
#loadingMessage {
|
||||
position: absolute;
|
||||
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;
|
||||
}
|
||||
.pdf-workspace { padding:16px; }
|
||||
#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; }
|
||||
#loadingMessage { position:absolute; inset:0; background:var(--bg); z-index:1; color:var(--muted); }
|
||||
.page-container { background:white; box-shadow:0 4px 20px #0002; max-width:100%; overflow:hidden; }
|
||||
.page-container canvas { display:block; }
|
||||
.controls { display:flex; justify-content:center; margin-top:16px; }
|
||||
.controls .btn-group { flex-wrap:wrap; justify-content:center; gap:6px; }
|
||||
.controls .btn { font-size:12px; }
|
||||
.actionButton { display:flex; justify-content:center; margin-top:12px; }
|
||||
.actionButton .btn { margin:0; }
|
||||
.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; }
|
||||
.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; }
|
||||
.fullscreen-mode .actionButton { display:none; }
|
||||
@media(max-width:600px) { .pdf-workspace { padding:10px; } .controls .btn { padding:7px 10px; } }
|
||||
|
||||
@@ -1,124 +1,18 @@
|
||||
/* Layout */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Monitor Cards */
|
||||
.monitor-card {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.stats-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:22px; }
|
||||
.stats-cell { min-width:0; }
|
||||
.monitor-card { padding:0; overflow:hidden; box-shadow:var(--shadow); }
|
||||
.monitor-card .card-header { padding:18px 22px; background:var(--surface); border-bottom:1px solid var(--line); }
|
||||
.monitor-card h3 { font-size:14px; }
|
||||
.monitor-card .card-body { padding:24px; min-height:235px; }
|
||||
.monitor-card .card-body > .text-muted { overflow-wrap:anywhere; }
|
||||
.monitor-card .row { flex-wrap:nowrap; gap:10px; }
|
||||
.monitor-card .col-4 { min-width:0; }
|
||||
.monitor-card .fw-bold { font-size:13px; overflow-wrap:anywhere; margin-top:6px; }
|
||||
.monitor-card .small { font-size:11px; }
|
||||
.cpu-gauge { min-height:185px; display:grid; place-items:center; }
|
||||
.raid-summary { text-align:center; padding:20px 0; }
|
||||
.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; }
|
||||
@media(max-width:1000px) { .stats-grid { grid-template-columns:1fr; } }
|
||||
@media(max-width:600px) { .monitor-card .card-body { padding:18px; } .monitor-card .row { gap:6px; } .monitor-card .p-2 { padding:10px 6px; } }
|
||||
|
||||
@@ -1,86 +1,11 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.custom-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
.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; }
|
||||
.media-container img,.media-container video { display:block; max-width:100%; max-height:65vh; object-fit:contain; flex-basis:100%; }
|
||||
.media-container audio { display:block; width:100%; max-width:680px; margin:28px auto; flex-basis:100%; }
|
||||
.media-container .btn { margin:0; }
|
||||
.file-info { max-width:760px; }
|
||||
.file-info h4 { font-size:16px; }
|
||||
.file-info table { width:100%; table-layout:fixed; }
|
||||
.file-info th { width:160px; }
|
||||
.file-info td { overflow-wrap:anywhere; font-size:12px; }
|
||||
.media-page .input-group { flex-wrap:wrap; } .media-page .input-group input { flex:1; min-width:160px; }
|
||||
@media(max-width:600px) { .media-container { padding:12px; } .file-info th { width:110px; } }
|
||||
|
||||
+63
-92
@@ -13,6 +13,7 @@
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
$sftp = initializeSFTP();
|
||||
|
||||
$currentPath = isset($_GET['path']) ? normalizePath($_GET['path']) : $defaultPath;
|
||||
@@ -35,51 +36,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>FTP</title>
|
||||
<title>Files · USB RAID</title>
|
||||
<meta charset="UTF-8">
|
||||
<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="stylesheet" href="../../css/bootstrap.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">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/index.css') ?>">
|
||||
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="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>
|
||||
<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>
|
||||
<?php if (isset($_SESSION["admin"]) && $_SESSION["admin"] == true) { ?>
|
||||
<a href="../adminpanel.php" class="btn btn-primary">Admin Panel</a>
|
||||
<a href="serverstat.php" class="btn btn-primary">Server Status</a>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</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">
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'files',
|
||||
'section' => 'Files',
|
||||
'eyebrow' => 'Your remote storage',
|
||||
'title' => 'All files',
|
||||
'description' => 'Everything in its place. Browse, manage and transfer your files.',
|
||||
'connected' => true,
|
||||
]); ?>
|
||||
<nav class="pathbar" aria-label="Directory path"><a href="index.php" title="Home directory">⌂ <span>Home</span></a><?php
|
||||
$relativePath = trim(substr($currentPath, strlen(rtrim($defaultPath, '/'))), '/');
|
||||
$crumbPath = rtrim($defaultPath, '/');
|
||||
foreach (array_filter(explode('/', $relativePath), 'strlen') as $segment):
|
||||
$crumbPath .= '/' . $segment;
|
||||
?><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>
|
||||
<section class="file-panel" aria-label="File browser">
|
||||
<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 class="file-content">
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<?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 btn-success" 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 btn-info" onclick="document.getElementById('dirInput').click()">Upload Directory</button>
|
||||
<button type="button" class="btn" data-dialog="createFileModal">+ New file</button>
|
||||
<button type="button" class="btn btn-primary" onclick="document.getElementById('fileInput').click()">↑ Upload files</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 folder</button>
|
||||
<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)">
|
||||
<?php } ?>
|
||||
@@ -87,57 +78,57 @@
|
||||
|
||||
<!-- Modal Forms -->
|
||||
<!-- 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-content">
|
||||
<div class="modal-header">
|
||||
<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 class="modal-body">
|
||||
<div class="mb-3">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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-content">
|
||||
<div class="modal-header">
|
||||
<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 class="modal-body">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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-content">
|
||||
<div class="modal-header">
|
||||
<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>
|
||||
<form method="POST" action="rename.php">
|
||||
<div class="modal-body">
|
||||
@@ -149,31 +140,31 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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 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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<!-- End of Modal Forms -->
|
||||
|
||||
<?php if (isset($_SESSION["upPer"]) && $_SESSION["upPer"] == true) { ?>
|
||||
<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>
|
||||
<?php } ?>
|
||||
|
||||
<table>
|
||||
<div class="file-table-scroll"><table class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Size</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -197,15 +188,14 @@
|
||||
|
||||
if ($currentPath !== $defaultPath) : ?>
|
||||
<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>
|
||||
<?php endif;
|
||||
|
||||
foreach ($directories as $directory) : ?>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?>/</a>
|
||||
</td>
|
||||
<tr data-file-name="<?= htmlspecialchars($directory) ?>" data-kind="folder">
|
||||
<td><span class="file-icon folder-icon" aria-hidden="true">▰</span>
|
||||
<a href="?path=<?= urlencode($currentPath . '/' . $directory) ?>"><?= htmlspecialchars($directory) ?></a></td><td class="size-cell">—</td>
|
||||
<td>
|
||||
<?php if (isset($_SESSION["delPer"]) && $_SESSION["delPer"] == true) : ?>
|
||||
<form method="POST" action="delete.php" style="display:inline;">
|
||||
@@ -221,7 +211,7 @@
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?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; ?>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -242,9 +232,8 @@
|
||||
$isArchive = true;
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
<tr data-file-name="<?= htmlspecialchars($file) ?>" data-kind="file">
|
||||
<td><span class="file-icon" aria-hidden="true">▤</span>
|
||||
<?php if ($isMedia): ?>
|
||||
<a class="text-info-emphasis" href="view.php?file=<?= urlencode($currentPath . '/' . $file) ?>">
|
||||
<?= htmlspecialchars($file) ?>
|
||||
@@ -301,36 +290,18 @@
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</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>
|
||||
</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>
|
||||
// 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 src="js/index.js"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'content/ftp/js/index.js') ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+30
-45
@@ -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);
|
||||
}
|
||||
|
||||
@@ -178,8 +182,7 @@ function createDirectory() {
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
alert('Directory created successfully!');
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createDirModal'));
|
||||
if (modal) modal.hide();
|
||||
document.getElementById('createDirModal').close();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to create directory: ' + xhr.responseText || xhr.statusText);
|
||||
@@ -221,8 +224,7 @@ function createFile() {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
alert('File created successfully!');
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createFileModal'));
|
||||
if (modal) modal.hide();
|
||||
document.getElementById('createFileModal').close();
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to create file: ' + (response.message || 'Unknown error'));
|
||||
@@ -236,53 +238,36 @@ function createFile() {
|
||||
};
|
||||
xhr.send(formData);
|
||||
}
|
||||
function openRenameModal(name, path) {
|
||||
function openRenameModal(name, path, isDirectory = false) {
|
||||
document.getElementById('originalName').value = name;
|
||||
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;
|
||||
|
||||
const renameModal = new bootstrap.Modal(document.getElementById('renameModal'));
|
||||
renameModal.show();
|
||||
document.getElementById('renameModal').showModal();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
const themeText = document.getElementById('themeText');
|
||||
const themeIcon = themeToggle.querySelector('.bi');
|
||||
|
||||
function setTheme(theme) {
|
||||
html.setAttribute('data-bs-theme', theme);
|
||||
document.body.classList.remove('theme-light', 'theme-dark');
|
||||
document.body.classList.add('theme-' + theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
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);
|
||||
// Filtering is local to the current directory; no remote request is needed.
|
||||
const search = document.getElementById('fileSearch');
|
||||
const typeFilter = document.getElementById('typeFilter');
|
||||
function filterFiles() {
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
const rows = [...document.querySelectorAll('[data-file-name]')];
|
||||
let visible = 0;
|
||||
rows.forEach(row => {
|
||||
row.hidden = !row.dataset.fileName.toLocaleLowerCase().includes(query) || (typeFilter.value !== 'all' && row.dataset.kind !== typeFilter.value);
|
||||
if (!row.hidden) visible++;
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
+22
-21
@@ -13,6 +13,7 @@ if(!isset($_SESSION['uname'])){
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
$sftp = initializeSFTP();
|
||||
|
||||
$filePath = isset($_GET['file']) ? $_GET['file'] : '';
|
||||
@@ -54,31 +55,30 @@ $showLineNumbers = in_array(strtolower($extension), ['php', 'js', 'html', 'css',
|
||||
$parentDir = dirname($filePath);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Edit File - <?= htmlspecialchars($fileName) ?></title>
|
||||
<meta charset="UTF-8">
|
||||
<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="stylesheet" href="../../css/bootstrap.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/open.css">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/open.css') ?>">
|
||||
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<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">
|
||||
<div class="header-container">
|
||||
<h1>Edit File: <?= htmlspecialchars($fileName) ?></h1>
|
||||
<a href="index.php?path=<?= urlencode($parentDir) ?>" class="btn btn-secondary">Back to File List</a>
|
||||
</div>
|
||||
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'files',
|
||||
'section' => 'File editor',
|
||||
'eyebrow' => 'File editor',
|
||||
'title' => $fileName,
|
||||
'description' => 'View and edit text files in your remote workspace.',
|
||||
'connected' => true,
|
||||
'back' => 'index.php?path=' . urlencode($parentDir),
|
||||
'class' => 'editor-page',
|
||||
]); ?>
|
||||
<section class="content-panel">
|
||||
<?php if (isset($error)): ?>
|
||||
<div class="alert alert-danger"><?= $error ?></div>
|
||||
<?php elseif ($editable): ?>
|
||||
@@ -113,8 +113,9 @@ $parentDir = dirname($filePath);
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<script src="../../js/theme.js"></script>
|
||||
</section>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
|
||||
<script>
|
||||
function updateLineNumbers() {
|
||||
const editor = document.getElementById('editor');
|
||||
|
||||
+49
-58
@@ -8,6 +8,7 @@ if(!isset($_SESSION['uname'])){
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
$sftp = initializeSFTP();
|
||||
|
||||
if (!isset($_GET['file'])) {
|
||||
@@ -111,35 +112,34 @@ if (isset($_GET['stream'])) {
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PDF Viewer</title>
|
||||
<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">
|
||||
<link rel="stylesheet" href="../../css/bootstrap.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/pdf.css">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/pdf.css') ?>">
|
||||
<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>
|
||||
<body>
|
||||
<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="text-center border-bottom m-5">
|
||||
<h1 class="mb-4">PDF Viewer</h1>
|
||||
<div class="mb-3">
|
||||
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
|
||||
</div>
|
||||
</header>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'files',
|
||||
'section' => 'PDF viewer',
|
||||
'eyebrow' => 'Document preview',
|
||||
'title' => $fileName,
|
||||
'description' => 'Browse your document without leaving the workspace.',
|
||||
'connected' => true,
|
||||
'back' => 'index.php?path=' . urlencode(dirname($filePath)),
|
||||
'class' => 'pdf-page',
|
||||
]); ?>
|
||||
|
||||
|
||||
<section class="content-panel pdf-workspace" aria-label="PDF document">
|
||||
<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 class="bg-dark bg-opacity-75 text-white p-3 rounded">
|
||||
@@ -151,17 +151,17 @@ if (isset($_GET['stream'])) {
|
||||
|
||||
<div class="controls">
|
||||
<div class="btn-group" role="group" aria-label="PDF Navigation">
|
||||
<button id="prev" class="btn btn-light border">
|
||||
<i class="bi bi-chevron-left"></i> Previous
|
||||
<button id="prev" class="btn btn-light border" disabled>
|
||||
<i class="ui-icon icon-chevron-left"></i> Previous
|
||||
</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>
|
||||
</span>
|
||||
<button id="next" class="btn btn-light border" disabled>
|
||||
Next <i class="ui-icon icon-chevron-right"></i>
|
||||
</button>
|
||||
<button id="next" class="btn btn-light border">
|
||||
Next <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
<button id="fullscreen" class="btn btn-light border">
|
||||
<i class="bi bi-fullscreen"></i>
|
||||
<button id="fullscreen" aria-label="Toggle fullscreen" class="btn btn-light border">
|
||||
<i class="ui-icon icon-fullscreen"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,26 +171,14 @@ if (isset($_GET['stream'])) {
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</section>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
|
||||
<script>
|
||||
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 pdfFileName = '<?= htmlspecialchars($fileName) ?>';
|
||||
const pdfFileName = <?= json_encode($fileName, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
|
||||
let currentPage = 1;
|
||||
let pdfDoc = null;
|
||||
|
||||
@@ -215,13 +203,14 @@ if (isset($_GET['stream'])) {
|
||||
|
||||
currentPage = Math.min(PdfPageStorage.loadPage(), pdfDoc.numPages);
|
||||
|
||||
renderPage(currentPage);
|
||||
document.getElementById('loadingMessage').classList.add('d-none');
|
||||
await renderPage(currentPage);
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
const loadingMessage = document.getElementById('loadingMessage');
|
||||
loadingMessage.querySelector('div').classList.add('bg-danger');
|
||||
loadingMessage.querySelector('div').innerHTML = '<i class="bi bi-exclamation-triangle"></i> Error loading PDF';
|
||||
if (loadingMessage) {
|
||||
loadingMessage.querySelector('div').className = 'alert alert-danger';
|
||||
loadingMessage.querySelector('div').textContent = 'Unable to load this PDF.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +219,7 @@ if (isset($_GET['stream'])) {
|
||||
const pageContainer = document.createElement('div');
|
||||
pageContainer.className = 'page-container';
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowWidth = document.getElementById('pdfContainer').clientWidth - 32;
|
||||
const windowHeight = window.innerHeight;
|
||||
const viewport = page.getViewport({ scale: 1.0 });
|
||||
|
||||
@@ -262,6 +251,8 @@ if (isset($_GET['stream'])) {
|
||||
document.getElementById('pdfContainer').innerHTML = '';
|
||||
document.getElementById('pdfContainer').appendChild(pageContainer);
|
||||
document.getElementById('pageNum').textContent = pageNumber;
|
||||
document.getElementById('prev').disabled = pageNumber <= 1;
|
||||
document.getElementById('next').disabled = pageNumber >= pdfDoc.numPages;
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
@@ -278,7 +269,7 @@ if (isset($_GET['stream'])) {
|
||||
});
|
||||
|
||||
document.getElementById('next').addEventListener('click', () => {
|
||||
if (currentPage < pdfDoc.numPages) {
|
||||
if (pdfDoc && currentPage < pdfDoc.numPages) {
|
||||
currentPage++;
|
||||
renderPage(currentPage);
|
||||
}
|
||||
@@ -290,14 +281,14 @@ if (isset($_GET['stream'])) {
|
||||
} else if (e.key === 'ArrowLeft' && currentPage > 1) {
|
||||
currentPage--;
|
||||
renderPage(currentPage);
|
||||
} else if (e.key === 'ArrowRight' && currentPage < pdfDoc.numPages) {
|
||||
} else if (e.key === 'ArrowRight' && pdfDoc && currentPage < pdfDoc.numPages) {
|
||||
currentPage++;
|
||||
renderPage(currentPage);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (currentPage) {
|
||||
if (pdfDoc) {
|
||||
renderPage(currentPage);
|
||||
}
|
||||
});
|
||||
@@ -305,28 +296,28 @@ if (isset($_GET['stream'])) {
|
||||
let isFullscreen = false;
|
||||
|
||||
document.getElementById('fullscreen').addEventListener('click', () => {
|
||||
const container = document.querySelector('.custom-container');
|
||||
const container = document.querySelector('.pdf-workspace');
|
||||
const fullscreenBtn = document.getElementById('fullscreen');
|
||||
const fullscreenIcon = fullscreenBtn.querySelector('i');
|
||||
|
||||
if (!isFullscreen) {
|
||||
container.classList.add('fullscreen-mode');
|
||||
document.body.style.overflow = 'hidden';
|
||||
fullscreenIcon.classList.remove('bi-fullscreen');
|
||||
fullscreenIcon.classList.add('bi-fullscreen-exit');
|
||||
fullscreenIcon.classList.remove('icon-fullscreen');
|
||||
fullscreenIcon.classList.add('icon-fullscreen-exit');
|
||||
} else {
|
||||
container.classList.remove('fullscreen-mode');
|
||||
document.body.style.overflow = '';
|
||||
fullscreenIcon.classList.remove('bi-fullscreen-exit');
|
||||
fullscreenIcon.classList.add('bi-fullscreen');
|
||||
fullscreenIcon.classList.remove('icon-fullscreen-exit');
|
||||
fullscreenIcon.classList.add('icon-fullscreen');
|
||||
}
|
||||
|
||||
isFullscreen = !isFullscreen;
|
||||
renderPage(currentPage);
|
||||
if (pdfDoc) renderPage(currentPage);
|
||||
});
|
||||
|
||||
loadPDF();
|
||||
</script>
|
||||
<script src="../../js/theme.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+86
-360
@@ -12,47 +12,20 @@ if (empty($_SESSION['admin']) || $_SESSION['admin'] !== true) {
|
||||
exit();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
use phpseclib3\Net\SSH2;
|
||||
|
||||
// $ssh_host = 'localhost';
|
||||
// $ssh_username = 'uname';
|
||||
// $ssh_password = 'pswd';
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
|
||||
// The configuration helper authenticates using SSH_HOST, SSH_USER and SSH_PASS.
|
||||
$ssh = getSSHConnection();
|
||||
if (!$ssh->login($ssh_username, $ssh_password)) {
|
||||
die('SSH login failed');
|
||||
}
|
||||
|
||||
function getDiskData($ssh, $device) {
|
||||
return $ssh->exec("df -h $device");
|
||||
}
|
||||
require_once __DIR__ . '/lib/serverstats.php';
|
||||
|
||||
function getSystemData($ssh) {
|
||||
// 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);
|
||||
$systemData = getSystemData($ssh, $defaultPath);
|
||||
|
||||
$monitors = [
|
||||
[
|
||||
'id' => 'SystemDisk',
|
||||
'title' => 'System Disk (sda)',
|
||||
'title' => 'System filesystem',
|
||||
'icon' => 'hdd',
|
||||
'color' => 'primary',
|
||||
'type' => 'storage',
|
||||
@@ -60,7 +33,7 @@ $monitors = [
|
||||
],
|
||||
[
|
||||
'id' => 'DataDisk',
|
||||
'title' => 'Data Disk (sdb)',
|
||||
'title' => 'Storage filesystem',
|
||||
'icon' => 'hdd',
|
||||
'color' => 'primary',
|
||||
'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>
|
||||
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Server Status</title>
|
||||
<meta charset="UTF-8">
|
||||
<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="stylesheet" href="../../css/bootstrap.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/serverstat.css">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/serverstat.css') ?>">
|
||||
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></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>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'server',
|
||||
'section' => 'Server status',
|
||||
'eyebrow' => 'Server overview',
|
||||
'title' => 'Server status',
|
||||
'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="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="card shadow-sm bg-body-tertiary">
|
||||
<div class="card-header monitor-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1 class="h4 mb-0">
|
||||
<i class="bi bi-server text-primary me-2"></i>
|
||||
Server Disk Status
|
||||
</h1>
|
||||
<span class="badge bg-light text-dark">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<?= htmlspecialchars($systemData['lastUpdate']) ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-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="stats-grid">
|
||||
<?php foreach ($monitors as $monitor): ?>
|
||||
<div class="col-md-6">
|
||||
<div class="monitor-card card h-100 border-<?= $monitor['color'] ?>">
|
||||
<div class="card-header bg-<?= $monitor['color'] ?> bg-opacity-10">
|
||||
<h3 class="h5 mb-0">
|
||||
<i class="bi bi-<?= $monitor['icon'] ?> text-<?= $monitor['color'] ?> me-2"></i>
|
||||
<?= $monitor['title'] ?>
|
||||
</h3>
|
||||
</div>
|
||||
<section class="stats-cell" aria-label="<?= htmlspecialchars($monitor['title']) ?>">
|
||||
<div class="monitor-card card h-100">
|
||||
<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>
|
||||
<div class="card-body">
|
||||
<?php if ($monitor['type'] === 'storage'):
|
||||
$diskData = $systemData['storage'][$monitor['source']];
|
||||
$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'], '%'));
|
||||
<?php if (!$diskData['available_data']): ?>
|
||||
<p class="text-muted" role="status">Storage statistics are unavailable for this path.</p>
|
||||
<?php else:
|
||||
$usagePercent = min(100, (int) rtrim($diskData['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>
|
||||
<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="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 text-center">
|
||||
<?php foreach (['size' => 'Total', 'used' => 'Used', 'available' => 'Free'] as $key => $label): ?>
|
||||
<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>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($monitor['type'] === 'raid'): ?>
|
||||
<div class="raid-summary">
|
||||
<i class="ui-icon icon-<?= $systemData['raid']['status'] === 'healthy' ? 'check-circle-fill text-success' : 'x-circle-fill text-warning' ?>" aria-hidden="true"></i>
|
||||
<h4><?= $systemData['raid']['arrays'] ? htmlspecialchars($systemData['raid']['type']) . ' Array' : 'Linux MD RAID' ?></h4>
|
||||
<span class="badge"><?= htmlspecialchars(ucfirst($systemData['raid']['status'])) ?></span>
|
||||
<?php foreach ($systemData['raid']['arrays'] as $array): ?>
|
||||
<p class="small text-muted mt-2"><?= htmlspecialchars($array['name'] . ' · ' . $array['type'] . ' · ' . $array['status']) ?></p>
|
||||
<?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>
|
||||
</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>
|
||||
let refreshInterval;
|
||||
let isRefreshing = false;
|
||||
@@ -463,11 +188,6 @@ function updateData(html) {
|
||||
currentLastUpdate.innerHTML = newLastUpdate;
|
||||
}
|
||||
|
||||
// Update CPU gauge if needed
|
||||
const cpuValue = document.querySelector('.cpu-gauge .h4')?.textContent;
|
||||
if (cpuValue) {
|
||||
drawCpuGauge(parseFloat(cpuValue));
|
||||
}
|
||||
}
|
||||
|
||||
function startRefresh() {
|
||||
@@ -476,7 +196,10 @@ function startRefresh() {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
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 => {
|
||||
updateData(html);
|
||||
isRefreshing = false;
|
||||
@@ -506,8 +229,7 @@ document.addEventListener('visibilitychange', () => {
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
startRefresh();
|
||||
drawCpuGauge(parseFloat(document.querySelector('.cpu-gauge .h4')?.textContent || '0'));
|
||||
if (!document.hidden) startRefresh();
|
||||
|
||||
function drawCpuGauge(value) {
|
||||
const canvas = document.getElementById('cpuGauge');
|
||||
@@ -527,7 +249,7 @@ function drawCpuGauge(value) {
|
||||
// Draw background circle
|
||||
ctx.beginPath();
|
||||
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.stroke();
|
||||
|
||||
@@ -543,7 +265,7 @@ function drawCpuGauge(value) {
|
||||
|
||||
// Draw percentage text inside the circle
|
||||
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.textBaseline = 'middle';
|
||||
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');
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
+22
-37
@@ -5,6 +5,7 @@ ini_set('display_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
|
||||
// getDatabaseConnection relocated to central config
|
||||
|
||||
@@ -218,35 +219,32 @@ function formatBytes($bytes, $precision = 2) {
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Shared Media - <?= htmlspecialchars($fileName) ?></title>
|
||||
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
|
||||
<meta charset="UTF-8">
|
||||
<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="../../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/view.css">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
|
||||
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="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>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>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'shared',
|
||||
'section' => 'Shared file',
|
||||
'eyebrow' => 'Shared with you',
|
||||
'title' => $fileName,
|
||||
'description' => 'Preview and access the file shared with you.',
|
||||
'guest' => true,
|
||||
'class' => 'media-page',
|
||||
]); ?>
|
||||
|
||||
<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">
|
||||
<div class="media-container position-relative">
|
||||
<?php if ($isImage): ?>
|
||||
@@ -293,22 +291,9 @@ function formatBytes($bytes, $precision = 2) {
|
||||
</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>
|
||||
</footer>
|
||||
</div>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
|
||||
|
||||
<script src="../../js/theme.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
+30
-44
@@ -14,6 +14,7 @@ error_reporting(E_ALL);
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
require_once __DIR__ . '/../../includes/workspace.php';
|
||||
$sftp = initializeSFTP();
|
||||
|
||||
if (!isset($_GET['file'])) {
|
||||
@@ -253,34 +254,32 @@ if (isset($_GET['stream'])) {
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="<?= isset($_COOKIE['theme']) ? $_COOKIE['theme'] : 'light' ?>">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Media Viewer - <?= htmlspecialchars($fileName) ?></title>
|
||||
<link rel="icon" href="../../img/favicon.ico" type="image/x-icon">
|
||||
<meta charset="UTF-8">
|
||||
<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="../../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/view.css">
|
||||
<script src="../../js/bootstrap.bundle.js"></script>
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/modern.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'css/workspace.css') ?>">
|
||||
<link rel="stylesheet" href="<?= workspaceAsset('../../', 'content/ftp/css/view.css') ?>">
|
||||
<script src="<?= workspaceAsset('../../', 'js/theme.js') ?>"></script>
|
||||
<script src="<?= workspaceAsset('../../', 'js/ui.js') ?>" defer></script>
|
||||
</head>
|
||||
<body class="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>Media Viewer</h1>
|
||||
<div class="mb-3 p-3">
|
||||
<a href="index.php?path=<?= urlencode(dirname($filePath)) ?>" class="btn btn-primary">Back to Files</a>
|
||||
</div>
|
||||
</header>
|
||||
<?php renderWorkspaceStart([
|
||||
'root' => '../../',
|
||||
'active' => 'files',
|
||||
'section' => 'Media preview',
|
||||
'eyebrow' => 'Media preview',
|
||||
'title' => $fileName,
|
||||
'description' => 'Preview your media, download a copy or create a shared link.',
|
||||
'connected' => true,
|
||||
'back' => 'index.php?path=' . urlencode(dirname($filePath)),
|
||||
'class' => 'media-page',
|
||||
]); ?>
|
||||
|
||||
<section class="row">
|
||||
|
||||
<section class="content-panel media-panel">
|
||||
<article class="col-12">
|
||||
<div class="media-container position-relative">
|
||||
<?php if ($isImage): ?>
|
||||
@@ -307,8 +306,8 @@ if (isset($_GET['stream'])) {
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Add Share Button -->
|
||||
<button type="button" class="btn btn-info m-3" data-bs-toggle="modal" data-bs-target="#shareModal">
|
||||
<i class="bi bi-share"></i> Share
|
||||
<button type="button" class="btn btn-info m-3" data-dialog="shareModal">
|
||||
<i class="ui-icon icon-share"></i> Share
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -319,7 +318,7 @@ if (isset($_GET['stream'])) {
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" id="shareUrl" value="<?= htmlspecialchars($shareUrl) ?>" readonly>
|
||||
<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>
|
||||
</div>
|
||||
<p class="small text-muted">This link will expire on <?= date('F j, Y, g:i a', strtotime($shareExpiration)) ?></p>
|
||||
@@ -356,28 +355,15 @@ if (isset($_GET['stream'])) {
|
||||
</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>
|
||||
</footer>
|
||||
</div>
|
||||
<?php renderWorkspaceEnd(); ?>
|
||||
|
||||
<!-- 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-content">
|
||||
<div class="modal-header">
|
||||
<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 class="modal-body">
|
||||
<form method="post" action="">
|
||||
@@ -400,14 +386,14 @@ if (isset($_GET['stream'])) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
function copyShareUrl() {
|
||||
@@ -418,7 +404,7 @@ function copyShareUrl() {
|
||||
// Show feedback
|
||||
const button = shareUrl.nextElementSibling;
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Copied!';
|
||||
button.innerHTML = '<i class="ui-icon icon-check"></i> Copied!';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
@@ -426,7 +412,7 @@ function copyShareUrl() {
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="../../js/theme.js"></script>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
+11
-47
@@ -1,47 +1,11 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
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;
|
||||
}
|
||||
.login-page { display:flex; min-height:100dvh; flex-direction:column; }
|
||||
.login-top,.login-footer { display:flex; align-items:center; justify-content:space-between; padding:28px 5vw; gap:16px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.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; } }
|
||||
@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; } }
|
||||
|
||||
+71
-181
@@ -1,181 +1,71 @@
|
||||
/* Modern dark theme with blue accents */
|
||||
:root {
|
||||
--bg-dark: #171b22;
|
||||
--bg-dark2: #1a202c;
|
||||
--primary-blue: #3b82f6;
|
||||
--text-light: #e5e7eb;
|
||||
--bg-darkblue: #101722;
|
||||
--container-bg-start: #1a2029;
|
||||
--container-bg-end: #151d29;
|
||||
--sftp-bg-start: #151b25;
|
||||
--sftp-bg-end: #101722;
|
||||
--container-border: rgba(77, 145, 255, 0.7);
|
||||
--panel-text: #e5e7eb;
|
||||
--panel-muted: #aab4c3;
|
||||
--control-bg: #101722;
|
||||
--control-border: #39485d;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, var(--bg-dark), var(--bg-darkblue));
|
||||
color: var(--text-light);
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a, .nav-link {
|
||||
color: var(--primary-blue) !important;
|
||||
transition: opacity .2s ease-in-out;
|
||||
}
|
||||
|
||||
body, button, input, select, textarea {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Keep text, fields and tables readable on every page that uses the shared theme. */
|
||||
.surface-panel,
|
||||
.dark-box {
|
||||
color: var(--panel-text);
|
||||
}
|
||||
|
||||
.surface-panel .text-muted,
|
||||
.dark-box .text-muted {
|
||||
color: var(--panel-muted) !important;
|
||||
}
|
||||
|
||||
.surface-panel .form-control,
|
||||
.surface-panel .form-select,
|
||||
.dark-box .form-control,
|
||||
.dark-box .form-select {
|
||||
color: var(--panel-text);
|
||||
background-color: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
}
|
||||
|
||||
.surface-panel .form-control::placeholder,
|
||||
.dark-box .form-control::placeholder {
|
||||
color: var(--panel-muted);
|
||||
}
|
||||
|
||||
.surface-panel .form-control:focus,
|
||||
.surface-panel .form-select:focus,
|
||||
.dark-box .form-control:focus,
|
||||
.dark-box .form-select:focus {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 .2rem rgba(59, 130, 246, .2);
|
||||
}
|
||||
|
||||
.surface-panel .table,
|
||||
.surface-panel .table th,
|
||||
.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;
|
||||
}
|
||||
/* Shared UI primitives, independent of a CSS framework. */
|
||||
: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; }
|
||||
: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; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.55 'Segoe UI',system-ui,sans-serif; }
|
||||
button,input,select,textarea { font:inherit; }
|
||||
a { color:var(--blue); text-decoration:none; } a:hover { text-decoration:underline; }
|
||||
:focus-visible { outline:3px solid var(--blue); outline-offset:3px; }
|
||||
[hidden] { display:none !important; }
|
||||
h1,h2,h3,h4,h5,p { margin:0 0 1rem; }
|
||||
h1 { font-size:clamp(26px,3vw,36px); letter-spacing:-.045em; line-height:1.2; }
|
||||
h2 { font-size:23px; letter-spacing:-.03em; } h3 { font-size:18px; } h4,h5 { font-size:16px; }
|
||||
small,.small,.form-text { font-size:12px; }
|
||||
.text-muted,.form-text { color:var(--muted); } .text-danger { color:var(--danger); } .text-success { color:var(--success); }
|
||||
.text-center { text-align:center; } .text-end { text-align:right; } .text-break,.text-wrap { overflow-wrap:anywhere; } .fw-bold { font-weight:650; }
|
||||
.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; }
|
||||
.btn-danger { color:var(--danger); } .btn-success,.btn-info { color:var(--blue); }
|
||||
.btn-sm { min-height:32px; padding:6px 10px; font-size:12px; } .btn:disabled,.btn.disabled { opacity:.5; cursor:default; }
|
||||
.btn-group,.input-group { display:flex; gap:8px; }
|
||||
.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; }
|
||||
.form-control::placeholder { color:var(--muted); } .form-control:focus,.form-select:focus { border-color:var(--blue); outline:3px solid var(--blue-soft); }
|
||||
label,.form-label { display:inline-block; margin-bottom:6px; font-weight:600; } input[type="checkbox"] { accent-color:var(--blue); width:16px; height:16px; }
|
||||
.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; }
|
||||
.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); }
|
||||
.custom-container > header { padding-bottom:20px; margin:8px 0 24px; }
|
||||
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); }
|
||||
.badge { display:inline-block; padding:2px 7px; border-radius:5px; background:var(--surface-alt); color:var(--muted); font-size:10px; font-weight:600; }
|
||||
.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); }
|
||||
.img-fluid { max-width:100%; height:auto; }
|
||||
html:not([data-theme="dark"]) .dark-logo,[data-theme="dark"] .light-logo { display:none; }
|
||||
footer { color:var(--muted); font-size:12px; } footer img { opacity:.7; max-height:22px; object-fit:contain; }
|
||||
.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; }
|
||||
.container,.container-fluid { width:100%; margin-inline:auto; } .container { max-width:1280px; }
|
||||
.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; }
|
||||
.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; }
|
||||
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); }
|
||||
dialog::backdrop { background:#080f2199; backdrop-filter:blur(4px); }
|
||||
.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); }
|
||||
.btn-close { border:0; background:transparent; color:var(--muted); cursor:pointer; font-size:24px; } .btn-close::before { content:'×'; } body:has(dialog[open]) { overflow:hidden; }
|
||||
.eyebrow { color:var(--muted); font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; }
|
||||
@media(min-width:900px) { .col-md-6 { width:calc(50% - 10px); } .col-lg-4 { width:calc(33.333% - 14px); } }
|
||||
@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. */
|
||||
.ui-icon { font-style:normal; display:inline-block; }
|
||||
.icon-chevron-left::before { content:'‹'; } .icon-chevron-right::before { content:'›'; }
|
||||
.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:'✓'; }
|
||||
.icon-x-circle-fill::before { content:'×'; } .icon-exclamation-triangle::before { content:'!'; }
|
||||
.icon-server::before,.icon-hdd::before,.icon-hdd-stack::before { content:'▤'; }
|
||||
.icon-clock::before { content:'◷'; } .icon-cpu::before { content:'▦'; } .icon-memory::before { content:'▥'; }
|
||||
.text-primary { color:var(--blue); } .text-warning { color:#c98d22; }
|
||||
.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; }
|
||||
@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; }
|
||||
.card-header.bg-success,.card-header.bg-warning,.card-header.bg-danger,.card-header.bg-primary,.card-header.bg-info { background:var(--surface-alt); }
|
||||
.custom-container.dark-box.fullscreen-mode { max-width:none; width:100%; height:100dvh; margin:0; border:0; border-radius:0; padding:0; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -15,68 +15,22 @@ if (empty($_SESSION)) {
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="cz">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<meta charset="UTF-8">
|
||||
<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="stylesheet" href="css/bootstrap.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · USB RAID</title>
|
||||
<link rel="icon" href="img/favicon.ico">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+21
-35
@@ -1,40 +1,26 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themeToggle = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
const themeText = document.getElementById('themeText');
|
||||
const themeIcon = themeToggle.querySelector('.bi');
|
||||
|
||||
(() => {
|
||||
const preference = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
let saved;
|
||||
try { saved = localStorage.getItem('theme'); } catch (_) { /* Storage may be disabled. */ }
|
||||
function setTheme(theme) {
|
||||
html.setAttribute('data-bs-theme', theme);
|
||||
document.body.classList.remove('theme-light', 'theme-dark');
|
||||
document.body.classList.add('theme-' + theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
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');
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const button = document.getElementById('themeToggle');
|
||||
if (button) {
|
||||
button.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
|
||||
const label = document.getElementById('themeText');
|
||||
if (label) label.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) { icon.textContent = theme === 'dark' ? '☀' : '◐'; icon.setAttribute('aria-hidden', 'true'); }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
setTheme(saved === 'dark' || saved === 'light' ? saved : preference.matches ? 'dark' : 'light');
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTheme(document.documentElement.dataset.theme);
|
||||
document.getElementById('themeToggle')?.addEventListener('click', () => {
|
||||
saved = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(saved);
|
||||
try { localStorage.setItem('theme', saved); } catch (_) { /* Keep this session usable. */ }
|
||||
});
|
||||
});
|
||||
preference.addEventListener('change', () => { if (saved !== 'dark' && saved !== 'light') setTheme(preference.matches ? 'dark' : 'light'); });
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user