This commit is contained in:
Bashy 2026-05-03 20:56:15 +03:00
commit 60ca58f5ba
80 changed files with 9458 additions and 0 deletions

44
views/_footer.php Normal file
View file

@ -0,0 +1,44 @@
</main>
<?php $b = buildInfo(); ?>
<footer class="text-center text-muted small py-3 border-top mt-4">
HackmanCMS
<strong>v<?= htmlspecialchars($b['version']) ?></strong>
<?php if ($b['sha'] !== ''): ?>
&middot; <code><?= htmlspecialchars($b['sha']) ?></code>
<?php endif; ?>
<?php if ($b['built'] !== ''): ?>
&middot; build <?= htmlspecialchars($b['built']) ?>
<?php endif; ?>
</footer>
<!-- Keyboard shortcut help (triggered with `?`) -->
<div class="modal fade" id="shortcutsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-keyboard me-1"></i>Keyboard shortcuts</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small">
<table class="table table-sm mb-0">
<tbody>
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
<tr><td><kbd>g</kbd> <kbd>d</kbd></td><td>Go to dashboard</td></tr>
<tr><td><kbd>g</kbd> <kbd>s</kbd></td><td>Go to settings</td></tr>
<tr><td><kbd>g</kbd> <kbd>a</kbd></td><td>Go to audit log</td></tr>
<tr><td><kbd>n</kbd></td><td>New post / draft (project page)</td></tr>
<tr><td><kbd>b</kbd></td><td>Trigger generate (Hexo project page)</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close any open modal</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php if (!empty($extra_scripts)) echo $extra_scripts; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>

65
views/_header.php Normal file
View file

@ -0,0 +1,65 @@
<?php
$_nav_active = $nav_active ?? '';
$_page_title = isset($page_title) ? htmlspecialchars($page_title) . ' — ' : '';
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $_page_title ?>HackmanCMS</title>
<link rel="icon" type="image/png" href="/assets/img/logo.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body>
<nav class="navbar navbar-expand-lg border-bottom px-3">
<a class="navbar-brand fw-bold" href="/">
<img src="/assets/img/logo.png" alt="" width="24" height="24" class="me-2">HackmanCMS
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navMain">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link <?= $_nav_active === 'dashboard' ? 'active' : '' ?>" href="/">
<i class="bi bi-grid-3x3-gap me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link <?= $_nav_active === 'audit' ? 'active' : '' ?>" href="/audit">
<i class="bi bi-journal-text me-1"></i>Audit
</a>
</li>
</ul>
<?php $user = Auth::currentUser(); ?>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
<i class="bi bi-person-circle me-1"></i><?= htmlspecialchars($user['username'] ?? '') ?>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="/settings">
<i class="bi bi-gear me-2"></i>Settings
</a>
</li>
<li>
<a class="dropdown-item" href="/audit">
<i class="bi bi-journal-text me-2"></i>Audit
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="/api/auth?action=logout">
<i class="bi bi-box-arrow-right me-2"></i>Log out
</a>
</li>
</ul>
</li>
</ul>
</div>
</nav>
<main class="container-fluid py-4">

31
views/audit.php Normal file
View file

@ -0,0 +1,31 @@
<?php
$page_title = 'Audit Log';
$nav_active = 'audit';
include ROOT . '/views/_header.php';
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Audit Log</h2>
<div class="d-flex gap-2 align-items-center">
<select id="auditProjectFilter" class="form-select form-select-sm" style="width:auto">
<option value="">All projects</option>
<?php foreach ($db->query('SELECT id, name FROM projects WHERE is_active = 1 ORDER BY name') as $p): ?>
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div id="auditTable">
<div class="text-muted small">Loading…</div>
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-sm btn-outline-secondary d-none" id="auditPrev">
<i class="bi bi-chevron-left me-1"></i>Prev
</button>
<button class="btn btn-sm btn-outline-secondary d-none" id="auditNext">
Next<i class="bi bi-chevron-right ms-1"></i>
</button>
</div>
<?php include ROOT . '/views/_footer.php'; ?>

128
views/dashboard.php Normal file
View file

@ -0,0 +1,128 @@
<?php
$page_title = 'Dashboard';
$nav_active = 'dashboard';
include ROOT . '/views/_header.php';
$projects = $db->query('SELECT * FROM projects WHERE is_active = 1 ORDER BY is_pinned DESC, name')->fetchAll();
?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h4 mb-0">Projects</h2>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addProjectModal">
<i class="bi bi-plus-lg me-1"></i>Add project
</button>
</div>
<?php if (empty($projects)): ?>
<div class="text-center text-muted py-5">
<i class="bi bi-folder-x fs-1 d-block mb-3 opacity-50"></i>
<p>No projects yet.</p>
<a href="/settings" class="btn btn-outline-secondary btn-sm">Configure scan paths</a>
<button class="btn btn-primary btn-sm ms-2" data-bs-toggle="modal" data-bs-target="#addProjectModal">Add manually</button>
</div>
<?php else: ?>
<div class="row g-3">
<?php foreach ($projects as $proj):
$type = ProjectTypes::get($proj['type']);
$icon = $type ? $type::typeIcon() : 'bi-folder';
$typeName = $type ? $type::typeName() : $proj['type'];
$hasRun = $type && in_array('run', $type::tabs());
?>
<div class="col-sm-6 col-lg-4 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-2 gap-2">
<i class="bi <?= htmlspecialchars($icon) ?> text-primary fs-5 flex-shrink-0"></i>
<h6 class="card-title mb-0 text-truncate"><?= htmlspecialchars($proj['name']) ?></h6>
</div>
<p class="text-muted small mb-2 text-truncate" title="<?= htmlspecialchars($proj['path']) ?>">
<code><?= htmlspecialchars($proj['path']) ?></code>
</p>
<div class="d-flex align-items-center gap-1 flex-wrap mt-1">
<span class="badge bg-secondary"><?= htmlspecialchars($typeName) ?></span>
<?php if ($proj['is_pinned']): ?>
<span class="badge bg-warning text-dark"><i class="bi bi-pin-fill"></i></span>
<?php endif; ?>
<?php if ($proj['url']): ?>
<a href="<?= htmlspecialchars($proj['url']) ?>" target="_blank" rel="noopener"
class="badge bg-dark text-decoration-none" title="Open site">
<i class="bi bi-box-arrow-up-right"></i>
</a>
<?php endif; ?>
<span class="badge bg-secondary-subtle text-body-secondary border project-disk d-none"
data-project-id="<?= $proj['id'] ?>" title="Project size (excl. node_modules, public, .git)">
<i class="bi bi-hdd me-1"></i><span class="project-disk-value"></span>
</span>
</div>
</div>
<div class="card-footer d-flex gap-2">
<a href="/project/<?= $proj['id'] ?>" class="btn btn-sm btn-outline-primary flex-grow-1">Open</a>
<?php if ($hasRun): ?>
<a href="/project/<?= $proj['id'] ?>?tab=run" class="btn btn-sm btn-outline-secondary" title="Run commands">
<i class="bi bi-terminal"></i>
</a>
<?php endif; ?>
<button class="btn btn-sm btn-outline-secondary btn-pin-project flex-shrink-0"
data-id="<?= $proj['id'] ?>"
title="<?= $proj['is_pinned'] ? 'Unpin' : 'Pin' ?>">
<i class="bi <?= $proj['is_pinned'] ? 'bi-pin-fill text-warning' : 'bi-pin' ?>"></i>
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Activity feed -->
<div class="mt-5">
<div class="d-flex align-items-center mb-3">
<h6 class="mb-0"><i class="bi bi-journal-text me-1"></i>Recent activity</h6>
<a href="/audit" class="ms-auto small text-decoration-none">View all </a>
</div>
<div id="activityFeed" class="list-group list-group-flush">
<div class="list-group-item text-muted small bg-transparent">Loading…</div>
</div>
</div>
<!-- Add Project Modal -->
<div class="modal fade" id="addProjectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add project</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addProjectForm">
<div class="modal-body">
<div id="addProjectError" class="alert alert-danger d-none"></div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Path on server</label>
<input type="text" name="path" class="form-control" placeholder="/var/www/mysite" required>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<option value="<?= htmlspecialchars($slug) ?>"><?= htmlspecialchars($class::typeName()) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">URL <span class="text-muted">(optional)</span></label>
<input type="url" name="url" class="form-control" placeholder="https://...">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add</button>
</div>
</form>
</div>
</div>
</div>
<?php include ROOT . '/views/_footer.php'; ?>

24
views/error.php Normal file
View file

@ -0,0 +1,24 @@
<?php
$status = http_response_code() ?: 404;
$messages = [404 => 'Page not found', 403 => 'Forbidden', 500 => 'Server error'];
$msg = $messages[$status] ?? 'Something went wrong';
if (Auth::check()):
$page_title = $status;
$nav_active = '';
include ROOT . '/views/_header.php';
?>
<div class="text-center py-5">
<p class="display-3 fw-bold text-muted"><?= $status ?></p>
<p class="lead mb-4"><?= htmlspecialchars($msg) ?></p>
<a href="/" class="btn btn-primary">Go home</a>
</div>
<?php
include ROOT . '/views/_footer.php';
else:
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head><meta charset="UTF-8"><title><?= $status ?></title></head>
<body class="text-center pt-5"><?= $status ?> <?= htmlspecialchars($msg) ?></body>
</html>
<?php endif; ?>

51
views/login.php Normal file
View file

@ -0,0 +1,51 @@
<?php
$needs_setup = !Auth::hasUsers($db);
?>
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login HackmanCMS</title>
<link rel="icon" type="image/png" href="/assets/img/logo.png">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/assets/css/app.css">
</head>
<body class="d-flex align-items-center justify-content-center min-vh-100">
<div class="card" style="width:360px">
<div class="card-body p-4">
<h4 class="card-title mb-4 text-center">
<img src="/assets/img/logo.png" alt="" width="40" height="40" class="me-2">HackmanCMS
</h4>
<?php if (!empty($error)): ?>
<div class="alert alert-danger py-2"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($needs_setup): ?>
<p class="text-muted small">First run create your admin account.</p>
<?php endif; ?>
<form method="POST" action="/api/auth">
<input type="hidden" name="action" value="<?= $needs_setup ? 'setup' : 'login' ?>">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required>
</div>
<?php if ($needs_setup): ?>
<div class="mb-3">
<label class="form-label">Confirm password</label>
<input type="password" name="password_confirm" class="form-control" required>
</div>
<?php endif; ?>
<button type="submit" class="btn btn-primary w-100">
<?= $needs_setup ? 'Create account' : 'Log in' ?>
</button>
</form>
</div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,105 @@
<?php
$pStmt = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
$pStmt->execute([$pid]);
$pSettings = [];
foreach ($pStmt->fetchAll() as $row) $pSettings[$row['key']] = $row['value'];
?>
<div id="analyticsPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Analytics</h5>
<select id="analyticsRange" class="form-select form-select-sm py-0 ms-auto" style="width:auto">
<option value="7" selected>Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="365">Last year</option>
</select>
<button class="btn btn-sm btn-outline-secondary" id="analyticsRefreshBtn" title="Refresh">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div id="analyticsBody" class="text-muted small">Loading…</div>
<!-- Setup / log import collapsed by default. -->
<details class="mt-4" id="analyticsSetupDetails">
<summary class="small text-muted">
<i class="bi bi-gear me-1"></i>Server-log import setup
</summary>
<div class="mt-2">
<p class="text-muted small mb-2">
Imports page-view data from the web server's access log. No JS, no pixel,
no client-side change to the managed site. Set the path of the access log
below; HackmanCMS tails new lines on each import. Asset hits, non-GETs and
obvious bots are dropped.
</p>
<div id="analyticsSettings" data-project-id="<?= $pid ?>" class="mb-2">
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Access log file</label>
<input type="text" id="analyticsLogPath" class="form-control form-control-sm font-monospace"
value="<?= htmlspecialchars($pSettings['analytics_log_path'] ?? '') ?>"
placeholder="/var/log/apache2/blog.example.com_access.log">
</div>
<div class="col-md-3">
<label class="form-label small">Log format</label>
<select id="analyticsLogFormat" class="form-select form-select-sm">
<?php $fmt = $pSettings['analytics_log_format'] ?? 'combined'; ?>
<option value="combined" <?= $fmt === 'combined' ? 'selected' : '' ?>>Apache combined</option>
<option value="nginx" <?= $fmt === 'nginx' ? 'selected' : '' ?>>nginx default</option>
</select>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-sm btn-outline-secondary w-100" id="analyticsSaveBtn">Save</button>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-7">
<label class="form-label small">Path prefix filter <span class="text-muted">(optional)</span></label>
<input type="text" id="analyticsLogFilter" class="form-control form-control-sm font-monospace"
value="<?= htmlspecialchars($pSettings['analytics_log_filter'] ?? '') ?>"
placeholder="/blog">
</div>
<div class="col-md-3">
<label class="form-label small d-block">&nbsp;</label>
<button class="btn btn-sm btn-primary w-100" id="analyticsImportBtn">
<i class="bi bi-arrow-down-circle me-1"></i>Import now
</button>
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-sm btn-outline-warning w-100" id="analyticsResetBtn"
title="Discard last-position state and reimport from start">
<i class="bi bi-arrow-counterclockwise"></i>
</button>
</div>
</div>
<div id="analyticsStatus" class="small text-muted">
<?php if (isset($pSettings['analytics_imported_at'])): ?>
Last import: <?= htmlspecialchars($pSettings['analytics_imported_at']) ?>
(<?= htmlspecialchars($pSettings['analytics_imported_count'] ?? '0') ?> rows total).
<?php else: ?>
No imports yet.
<?php endif; ?>
</div>
<p class="text-muted small mb-0 mt-2">
Continuous tracking install once as a user that can read the log:
<br>
<code class="small">*/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php</code>
</p>
<p class="text-muted small mb-0 mt-2">
<i class="bi bi-archive"></i>
<strong>Retention:</strong> raw events for 90 days, hourly rollups
for 365 days, daily rollups forever. Visitor IPs are hashed with a
<strong>daily-rotating salt</strong> so visitors look like new
visitors across days "uniques over a multi-day window" is
therefore the sum of per-day unique counts.
<?php if (isset($pSettings['analytics_last_rollup'])): ?>
Last rollup:
<?= htmlspecialchars($pSettings['analytics_last_rollup']) ?>.
<?php endif; ?>
</p>
</div>
</div>
</details>
</div>

View file

@ -0,0 +1,15 @@
<div id="configPanel" data-project-id="<?= $pid ?>">
<div class="d-flex justify-content-between align-items-center mb-2 gap-2 flex-wrap">
<select id="configFileSelect" class="form-select form-select-sm" style="width:auto;min-width:220px">
<option value="_config.yml">_config.yml (blog)</option>
</select>
<div class="d-flex align-items-center gap-2">
<span id="configSaveStatus" class="text-muted small"></span>
<button class="btn btn-sm btn-primary" id="configSaveBtn">
<i class="bi bi-floppy me-1"></i>Save
</button>
</div>
</div>
<div id="configEditor"
style="border:1px solid var(--hm-border);border-radius:4px;overflow:hidden"></div>
</div>

View file

@ -0,0 +1,44 @@
<div id="projectDashboard" data-project-id="<?= $pid ?>"
data-project-type="<?= htmlspecialchars($project['type']) ?>">
<?php if ($isHexo): ?>
<!-- Tags + categories -->
<div id="tagsPanel" data-project-id="<?= $pid ?>" class="mb-4">
<div id="tagsLoading" class="text-muted small">Loading tags…</div>
<div id="tagsContent" class="d-none">
<div class="row g-4">
<div class="col-md-6">
<h6 class="mb-2 text-muted">
<i class="bi bi-tags me-1"></i>Tags
<span id="tagsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="tagCloud" class="tag-cloud"></div>
</div>
<div class="col-md-6">
<h6 class="mb-2 text-muted">
<i class="bi bi-folder me-1"></i>Categories
<span id="catsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="catCloud" class="tag-cloud"></div>
</div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Recent files -->
<div id="recentPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-2 gap-2">
<h6 class="mb-0 text-muted">
<i class="bi bi-clock-history me-1"></i>Recent files
</h6>
<button class="btn btn-sm btn-outline-secondary ms-auto py-0" id="recentClearBtn">
<i class="bi bi-trash"></i> Clear
</button>
</div>
<div id="recentList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<div id="draftsPanel" class="row g-0 h-split" data-project-id="<?= $pid ?>">
<!-- Left: draft list -->
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="mb-3">
<button class="btn btn-sm btn-primary" id="newDraftBtn">
<i class="bi bi-plus-lg me-1"></i>New draft
</button>
</div>
<div id="draftsList">
<div class="text-muted small">Loading…</div>
</div>
</div>
<!-- Right: draft editor -->
<div class="col-md-7 col-xl-8 border-start ps-3" id="draftEditorPane">
<div class="editor-placeholder">
<i class="bi bi-pencil-square fs-1 d-block mb-2 opacity-25"></i>
<span>Select a draft or create a new one</span>
</div>
</div>
</div>

View file

@ -0,0 +1,84 @@
<div id="gitPanel" data-project-id="<?= $pid ?>">
<!-- Subdir picker (hidden until needed) -->
<div id="gitSubdirSelector" class="d-none alert alert-info py-2 d-flex align-items-center gap-3 mb-3">
<i class="bi bi-git flex-shrink-0"></i>
<span class="flex-grow-1 small">No git repo at project root. Found in subdirectory:</span>
<select id="gitSubdirSelect" class="form-select form-select-sm flex-shrink-0" style="width:auto"></select>
<button class="btn btn-sm btn-primary flex-shrink-0" id="gitSubdirUseBtn">Use</button>
</div>
<div id="gitNoRepo" class="alert alert-warning d-none">
<i class="bi bi-exclamation-triangle me-2"></i>Not a git repository and no git repos found in subdirectories.
</div>
<div id="gitStatus" class="d-none">
<!-- Branch + summary -->
<div class="d-flex align-items-center gap-3 mb-3 flex-wrap">
<span class="badge bg-secondary fs-6" id="gitBranch"><i class="bi bi-git me-1"></i></span>
<span class="text-muted small" id="gitStatusSummary"></span>
<div class="ms-auto d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="gitStashBtn">Stash</button>
<button class="btn btn-sm btn-outline-secondary" id="gitStashPopBtn">Stash pop</button>
<button class="btn btn-sm btn-outline-danger" id="gitResetBtn">Reset HEAD</button>
</div>
</div>
<!-- Changed files -->
<div id="gitFiles" class="mb-2"></div>
<!-- Commit form -->
<div class="input-group mb-3" style="max-width:700px">
<input type="text" id="gitCommitMsg" class="form-control form-control-sm"
placeholder="Commit message — stages all changed files">
<button class="btn btn-sm btn-primary" id="gitCommitBtn">
<i class="bi bi-check2 me-1"></i>Commit
</button>
</div>
<!-- Pull / Push -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-sm btn-outline-secondary" id="gitPullBtn">
<i class="bi bi-cloud-download me-1"></i>Pull
</button>
<button class="btn btn-sm btn-outline-secondary" id="gitPushBtn">
<i class="bi bi-cloud-upload me-1"></i>Push
</button>
</div>
<pre id="gitStreamOutput" class="small p-2 rounded d-none"
style="max-height:200px;overflow-y:auto;background:var(--hm-bg)"></pre>
</div>
<!-- Sub-tabs -->
<div class="d-flex gap-2 mb-3 mt-2">
<button class="btn btn-sm btn-outline-primary active" id="gitTabLogBtn">Log</button>
<button class="btn btn-sm btn-outline-secondary" id="gitTabBranchesBtn">Branches</button>
</div>
<div id="gitLogPanel">
<div id="gitLogTable" class="text-muted small">Loading…</div>
</div>
<div id="gitBranchesPanel" class="d-none">
<div id="gitBranchList" class="mb-3"></div>
<div class="input-group input-group-sm" style="max-width:400px">
<input type="text" id="gitNewBranch" class="form-control" placeholder="new-branch-name">
<button class="btn btn-outline-secondary" id="gitCreateBranchBtn">
<i class="bi bi-plus-lg me-1"></i>Create &amp; switch
</button>
</div>
</div>
</div>
<!-- Diff viewer modal -->
<div class="modal fade" id="gitDiffModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="gitDiffTitle">Diff</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<pre id="gitDiffContent" class="diff-view m-0 p-3 small" style="overflow-x:auto"></pre>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
<div id="linksPanel" data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-link-45deg text-primary"></i>
<span class="small text-muted">
Scans posts/pages for HTTP/HTTPS links (and internal links if a project URL is set)
and reports any that are unreachable.
</span>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="linksOnlyBroken" checked>
<label class="form-check-label small" for="linksOnlyBroken">Broken only</label>
</div>
<button class="btn btn-sm btn-primary" id="linksScanBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Run scan
</button>
</div>
<?php if (!($project['url'] ?? '')): ?>
<div class="alert alert-warning small py-2">
No project URL is set. Internal links (starting with <code>/</code>) and relative
links will be skipped. Set a URL on Settings Display name area or via project edit
to enable them.
</div>
<?php endif; ?>
<div id="linksRunInfo" class="text-muted small mb-2"></div>
<div id="linksTableWrap" class="table-responsive">
<div class="text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div id="scratchpadCard" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-2 gap-2">
<i class="bi bi-sticky text-warning"></i>
<span class="small text-muted">Quick notes pre-deploy TODOs, reminders. Auto-saves.</span>
<span id="scratchpadStatus" class="text-muted small ms-auto"></span>
</div>
<textarea id="scratchpadInput"
class="form-control font-monospace"
style="height: calc(var(--hm-tab-height) - 30px)"
placeholder="Start typing — saves automatically…"><?= htmlspecialchars((string)($project['scratchpad'] ?? '')) ?></textarea>
</div>

View file

@ -0,0 +1,27 @@
<div id="pluginsPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-puzzle text-primary"></i>
<span class="small text-muted">
Hexo plugins listed from <code>package.json</code>. Install/uninstall runs npm.
</span>
</div>
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex gap-2 align-items-center">
<input type="text" id="pluginInstallName" class="form-control form-control-sm font-monospace"
placeholder="hexo-…" style="max-width:300px">
<button class="btn btn-sm btn-primary" id="pluginInstallBtn">
<i class="bi bi-cloud-download me-1"></i>Install
</button>
<small class="text-muted ms-2">npm install --save</small>
</div>
<div id="pluginInstallLog" class="small font-monospace text-muted mt-2 d-none"
style="white-space:pre-wrap;max-height:200px;overflow:auto"></div>
</div>
</div>
<div id="pluginsList" class="row g-2">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
<div id="recentPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2">
<i class="bi bi-clock-history text-info"></i>
<span class="small text-muted">Recently opened or saved files in this project.</span>
<button class="btn btn-sm btn-outline-secondary ms-auto" id="recentClearBtn">
<i class="bi bi-trash"></i> Clear
</button>
</div>
<div id="recentList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
<div id="searchPanel" data-project-id="<?= $pid ?>">
<div class="input-group mb-3" style="max-width:600px">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" id="searchQuery" class="form-control"
placeholder="Search in markdown files…" autocomplete="off">
<button class="btn btn-outline-secondary" id="searchClearBtn">Clear</button>
</div>
<div id="searchResults"></div>
</div>

View file

@ -0,0 +1,231 @@
<?php
// Load current project settings from DB
$psStmt = $db->prepare('SELECT key, value FROM project_settings WHERE project_id = ?');
$psStmt->execute([$pid]);
$pSettings = [];
foreach ($psStmt->fetchAll() as $row) {
$pSettings[$row['key']] = $row['value'];
}
?>
<?php
$schedules = [];
if ($isHexo) {
$sStmt = $db->prepare('SELECT * FROM scheduled_builds WHERE project_id = ? ORDER BY id');
$sStmt->execute([$pid]);
$schedules = $sStmt->fetchAll();
}
?>
<div id="settingsPanel" data-project-id="<?= $pid ?>">
<!-- Project -->
<h6 class="mb-3">Project</h6>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label small">Display name</label>
<div class="input-group input-group-sm">
<input type="text" id="projectNameInput" class="form-control"
value="<?= htmlspecialchars($project['name']) ?>">
<button class="btn btn-outline-secondary" id="projectNameSaveBtn">Save</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label small">Project type</label>
<div class="input-group input-group-sm">
<select class="form-select" id="projectTypeSelectSettings">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<option value="<?= htmlspecialchars($slug) ?>" <?= $project['type'] === $slug ? 'selected' : '' ?>>
<?= htmlspecialchars($class::typeName()) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-outline-secondary" id="projectTypeSaveBtn">Save</button>
</div>
</div>
</div>
<?php if ($isHexo): ?>
<hr>
<!-- Broken link checker -->
<h6 class="mb-2 mt-3">Broken link checker</h6>
<p class="text-muted small mb-2">
Scans posts/pages for HTTP/HTTPS links (and internal links if a project URL is set)
and reports any that are unreachable.
</p>
<div id="linksPanel" class="mb-4"
data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<div class="d-flex align-items-center gap-2 mb-2">
<button class="btn btn-sm btn-primary" id="linksScanBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Run scan
</button>
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="linksOnlyBroken" checked>
<label class="form-check-label small" for="linksOnlyBroken">Broken only</label>
</div>
<span id="linksRunInfo" class="text-muted small ms-auto"></span>
</div>
<div id="linksTableWrap" class="table-responsive"></div>
</div>
<?php endif; ?>
<hr>
<!-- Backup & Export -->
<h6 class="mb-2 mt-3">Backup &amp; export</h6>
<p class="text-muted small mb-2">
Download a zip of the project source.
Excludes <code>node_modules</code>, <code>public</code>, and <code>.git</code>.
</p>
<a class="btn btn-sm btn-outline-primary mb-4"
href="/api/backup?project_id=<?= $pid ?>">
<i class="bi bi-download me-1"></i>Download backup (.zip)
</a>
<?php if ($isHexo): ?>
<hr>
<!-- Scheduled builds (Hexo) -->
<h6 class="mb-2 mt-3">Scheduled builds</h6>
<p class="text-muted small mb-2">
Run a project command on a cron schedule. Output is saved to command history.
Requires the system cron entry to be installed (see <code>docs/scheduled-builds.md</code>).
</p>
<div id="schedulesList" class="mb-2">
<?php if (!$schedules): ?>
<div class="text-muted small">No schedules yet.</div>
<?php else: foreach ($schedules as $s): ?>
<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1 schedule-row"
data-id="<?= (int)$s['id'] ?>">
<div class="form-check form-switch m-0">
<input class="form-check-input schedule-enabled" type="checkbox"
<?= $s['is_enabled'] ? 'checked' : '' ?>>
</div>
<select class="form-select form-select-sm schedule-cmd" style="max-width:150px">
<?php foreach ($type::commands() as $c): ?>
<option value="<?= htmlspecialchars($c['id']) ?>"
<?= $s['cmd_id'] === $c['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($c['label']) ?>
</option>
<?php endforeach; ?>
</select>
<input type="text" class="form-control form-control-sm font-monospace schedule-cron"
placeholder="0 3 * * *" value="<?= htmlspecialchars($s['cron']) ?>"
style="max-width:160px">
<small class="text-muted small flex-grow-1">
<?php if ($s['last_run_at']): ?>
last: <?= htmlspecialchars($s['last_run_at']) ?>
(<?= htmlspecialchars($s['last_status'] ?? '') ?>)
<?php else: ?>
never run
<?php endif; ?>
</small>
<button class="btn btn-sm btn-outline-secondary schedule-save" title="Save">
<i class="bi bi-check-lg"></i>
</button>
<button class="btn btn-sm btn-outline-danger schedule-delete" title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
<?php endforeach; endif; ?>
</div>
<button class="btn btn-sm btn-outline-primary mb-4" id="newScheduleBtn">
<i class="bi bi-plus-lg me-1"></i>Add schedule
</button>
<hr>
<!-- Page directories (Hexo) -->
<h6 class="mb-2 mt-3">Page directories</h6>
<p class="text-muted small mb-2">
Subdirectories of <code>source/</code> scanned for pages, one per line.
Leave empty to scan only <code>source/*.md</code>.
</p>
<textarea id="pageDirsInput" class="form-control form-control-sm font-monospace mb-2" rows="3"
placeholder="p&#10;pages"><?= htmlspecialchars($pSettings['page_dirs'] ?? "p\npages") ?></textarea>
<button class="btn btn-sm btn-outline-primary mb-4" id="pageDirsSaveBtn">Save</button>
<hr>
<!-- Templates -->
<h6 class="mb-3 mt-3">Post Templates</h6>
<p class="text-muted small">
Full post file content (front matter + body). Applied when creating a new post.
</p>
<div id="templatesList" class="mb-3"></div>
<button class="btn btn-sm btn-outline-primary mb-4" id="newTemplateBtn">
<i class="bi bi-plus-lg me-1"></i>New template
</button>
<hr>
<!-- Snippets -->
<h6 class="mb-3 mt-3">Snippets</h6>
<p class="text-muted small">
Reusable markdown fragments. Insert into post body via the snippet picker.
</p>
<div id="snippetsList" class="mb-3"></div>
<button class="btn btn-sm btn-outline-primary" id="newSnippetBtn">
<i class="bi bi-plus-lg me-1"></i>New snippet
</button>
<?php endif; ?>
</div>
<?php if ($isHexo): ?>
<!-- Template editor modal -->
<div class="modal fade" id="templateModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="templateModalTitle">New template</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="templateId">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" id="templateName" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small">Content <span class="text-muted">(full post: front matter + body)</span></label>
<div id="templateContentEditor" class="border rounded" style="height:400px"></div>
<textarea id="templateContent" style="display:none"></textarea>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="templateSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Snippet editor modal -->
<div class="modal fade" id="snippetModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="snippetModalTitle">New snippet</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="snippetId">
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" id="snippetName" class="form-control form-control-sm">
</div>
<div>
<label class="form-label small">Content</label>
<div id="snippetContentEditor" class="border rounded" style="height:250px"></div>
<textarea id="snippetContent" style="display:none"></textarea>
</div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="snippetSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<?php endif; ?>

View file

@ -0,0 +1,19 @@
<div id="tagsPanel" data-project-id="<?= $pid ?>">
<div id="tagsLoading" class="text-muted small">Loading…</div>
<div id="tagsContent" class="d-none">
<div class="row g-4">
<div class="col-md-6">
<h6 class="mb-3 text-muted">
Tags <span id="tagsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="tagCloud" class="tag-cloud"></div>
</div>
<div class="col-md-6">
<h6 class="mb-3 text-muted">
Categories <span id="catsCount" class="badge bg-secondary ms-1">0</span>
</h6>
<div id="catCloud" class="tag-cloud"></div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,46 @@
<div id="themesPanel" data-project-id="<?= $pid ?>">
<div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
<i class="bi bi-palette text-primary"></i>
<span class="small text-muted">
Manage Hexo themes installed under <code>themes/</code>. Switching writes to <code>_config.yml</code>.
</span>
<button class="btn btn-sm btn-outline-primary ms-auto" id="cloneThemeBtn" data-bs-toggle="modal"
data-bs-target="#cloneThemeModal">
<i class="bi bi-cloud-download me-1"></i>Clone from git URL
</button>
</div>
<div id="themesList" class="row g-3">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>
<!-- Clone modal -->
<div class="modal fade" id="cloneThemeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Clone theme</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label small">Git URL</label>
<input type="text" id="cloneThemeUrl" class="form-control form-control-sm font-monospace"
placeholder="https://github.com/user/hexo-theme-foo.git">
</div>
<div class="mb-2">
<label class="form-label small">Folder name <span class="text-muted">(optional)</span></label>
<input type="text" id="cloneThemeName" class="form-control form-control-sm"
placeholder="auto from URL">
</div>
<div id="cloneThemeLog" class="small font-monospace text-muted"
style="white-space:pre-wrap;max-height:200px;overflow:auto"></div>
</div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="cloneThemeSubmit">Clone</button>
</div>
</div>
</div>
</div>

431
views/project/view.php Normal file
View file

@ -0,0 +1,431 @@
<?php
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ?');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); include ROOT . '/views/error.php'; exit; }
$type = ProjectTypes::get($project['type']);
$tabs = $type ? $type::tabs() : ['files'];
$isHexo = $project['type'] === 'hexo';
$isStorage = $project['type'] === 'storage';
$pid = (int)$project['id'];
// Remove 'git' tab if no git repo found at project root or one level deep
if (in_array('git', $tabs)) {
$projectPath = $project['path'];
$hasGit = is_dir($projectPath . '/.git');
if (!$hasGit) {
foreach (@scandir($projectPath) ?: [] as $item) {
if ($item[0] === '.') continue;
if (is_dir($projectPath . '/' . $item . '/.git')) { $hasGit = true; break; }
}
}
if (!$hasGit) $tabs = array_values(array_diff($tabs, ['git']));
}
$tab = $_GET['tab'] ?? $tabs[0];
if (!in_array($tab, $tabs)) $tab = $tabs[0];
$page_title = $project['name'];
$nav_active = '';
include ROOT . '/views/_header.php';
?>
<?php
$tabLabels = [
'dashboard' => 'Dashboard', 'analytics' => 'Analytics',
'posts' => 'Posts', 'config' => 'Config',
'files' => 'Files', 'media' => 'Media',
'run' => 'Run', 'themes' => 'Themes',
'plugins' => 'Plugins', 'git' => 'Git',
'notes' => 'Notes', 'settings' => 'Settings',
];
$tabIcons = [
'dashboard' => 'bi-grid-1x2', 'analytics' => 'bi-graph-up',
'posts' => 'bi-file-earmark-text','config' => 'bi-sliders',
'files' => 'bi-folder2', 'media' => 'bi-images',
'run' => 'bi-terminal', 'themes' => 'bi-palette',
'plugins' => 'bi-puzzle', 'git' => 'bi-git',
'notes' => 'bi-sticky', 'settings' => 'bi-gear',
];
$tabGroups = [
['dashboard', 'analytics'],
['posts', 'config', 'files', 'media'],
['run', 'themes', 'plugins', 'git'],
['notes', 'settings'],
];
?>
<!-- Sidebar + content -->
<div class="project-layout d-flex gap-3 align-items-start">
<aside class="project-sidebar" id="projectSidebar">
<button type="button" class="btn btn-sm btn-link text-body-secondary p-0 sidebar-toggle"
id="sidebarToggle" title="Collapse sidebar" aria-label="Collapse sidebar">
<i class="bi bi-chevron-double-left collapse-icon-expanded"></i>
<i class="bi bi-chevron-double-right collapse-icon-collapsed"></i>
</button>
<!-- Project meta header -->
<div class="project-sidebar-header mb-2">
<div class="d-flex align-items-center gap-1">
<i class="bi <?= htmlspecialchars($type ? $type::typeIcon() : 'bi-folder') ?> text-primary flex-shrink-0"></i>
<span class="fw-semibold text-truncate sidebar-label" title="<?= htmlspecialchars($project['path']) ?>">
<?= htmlspecialchars($project['name']) ?>
</span>
<div class="dropdown ms-auto flex-shrink-0 sidebar-label">
<button class="btn btn-sm btn-link text-body-secondary p-0 px-1"
data-bs-toggle="dropdown" aria-expanded="false" title="More">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><span class="dropdown-item-text small text-muted text-truncate d-block" style="max-width:280px"
title="<?= htmlspecialchars($project['path']) ?>">
<code><?= htmlspecialchars($project['path']) ?></code>
</span></li>
<li><hr class="dropdown-divider"></li>
<li>
<button type="button" class="dropdown-item text-danger"
data-bs-toggle="modal" data-bs-target="#deleteProjectModal">
<i class="bi bi-trash me-2"></i>Remove project
</button>
</li>
</ul>
</div>
</div>
<?php if ($project['url']): ?>
<a href="<?= htmlspecialchars($project['url']) ?>" target="_blank" rel="noopener"
class="d-inline-flex align-items-center gap-1 small text-decoration-none sidebar-label mt-1"
title="<?= htmlspecialchars($project['url']) ?>">
<i class="bi bi-box-arrow-up-right"></i>
<span class="text-truncate"><?= htmlspecialchars(preg_replace('#^https?://#', '', $project['url'])) ?></span>
</a>
<?php endif; ?>
<div class="mt-1 sidebar-label">
<span class="badge bg-secondary-subtle text-body-secondary border d-none"
id="diskBadge" data-project-id="<?= $pid ?>"
title="Project size (excl. node_modules, public, .git)">
<i class="bi bi-hdd me-1"></i><span id="diskBadgeValue"></span>
</span>
</div>
</div>
<hr>
<!-- Grouped nav -->
<?php foreach ($tabGroups as $i => $group):
$visible = array_values(array_intersect($group, $tabs));
if (!$visible) continue; ?>
<?php if ($i > 0): ?><hr><?php endif; ?>
<ul class="nav flex-column">
<?php foreach ($visible as $t): ?>
<li class="nav-item">
<a class="nav-link <?= $t === $tab ? 'active' : '' ?>"
href="?tab=<?= urlencode($t) ?>"
title="<?= htmlspecialchars($tabLabels[$t] ?? ucfirst($t)) ?>">
<i class="bi <?= $tabIcons[$t] ?? 'bi-circle' ?>"></i>
<span class="sidebar-label"><?= htmlspecialchars($tabLabels[$t] ?? ucfirst($t)) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endforeach; ?>
</aside>
<section class="project-main flex-grow-1 min-w-0">
<!-- ── POSTS tab (Hexo) ─────────────────────────────────────────────────────── -->
<?php if ($tab === 'posts'): ?>
<div id="postsPanel" class="row g-0 h-split" data-project-id="<?= $pid ?>">
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="d-flex align-items-center gap-1 mb-2 flex-wrap">
<button class="btn btn-sm btn-outline-primary active" id="showPosts">Posts</button>
<button class="btn btn-sm btn-outline-secondary" id="showPages">Pages</button>
<button class="btn btn-sm btn-outline-secondary" id="showDrafts">Drafts</button>
<button class="btn btn-sm btn-primary" id="newItemBtn" title="New draft">
<i class="bi bi-plus-lg"></i> New
</button>
<div class="input-group input-group-sm ms-auto" style="max-width:160px">
<input type="text" id="searchQuery" class="form-control" placeholder="Search…" autocomplete="off">
<button class="btn btn-outline-secondary" id="searchClearBtn" title="Clear"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div id="postsList"></div>
<div id="searchResults" class="d-none"></div>
</div>
<div class="col-md-7 col-xl-8 border-start ps-3 d-flex flex-column">
<div class="editor-tab-bar" id="editorTabBar"></div>
<div class="editor-tab-content" id="editorTabContent">
<div class="editor-placeholder">
<i class="bi bi-cursor-text fs-1 d-block mb-2 opacity-25"></i>
Select a post to edit
</div>
</div>
</div>
</div>
<!-- ── FILES tab ─────────────────────────────────────────────────────────────── -->
<?php elseif ($tab === 'files'): ?>
<div id="fileBrowser" class="row g-0 h-split"
data-project-id="<?= $pid ?>"
data-project-type="<?= htmlspecialchars($project['type']) ?>">
<div class="col-md-5 col-xl-4 split-list pe-2">
<div class="d-flex align-items-center mb-2 gap-2">
<nav aria-label="breadcrumb" id="fileCrumb" class="flex-grow-1">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item"><a href="#" data-path="">Root</a></li>
</ol>
</nav>
<button class="btn btn-xs btn-outline-secondary flex-shrink-0"
data-bs-toggle="modal" data-bs-target="#uploadModal"
title="Upload file">
<i class="bi bi-cloud-upload"></i>
</button>
</div>
<div id="fileList" class="list-group">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
<div class="col-md-7 col-xl-8 border-start ps-3 d-flex flex-column" id="editorPane">
<div class="editor-tab-bar" id="editorTabBar"></div>
<div class="editor-tab-content" id="editorTabContent">
<div class="editor-placeholder">
<i class="bi bi-cursor-text fs-1 d-block mb-2 opacity-25"></i>
Select a file to edit
</div>
</div>
</div>
</div>
<!-- ── RUN tab ───────────────────────────────────────────────────────────────── -->
<?php elseif ($tab === 'run' && $type && in_array('run', $tabs)): ?>
<div id="commandRunner" data-project-id="<?= $pid ?>">
<div class="row g-3">
<div class="col-md-4 col-lg-3">
<div class="card">
<div class="card-header small">Commands</div>
<div class="list-group list-group-flush">
<?php foreach ($type::commands() as $cmd): ?>
<button class="list-group-item list-group-item-action btn-run-cmd"
data-cmd="<?= htmlspecialchars($cmd['id']) ?>">
<span class="d-block"><?= htmlspecialchars($cmd['label']) ?></span>
<code class="small text-muted"><?= htmlspecialchars($cmd['cmd']) ?></code>
</button>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="col-md-8 col-lg-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center small">
Output
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary py-0" id="showHistoryBtn">History</button>
<button class="btn btn-sm btn-outline-secondary py-0" id="clearOutput">Clear</button>
</div>
</div>
<div class="card-body p-0">
<pre id="cmdOutput" class="m-0 p-3 text-success"
style="min-height:300px;max-height:65vh;overflow-y:auto;font-size:.8rem;background:transparent"></pre>
</div>
</div>
</div>
</div>
</div>
<!-- ── MEDIA tab (Storage) ───────────────────────────────────────────────────── -->
<?php elseif ($tab === 'media'): ?>
<div id="mediaPanel" data-project-id="<?= $pid ?>"
data-project-url="<?= htmlspecialchars($project['url'] ?? '') ?>">
<nav aria-label="breadcrumb" id="mediaCrumb" class="mb-3">
<ol class="breadcrumb mb-0 small">
<li class="breadcrumb-item"><a href="#" data-path="">Root</a></li>
</ol>
</nav>
<div id="mediaGrid" class="row g-3">
<div class="col-12 text-muted small">Loading…</div>
</div>
</div>
<?php elseif ($tab === 'git'): ?>
<?php include ROOT . '/views/project/_tab_git.php'; ?>
<?php elseif ($tab === 'config'): ?>
<?php include ROOT . '/views/project/_tab_config.php'; ?>
<?php elseif ($tab === 'dashboard'): ?>
<?php include ROOT . '/views/project/_tab_dashboard.php'; ?>
<?php elseif ($tab === 'analytics'): ?>
<?php include ROOT . '/views/project/_tab_analytics.php'; ?>
<?php elseif ($tab === 'settings'): ?>
<?php include ROOT . '/views/project/_tab_settings.php'; ?>
<?php elseif ($tab === 'notes'): ?>
<?php include ROOT . '/views/project/_tab_notes.php'; ?>
<?php elseif ($tab === 'themes'): ?>
<?php include ROOT . '/views/project/_tab_themes.php'; ?>
<?php elseif ($tab === 'plugins'): ?>
<?php include ROOT . '/views/project/_tab_plugins.php'; ?>
<?php endif; ?>
</section>
</div>
<!-- ═══ MODALS ════════════════════════════════════════════════════════════════ -->
<!-- File editor -->
<div class="modal fade" id="fileEditModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="fileEditName">Edit file</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<input type="hidden" id="fileEditPid" value="<?= $pid ?>">
<input type="hidden" id="fileEditPath">
<textarea id="fileEditContent" style="display:none"></textarea>
<div id="fileEditCm" style="height:70vh"></div>
</div>
<div class="modal-footer py-2">
<span id="fileEditStatus" class="text-muted small me-auto"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary btn-sm" id="fileEditSave">Save</button>
</div>
</div>
</div>
</div>
<!-- Upload -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Upload file</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="uploadPid" value="<?= $pid ?>">
<input type="hidden" id="uploadAccept" value="any">
<div class="mb-3">
<label class="form-label small">Target folder</label>
<input type="text" id="uploadFolder" class="form-control form-control-sm font-monospace"
placeholder="<?= $isHexo ? 'source/images' : '' ?>">
</div>
<div class="mb-3">
<label class="form-label small">File</label>
<input type="file" id="uploadFile" class="form-control form-control-sm">
</div>
<div id="uploadResult" class="d-none">
<div class="alert alert-success py-2 small mb-2" id="uploadResultMsg"></div>
<div class="input-group input-group-sm">
<input type="text" id="uploadResultUrl" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" id="uploadCopy" type="button">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary btn-sm" id="uploadSubmit">Upload</button>
</div>
</div>
</div>
</div>
<!-- Command history -->
<div class="modal fade" id="cmdHistoryModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Command history</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="cmdHistoryList" class="list-group list-group-flush">
<div class="list-group-item text-muted small">Loading…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Delete project -->
<div class="modal fade" id="deleteProjectModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title">Remove project?</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body small text-muted">Files on disk are untouched.</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="confirmDelete"
data-project-id="<?= $pid ?>">Remove</button>
</div>
</div>
</div>
</div>
<!-- Toast container -->
<div id="toastContainer" style="position:fixed;bottom:1rem;right:1rem;z-index:9999;min-width:260px"></div>
<?php
$extra_scripts = '
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/dracula.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script type="module">
// Mirrors Agenda\'s Milkdown loader (lib/layout.php). Front matter is NOT fed
// through Milkdown — it\'s extracted in JS and edited in a separate CodeMirror
// so the YAML round-trips byte-for-byte.
const H = "https://esm.sh/@milkdown/";
const V = "@7.20.0/es2022/";
Promise.all([
import(H + "core" + V + "core.mjs"),
import(H + "preset-commonmark" + V + "preset-commonmark.mjs"),
import(H + "preset-gfm" + V + "preset-gfm.mjs"),
import(H + "plugin-history" + V + "plugin-history.mjs"),
import(H + "utils" + V + "utils.mjs"),
]).then(function ([core, cm, gfmPkg, hist, utils]) {
window.MilkdownKit = {
Editor: core.Editor, rootCtx: core.rootCtx,
defaultValueCtx: core.defaultValueCtx, commandsCtx: core.commandsCtx,
commonmark: cm.commonmark, gfm: gfmPkg.gfm, history: hist.history,
getMarkdown: utils.getMarkdown, replaceAll: utils.replaceAll,
callCommand: utils.callCommand,
commands: {
bold: cm.toggleStrongCommand, italic: cm.toggleEmphasisCommand,
strikethrough: gfmPkg.toggleStrikethroughCommand,
inlineCode: cm.toggleInlineCodeCommand, link: cm.toggleLinkCommand,
bulletList: cm.wrapInBulletListCommand, orderedList: cm.wrapInOrderedListCommand,
blockquote: cm.wrapInBlockquoteCommand, codeBlock: cm.createCodeBlockCommand,
},
};
window.dispatchEvent(new CustomEvent("milkdown-ready"));
}).catch(function (err) { console.error("[Milkdown] core load failed:", err); });
</script>
<script src="/assets/js/milkdown-mount.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/css/css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/xml/xml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/php/php.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/shell/shell.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
';
include ROOT . '/views/_footer.php';
?>

84
views/settings.php Normal file
View file

@ -0,0 +1,84 @@
<?php
$page_title = 'Settings';
$nav_active = 'settings';
include ROOT . '/views/_header.php';
$scan_paths = $db->query('SELECT * FROM scan_paths ORDER BY path')->fetchAll();
?>
<h2 class="h4 mb-4">Settings</h2>
<div class="row g-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">Project discovery</div>
<div class="card-body">
<p class="text-muted small mb-3">
Directories to scan. HackmanCMS detects project types automatically by looking for marker files
(<code>_config.yml</code> Hexo, <code>index.php</code> Website, etc.).
</p>
<ul class="list-group list-group-flush mb-3" id="scanPathsList">
<?php if (empty($scan_paths)): ?>
<li class="list-group-item text-muted px-0 small">No scan paths configured yet.</li>
<?php endif; ?>
<?php foreach ($scan_paths as $sp): ?>
<li class="list-group-item d-flex align-items-center gap-2 px-0">
<code class="flex-grow-1"><?= htmlspecialchars($sp['path']) ?></code>
<span class="badge bg-secondary">depth <?= (int)$sp['depth'] ?></span>
<button class="btn btn-sm btn-outline-primary btn-scan" data-path="<?= htmlspecialchars($sp['path']) ?>"
data-depth="<?= (int)$sp['depth'] ?>">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-sm btn-outline-danger btn-remove-scan" data-id="<?= (int)$sp['id'] ?>">
<i class="bi bi-x-lg"></i>
</button>
</li>
<?php endforeach; ?>
</ul>
<form id="addScanPathForm" class="d-flex gap-2">
<input type="text" name="path" class="form-control form-control-sm" placeholder="/var/www" required>
<input type="number" name="depth" class="form-control form-control-sm" value="2" min="1" max="5"
style="width:72px" title="Scan depth">
<button type="submit" class="btn btn-sm btn-primary">Add</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">Project types</div>
<div class="card-body">
<p class="text-muted small mb-3">
Drop a PHP file extending <code>ProjectTypeBase</code> into
<code>lib/project-types/</code> to register a new type no config needed.
</p>
<ul class="list-group list-group-flush">
<?php foreach (ProjectTypes::all() as $slug => $class): ?>
<li class="list-group-item px-0 d-flex align-items-center gap-2">
<i class="bi <?= htmlspecialchars($class::typeIcon()) ?> text-primary"></i>
<span><?= htmlspecialchars($class::typeName()) ?></span>
<?php if ($class::description()): ?>
<small class="text-muted"><?= htmlspecialchars($class::description()) ?></small>
<?php endif; ?>
<code class="ms-auto text-muted small"><?= htmlspecialchars($slug) ?></code>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
</div>
<div id="scanResults" class="mt-4 d-none">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Scan results</h5>
<button class="btn btn-sm btn-outline-secondary" id="closeScanResults">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="scanResultsList"></div>
</div>
<?php include ROOT . '/views/_footer.php'; ?>