HackmanCMS/views/project/view.php
2026-05-03 20:56:15 +03:00

431 lines
19 KiB
PHP

<?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';
?>