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

4
web/.htaccess Normal file
View file

@ -0,0 +1,4 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

187
web/api/analytics.php Normal file
View file

@ -0,0 +1,187 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$project_id]);
if (!$st->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$days = max(1, min(3650, (int)($_GET['days'] ?? 7)));
$today = date('Y-m-d');
$tomorrow = date('Y-m-d', strtotime($today . ' +1 day'));
$sinceD = date('Y-m-d', strtotime($today . " -{$days} days")); // window start (date)
$prevFrom = date('Y-m-d', strtotime($today . " -" . ($days * 2) . " days"));
$prevTo = $sinceD;
$startTs = $sinceD . ' 00:00:00'; // for raw bound
// ---- helpers --------------------------------------------------------------
// "Today's" raw stats — raw is authoritative for the current day; everything
// else comes from the daily rollup (which is built once per day in import).
function rawTotalsToday(PDO $db, int $pid, string $today): array {
$q = $db->prepare(
"SELECT COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits WHERE project_id = ? AND date(visited_at) = ? AND status < 400");
$q->execute([$pid, $today]);
$r = $q->fetch();
return ['views' => (int)($r['views'] ?? 0), 'uniques' => (int)($r['uniques'] ?? 0)];
}
function dailyTotalsRange(PDO $db, int $pid, string $fromDate, string $toDateExcl): array {
// SUM views + uniques across rollup rows (per-day uniques summed; cross-
// day overlap not deduped — by design, since the daily-rotating salt
// makes cross-day visitors look like new visitors).
$q = $db->prepare(
"SELECT COALESCE(SUM(views),0) AS views, COALESCE(SUM(uniques),0) AS uniques
FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400");
$q->execute([$pid, $fromDate, $toDateExcl]);
$r = $q->fetch();
return ['views' => (int)($r['views'] ?? 0), 'uniques' => (int)($r['uniques'] ?? 0)];
}
// Window totals = today (raw) + (since..today) (daily rollup). When the window
// includes today, raw covers it. When the window is fully past, raw isn't used.
$tot = rawTotalsToday($db, $project_id, $today);
$totDaily = dailyTotalsRange($db, $project_id, $sinceD, $today);
$totals = [
'views' => $tot['views'] + $totDaily['views'],
'uniques' => $tot['uniques'] + $totDaily['uniques'],
];
// Previous-period totals — fully past, daily rollup only.
$prevTotals = dailyTotalsRange($db, $project_id, $prevFrom, $prevTo);
// All-time
$all = $db->prepare(
"SELECT COALESCE(SUM(views),0) AS views, COALESCE(SUM(uniques),0) AS uniques,
MIN(bucket_at) AS first_seen, MAX(bucket_at) AS last_seen
FROM site_visits_daily WHERE project_id = ? AND status < 400");
$all->execute([$project_id]);
$allRow = $all->fetch();
$rawAll = $db->prepare(
"SELECT COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques,
MIN(date(visited_at)) AS first_seen, MAX(date(visited_at)) AS last_seen
FROM site_visits WHERE project_id = ? AND status < 400");
$rawAll->execute([$project_id]);
$rawAllRow = $rawAll->fetch();
$allTime = [
'views' => (int)$allRow['views'] + (int)($rawAllRow['views'] ?? 0),
'uniques' => (int)$allRow['uniques'] + (int)($rawAllRow['uniques'] ?? 0),
'first_seen' => $allRow['first_seen'] ?: $rawAllRow['first_seen'],
'last_seen' => max($allRow['last_seen'] ?? '', $rawAllRow['last_seen'] ?? '') ?: null,
];
// Top pages — UNION raw(today) + daily(since..today), aggregate by path.
$topPages = $db->prepare(
"SELECT path, SUM(views) AS views, SUM(uniques) AS uniques
FROM (
SELECT path, COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY path
UNION ALL
SELECT path, views, uniques
FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
)
GROUP BY path ORDER BY views DESC LIMIT 20");
$topPages->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Top referrers
$topRefs = $db->prepare(
"SELECT referrer, SUM(views) AS views FROM (
SELECT COALESCE(referrer, '') AS referrer, COUNT(*) AS views
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
AND referrer IS NOT NULL AND referrer != ''
GROUP BY referrer
UNION ALL
SELECT referrer, views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ?
AND status < 400 AND referrer != ''
)
GROUP BY referrer ORDER BY views DESC LIMIT 20");
$topRefs->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Daily series (current period) — today from raw, prior days from daily rollup.
$series = $db->prepare(
"SELECT day, SUM(views) AS views, SUM(uniques) AS uniques FROM (
SELECT date(visited_at) AS day, COUNT(*) AS views, COUNT(DISTINCT ip_hash) AS uniques
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY day
UNION ALL
SELECT bucket_at AS day, views, uniques FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
)
GROUP BY day ORDER BY day");
$series->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Previous-period daily series (for chart overlay) — daily rollup only.
$prevSeries = $db->prepare(
"SELECT bucket_at AS day, SUM(views) AS views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
GROUP BY bucket_at ORDER BY bucket_at");
$prevSeries->execute([$project_id, $prevFrom, $prevTo]);
// Hour-of-day — raw (today) + hourly rollup (other days). Days only in daily
// rollup don't contribute to this distribution (we lose sub-day timestamps
// after 365d). For most query windows that's fine.
$hours = $db->prepare(
"SELECT hour, SUM(views) AS views FROM (
SELECT CAST(strftime('%H', visited_at) AS INTEGER) AS hour, COUNT(*) AS views
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status < 400
GROUP BY hour
UNION ALL
SELECT CAST(strftime('%H', bucket_at) AS INTEGER) AS hour, SUM(views) AS views
FROM site_visits_hourly
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status < 400
GROUP BY hour
)
GROUP BY hour ORDER BY hour");
$hours->execute([$project_id, $today, $project_id, $startTs, $tomorrow]);
// Top 404s
$top404s = $db->prepare(
"SELECT path, SUM(hits) AS hits, MAX(last_hit) AS last_hit FROM (
SELECT path, COUNT(*) AS hits, MAX(visited_at) AS last_hit
FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? AND status = 404
GROUP BY path
UNION ALL
SELECT path, views AS hits, bucket_at AS last_hit FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? AND status = 404
)
GROUP BY path ORDER BY hits DESC LIMIT 20");
$top404s->execute([$project_id, $today, $project_id, $sinceD, $today]);
// Status mix
$statusMix = $db->prepare(
"SELECT status, SUM(views) AS views FROM (
SELECT status, COUNT(*) AS views FROM site_visits
WHERE project_id = ? AND date(visited_at) = ? GROUP BY status
UNION ALL
SELECT status, SUM(views) AS views FROM site_visits_daily
WHERE project_id = ? AND bucket_at >= ? AND bucket_at < ? GROUP BY status
)
GROUP BY status ORDER BY views DESC");
$statusMix->execute([$project_id, $today, $project_id, $sinceD, $today]);
echo json_encode([
'days' => $days,
'window' => $totals,
'previous' => $prevTotals,
'all_time' => $allTime,
'top_pages' => $topPages->fetchAll(),
'top_refs' => $topRefs->fetchAll(),
'series' => $series->fetchAll(),
'prev_series' => $prevSeries->fetchAll(),
'hours' => $hours->fetchAll(),
'top_404s' => $top404s->fetchAll(),
'status_mix' => $statusMix->fetchAll(),
'notes' => [
'unique_semantic' => 'sum of per-day unique visitors (daily-rotating salt) — same person across days counts once per day',
],
]);

View file

@ -0,0 +1,39 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$project_id]);
if (!$st->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'POST only']); exit; }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'run';
if ($action === 'reset') {
$db->prepare('DELETE FROM project_settings WHERE project_id = ?
AND key IN ("analytics_last_size", "analytics_last_inode")')
->execute([$project_id]);
Audit::log($db, 'analytics_reset', $project_id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'wipe') {
// Drop all collected visits for this project and clear cursors.
$db->prepare('DELETE FROM site_visits WHERE project_id = ?')->execute([$project_id]);
$db->prepare('DELETE FROM project_settings WHERE project_id = ?
AND key IN ("analytics_last_size", "analytics_last_inode",
"analytics_imported_at", "analytics_imported_count")')
->execute([$project_id]);
Audit::log($db, 'analytics_wipe', $project_id);
echo json_encode(['ok' => true]);
exit;
}
@set_time_limit(300);
require_once ROOT . '/bin/import-site-logs.php';
$res = importOne($db, $project_id);
echo json_encode($res + ['ok' => $res['error'] === null]);

34
web/api/audit.php Normal file
View file

@ -0,0 +1,34 @@
<?php
header('Content-Type: application/json');
$limit = min(200, (int)($_GET['limit'] ?? 50));
$offset = max(0, (int)($_GET['offset'] ?? 0));
$pid = isset($_GET['project_id']) && $_GET['project_id'] !== '' ? (int)$_GET['project_id'] : null;
if ($pid) {
$stmt = $db->prepare(
'SELECT a.*, u.username FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.project_id = ?
ORDER BY a.created_at DESC LIMIT ? OFFSET ?'
);
$stmt->execute([$pid, $limit, $offset]);
$cnt = $db->prepare('SELECT COUNT(*) FROM audit_log WHERE project_id = ?');
$cnt->execute([$pid]);
} else {
$stmt = $db->prepare(
'SELECT a.*, u.username, p.name AS project_name FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
LEFT JOIN projects p ON p.id = a.project_id
ORDER BY a.created_at DESC LIMIT ? OFFSET ?'
);
$stmt->execute([$limit, $offset]);
$cnt = $db->query('SELECT COUNT(*) FROM audit_log');
}
echo json_encode([
'entries' => $stmt->fetchAll(),
'total' => (int)$cnt->fetchColumn(),
'limit' => $limit,
'offset' => $offset,
]);

48
web/api/auth.php Normal file
View file

@ -0,0 +1,48 @@
<?php
$action = $_POST['action'] ?? $_GET['action'] ?? '';
if ($action === 'logout') {
Auth::logout();
header('Location: /login');
exit;
}
if ($action === 'login') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (Auth::login($db, $username, $password)) {
header('Location: /');
exit;
}
$error = 'Invalid username or password.';
include ROOT . '/views/login.php';
exit;
}
if ($action === 'setup') {
if (Auth::hasUsers($db)) {
header('Location: /login');
exit;
}
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['password_confirm'] ?? '';
if (strlen($username) < 2) {
$error = 'Username must be at least 2 characters.';
} elseif (strlen($password) < 8) {
$error = 'Password must be at least 8 characters.';
} elseif ($password !== $confirm) {
$error = 'Passwords do not match.';
} else {
Auth::createUser($db, $username, $password);
Auth::login($db, $username, $password);
header('Location: /');
exit;
}
include ROOT . '/views/login.php';
exit;
}
http_response_code(400);
echo 'Bad request';

68
web/api/backup.php Normal file
View file

@ -0,0 +1,68 @@
<?php
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) {
header('Content-Type: application/json');
http_response_code(404);
echo json_encode(['error' => 'Project not found']);
exit;
}
$base = realpath($project['path']);
if (!$base || !is_dir($base)) {
header('Content-Type: application/json');
echo json_encode(['error' => 'Project path not accessible']);
exit;
}
$excludes = ['node_modules', 'public', '.git'];
// Build ZIP into a temp file then stream
$tmp = tempnam(sys_get_temp_dir(), 'hexbk');
$zip = new ZipArchive();
if ($zip->open($tmp, ZipArchive::OVERWRITE) !== true) {
@unlink($tmp);
header('Content-Type: application/json');
echo json_encode(['error' => 'Cannot open zip for writing']);
exit;
}
$baseLen = strlen($base) + 1;
$it = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($base,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS),
function ($current) use ($excludes, $base) {
$name = $current->getFilename();
if ($current->isDir() && in_array($name, $excludes, true)) return false;
return true;
}
),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $file) {
$abs = $file->getPathname();
$local = substr($abs, $baseLen);
if ($local === '') continue;
if ($file->isDir()) {
$zip->addEmptyDir($local);
} else {
$zip->addFile($abs, $local);
}
}
$zip->close();
Audit::log($db, 'backup_download', $project_id);
$slug = preg_replace('/[^A-Za-z0-9._-]+/', '-', $project['name']) ?: 'project';
$filename = $slug . '_' . date('Y-m-d_His') . '.zip';
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($tmp));
header('Cache-Control: no-store');
readfile($tmp);
@unlink($tmp);

46
web/api/disk.php Normal file
View file

@ -0,0 +1,46 @@
<?php
header('Content-Type: application/json');
$ids = $_GET['ids'] ?? '';
if ($ids !== '') {
// Bulk: ?ids=1,2,3
$idList = array_filter(array_map('intval', explode(',', $ids)));
if (!$idList) { echo json_encode(['items' => []]); exit; }
$place = implode(',', array_fill(0, count($idList), '?'));
$stmt = $db->prepare("SELECT id, path FROM projects WHERE is_active = 1 AND id IN ($place)");
$stmt->execute($idList);
$out = [];
foreach ($stmt->fetchAll() as $p) {
$out[(int)$p['id']] = diskUsage($p['path']);
}
echo json_encode(['items' => $out]);
exit;
}
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, path FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
echo json_encode(['size' => diskUsage($project['path'])]);
function diskUsage(?string $path): ?array {
if (!$path) return null;
$real = realpath($path);
if (!$real || !is_dir($real)) return null;
// GNU du -sb with excludes; bytes for accuracy
$cmd = 'du -sb --exclude=node_modules --exclude=public --exclude=.git '
. escapeshellarg($real) . ' 2>/dev/null';
$out = shell_exec($cmd);
if ($out === null) return null;
$bytes = (int)strtok(trim($out), "\t");
return ['bytes' => $bytes, 'human' => humanBytes($bytes)];
}
function humanBytes(int $b): string {
if ($b < 1024) return $b . ' B';
if ($b < 1024 * 1024) return number_format($b / 1024, 1) . ' KB';
if ($b < 1024 * 1024 * 1024) return number_format($b / 1024 / 1024, 1) . ' MB';
return number_format($b / 1024 / 1024 / 1024, 2) . ' GB';
}

111
web/api/drafts.php Normal file
View file

@ -0,0 +1,111 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$pid = (int)($_GET['project_id'] ?? $input['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$pid]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
// ── LIST ──────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$rows = $db->prepare('SELECT * FROM drafts WHERE project_id = ? ORDER BY updated_at DESC');
$rows->execute([$pid]);
echo json_encode(['drafts' => $rows->fetchAll()]);
exit;
}
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
$action = $input['action'] ?? 'create';
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($action === 'create') {
$title = trim($input['title'] ?? '');
$slug = trim($input['slug'] ?? '') ?: slugify($title);
$folder = trim($input['folder'] ?? '');
$fm = $input['frontmatter'] ?? '';
$body = $input['body'] ?? '';
if (!$title) { echo json_encode(['error' => 'Title required']); exit; }
$stmt = $db->prepare(
'INSERT INTO drafts (project_id, title, slug, folder, frontmatter, body) VALUES (?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$pid, $title, $slug, $folder, $fm, $body]);
$newId = (int)$db->lastInsertId();
Audit::log($db, 'draft_create', $pid, $title);
echo json_encode(['ok' => true, 'id' => $newId]);
exit;
}
// ── UPDATE ────────────────────────────────────────────────────────────────────
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$title = trim($input['title'] ?? '');
$slug = trim($input['slug'] ?? '');
$folder = trim($input['folder'] ?? '');
$fm = $input['frontmatter'] ?? '';
$body = $input['body'] ?? '';
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare(
'UPDATE drafts SET title=?, slug=?, folder=?, frontmatter=?, body=?,
updated_at=CURRENT_TIMESTAMP WHERE id=? AND project_id=?'
)->execute([$title, $slug, $folder, $fm, $body, $id, $pid]);
Audit::log($db, 'draft_update', $pid, $title);
echo json_encode(['ok' => true]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$row = $db->prepare('SELECT title FROM drafts WHERE id = ? AND project_id = ?');
$row->execute([$id, $pid]);
$title = (string)$row->fetchColumn();
$db->prepare('DELETE FROM drafts WHERE id = ? AND project_id = ?')->execute([$id, $pid]);
Audit::log($db, 'draft_delete', $pid, $title);
echo json_encode(['ok' => true]);
exit;
}
// ── PUBLISH (write to _posts, delete from DB) ─────────────────────────────────
if ($action === 'publish') {
$id = (int)($input['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM drafts WHERE id = ? AND project_id = ?');
$stmt->execute([$id, $pid]);
$draft = $stmt->fetch();
if (!$draft) { echo json_encode(['error' => 'Draft not found']); exit; }
$base = realpath($project['path']);
$posts_dir = $base . '/source/_posts';
$target_dir = $draft['folder']
? $posts_dir . '/' . ltrim($draft['folder'], '/')
: $posts_dir;
$real_t = realpath($target_dir) ?: $target_dir;
if (realpath($posts_dir) && !str_starts_with($real_t . '/', realpath($posts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
if (!is_dir($target_dir)) mkdir($target_dir, 0755, true);
$filename = ($draft['slug'] ?: slugify($draft['title'])) . '.md';
$filepath = $target_dir . '/' . $filename;
$fm = "---\ntitle: \"" . addslashes($draft['title']) . "\"\ndate: " . date('Y-m-d H:i:s') . "\n---";
file_put_contents($filepath, $fm . "\n\n" . $draft['body']);
$db->prepare('DELETE FROM drafts WHERE id = ?')->execute([$id]);
$rel = 'source/_posts/' . ($draft['folder'] ? ltrim($draft['folder'], '/') . '/' : '') . $filename;
Audit::log($db, 'draft_publish', $pid, $rel);
echo json_encode(['ok' => true, 'path' => $rel, 'filename' => $filename]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
function slugify(string $s): string {
$s = mb_strtolower($s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-') ?: 'draft-' . time();
}

122
web/api/files.php Normal file
View file

@ -0,0 +1,122 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
// POST actions read params from JSON body
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'write';
$rel_path = $input['path'] ?? '';
} else {
$input = [];
$rel_path = $_GET['path'] ?? '';
$action = $_GET['action'] ?? 'list';
}
function safeTarget(string $base, string $rel): string|false {
if ($rel === '' || $rel === '.') return $base;
$candidate = $base . '/' . ltrim($rel, '/');
if (file_exists($candidate)) {
$real = realpath($candidate);
return ($real && str_starts_with($real . '/', $base . '/')) ? $real : false;
}
// File doesn't exist yet (write) — validate parent
$parentReal = realpath(dirname($candidate));
return ($parentReal && str_starts_with($parentReal . '/', $base . '/')) ? $candidate : false;
}
$target = safeTarget($base, $rel_path);
function touchRecent(PDO $db, int $pid, string $rel): void {
if ($rel === '' || $rel === '.') return;
$db->prepare('INSERT INTO recent_files (project_id, path, opened_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(project_id, path) DO UPDATE SET opened_at = CURRENT_TIMESTAMP')
->execute([$pid, $rel]);
}
// ── WRITE ─────────────────────────────────────────────────────────────────────
if ($action === 'write' && $method === 'POST') {
if ($target === false) { http_response_code(403); echo json_encode(['error' => 'Access denied']); exit; }
$dir = dirname($target);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($target, $input['content'] ?? '');
touchRecent($db, $project_id, $rel_path);
Audit::log($db, 'file_write', $project_id, $rel_path);
echo json_encode(['ok' => true]);
exit;
}
// ── DELETE file ───────────────────────────────────────────────────────────────
if ($action === 'delete' && $method === 'POST') {
if ($target === false || !is_file($target)) { echo json_encode(['error' => 'File not found']); exit; }
unlink($target);
Audit::log($db, 'file_delete', $project_id, $rel_path);
echo json_encode(['ok' => true]);
exit;
}
// ── SERVE (proxy file with correct content-type) ──────────────────────────────
if ($action === 'serve') {
if ($target === false || !is_file($target)) { http_response_code(404); exit; }
$ext = strtolower(pathinfo($target, PATHINFO_EXTENSION));
$mimes = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
'mp4' => 'video/mp4', 'webm' => 'video/webm',
'mp3' => 'audio/mpeg', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg',
'pdf' => 'application/pdf',
];
header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream'));
header('Content-Length: ' . filesize($target));
header('Cache-Control: max-age=3600');
readfile($target);
exit;
}
// ── READ ──────────────────────────────────────────────────────────────────────
if ($action === 'read') {
if ($target === false || !is_file($target)) { echo json_encode(['error' => 'Not a file']); exit; }
if (filesize($target) > 512 * 1024) { echo json_encode(['error' => 'File too large (>512 KB)']); exit; }
$content = file_get_contents($target);
if ($content === false) { echo json_encode(['error' => 'Cannot read file — check permissions']); exit; }
touchRecent($db, $project_id, $rel_path);
$json = json_encode(['content' => $content]);
if ($json === false) {
// Non-UTF-8 bytes — substitute replacement characters
$json = json_encode(['content' => $content], JSON_INVALID_UTF8_SUBSTITUTE);
}
echo $json ?? json_encode(['error' => 'Cannot encode file content']);
exit;
}
// ── LIST (default) ────────────────────────────────────────────────────────────
if ($target === false || !is_dir($target)) { echo json_encode(['error' => 'Not a directory']); exit; }
$entries = [];
foreach (scandir($target) as $item) {
if ($item === '.' || $item === '..') continue;
$full = $target . '/' . $item;
$rel = $rel_path ? rtrim($rel_path, '/') . '/' . $item : $item;
$entries[] = [
'name' => $item,
'type' => is_dir($full) ? 'dir' : 'file',
'size' => is_file($full) ? filesize($full) : null,
'modified' => filemtime($full),
'path' => $rel,
];
}
usort($entries, fn($a, $b) =>
$a['type'] !== $b['type'] ? ($a['type'] === 'dir' ? -1 : 1) : strcmp($a['name'], $b['name'])
);
echo json_encode(['entries' => $entries]);

221
web/api/git.php Normal file
View file

@ -0,0 +1,221 @@
<?php
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { jsonErr(404, 'Project not found'); }
$path = realpath($project['path']);
if (!$path) { jsonErr(400, 'Path not accessible'); }
// Parse POST body early so subdir can be sent in either GET param or POST body
$input = [];
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
}
// Allow using a git repo in a subdirectory
$subdir = trim($_GET['subdir'] ?? ($input['subdir'] ?? ''));
if ($subdir) {
$sub = realpath($path . '/' . $subdir);
if ($sub && str_starts_with($sub . '/', $path . '/') && is_dir($sub . '/.git')) {
$path = $sub;
}
}
if (!is_dir($path . '/.git')) {
// Scan one level deep for git sub-repos
$subdirs = [];
foreach (@scandir($path) ?: [] as $item) {
if ($item[0] === '.') continue;
$sub = $path . '/' . $item;
if (is_dir($sub) && is_dir($sub . '/.git')) $subdirs[] = $item;
}
header('Content-Type: application/json');
echo json_encode(['no_git' => true, 'subdirs' => $subdirs]);
exit;
}
function jsonErr(int $code, string $msg): never {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode(['error' => $msg]);
exit;
}
function git(string $path, string $args, array &$out = [], int &$exit = 0): string {
exec('git -C ' . escapeshellarg($path) . ' ' . $args . ' 2>&1', $out, $exit);
return implode("\n", $out);
}
// ── READ actions (GET) ────────────────────────────────────────────────────────
header('Content-Type: application/json');
$action = $_GET['action'] ?? '';
if ($action === 'status') {
$lines = [];
$branch = trim(git($path, 'rev-parse --abbrev-ref HEAD'));
git($path, 'status --porcelain', $lines);
$files = [];
foreach ($lines as $l) {
if (strlen($l) < 3) continue;
$files[] = ['xy' => substr($l, 0, 2), 'file' => trim(substr($l, 3))];
}
$stashes = [];
git($path, 'stash list', $stashes);
echo json_encode(['branch' => $branch, 'files' => $files, 'stash_count' => count($stashes)]);
exit;
}
if ($action === 'branches') {
$lines = [];
git($path, 'branch -a', $lines);
$branches = [];
foreach ($lines as $l) {
$cur = str_starts_with($l, '* ');
$branches[] = ['name' => trim(ltrim($l, '* ')), 'current' => $cur];
}
echo json_encode(['branches' => $branches]);
exit;
}
if ($action === 'log') {
$limit = min(50, (int)($_GET['limit'] ?? 25));
$lines = [];
git($path, 'log --pretty=format:"%H|%h|%s|%an|%ar|%ad" --date=short -' . $limit, $lines);
$commits = array_map(fn($l) => array_combine(
['hash','short','subject','author','rel','date'],
array_pad(explode('|', $l, 6), 6, '')
), array_filter($lines));
echo json_encode(['commits' => array_values($commits)]);
exit;
}
if ($action === 'diff') {
$file = $_GET['file'] ?? '';
$hash = $_GET['hash'] ?? '';
if ($hash) {
$diff = git($path, 'show ' . escapeshellarg($hash));
} elseif ($file) {
$diff = git($path, 'diff -- ' . escapeshellarg($file));
if (!trim($diff)) $diff = git($path, 'diff --cached -- ' . escapeshellarg($file));
} else {
$diff = git($path, 'diff');
}
echo json_encode(['diff' => $diff]);
exit;
}
// ── WRITE actions (POST) ──────────────────────────────────────────────────────
if ($method !== 'POST') { jsonErr(405, 'Method not allowed'); }
$action = $input['action'] ?? $action;
// Streaming ops (pull, push) return SSE
if (in_array($action, ['pull', 'push'])) {
Audit::log($db, 'git_' . $action, $project_id);
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
while (ob_get_level()) ob_end_flush();
$cmd = 'git -C ' . escapeshellarg($path) . ' ' . $action . ' 2>&1';
$proc = popen($cmd, 'r');
if (!$proc) {
echo "data: " . json_encode(['error' => 'Failed to start']) . "\n\n";
flush();
exit;
}
while (!feof($proc)) {
$line = fgets($proc, 4096);
if ($line !== false && $line !== '') {
echo 'data: ' . json_encode(['line' => $line]) . "\n\n";
flush();
}
}
$exit = pclose($proc);
echo 'data: ' . json_encode(['done' => true, 'exit_code' => $exit]) . "\n\n";
flush();
exit;
}
// JSON ops
header('Content-Type: application/json');
if ($action === 'commit') {
$msg = trim($input['message'] ?? '');
if (!$msg) { echo json_encode(['error' => 'Commit message required']); exit; }
$out = []; $exit = 0;
git($path, 'add -A');
$result = git($path, 'commit -m ' . escapeshellarg($msg), $out, $exit);
Audit::log($db, 'git_commit', $project_id, $msg);
echo json_encode(['ok' => $exit === 0, 'output' => $result, 'exit_code' => $exit]);
exit;
}
if ($action === 'checkout') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'checkout ' . escapeshellarg($branch), $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'create_branch') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch name required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'checkout -b ' . escapeshellarg($branch), $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'merge') {
$branch = $input['branch'] ?? '';
if (!$branch) { echo json_encode(['error' => 'Branch required']); exit; }
$out = []; $exit = 0;
$result = git($path, 'merge ' . escapeshellarg($branch), $out, $exit);
Audit::log($db, 'git_merge', $project_id, $branch);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stash') {
$out = []; $exit = 0;
$result = git($path, 'stash', $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stash_pop') {
$out = []; $exit = 0;
$result = git($path, 'stash pop', $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'reset') {
$out = []; $exit = 0;
$result = git($path, 'reset --hard HEAD', $out, $exit);
Audit::log($db, 'git_reset', $project_id);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
if ($action === 'stage') {
$files = $input['files'] ?? [];
if (!is_array($files) || empty($files)) {
echo json_encode(['error' => 'No files specified']); exit;
}
$args = 'add --';
foreach ($files as $f) { $args .= ' ' . escapeshellarg((string)$f); }
$out = []; $exit = 0;
$result = git($path, $args, $out, $exit);
echo json_encode(['ok' => $exit === 0, 'output' => $result]);
exit;
}
jsonErr(400, 'Unknown action');

204
web/api/links.php Normal file
View file

@ -0,0 +1,204 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'scan';
if ($action === 'scan') {
@set_time_limit(300);
$result = scanProjectLinks($db, $project);
Audit::log($db, 'link_scan', $project_id,
"broken={$result['broken']} of {$result['total']}");
echo json_encode(['ok' => true, 'run_id' => $result['run_id']] + $result);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — latest run + its results
$run = $db->prepare('SELECT * FROM link_check_runs WHERE project_id = ?
ORDER BY id DESC LIMIT 1');
$run->execute([$project_id]);
$lastRun = $run->fetch();
if (!$lastRun) { echo json_encode(['run' => null, 'results' => []]); exit; }
$onlyBroken = !empty($_GET['broken_only']);
$where = 'run_id = ?';
$args = [$lastRun['id']];
if ($onlyBroken) {
$where .= ' AND (status_code IS NULL OR status_code >= 400)';
}
$res = $db->prepare("SELECT * FROM link_check_results WHERE $where ORDER BY status_code DESC, source");
$res->execute($args);
echo json_encode(['run' => $lastRun, 'results' => $res->fetchAll()]);
function scanProjectLinks(PDO $db, array $project): array {
$base = realpath($project['path']);
$siteUrl = rtrim((string)($project['url'] ?? ''), '/');
$pid = (int)$project['id'];
$db->prepare('INSERT INTO link_check_runs (project_id) VALUES (?)')->execute([$pid]);
$runId = (int)$db->lastInsertId();
// Collect markdown files
$files = [];
foreach (['source/_posts', 'source/_drafts', 'source'] as $sub) {
$dir = $base . '/' . $sub;
if (!is_dir($dir)) continue;
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
$dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS));
foreach ($it as $f) {
if ($f->getExtension() === 'md') {
$files[] = ['abs' => $f->getPathname(),
'rel' => ltrim(str_replace($base, '', $f->getPathname()), '/')];
}
}
}
// Extract links per file
$byUrl = []; // url => [ [source, ...] ]
foreach ($files as $f) {
$content = file_get_contents($f['abs']);
if ($content === false) continue;
$urls = extractLinks($content);
foreach ($urls as $u) {
$byUrl[$u][] = $f['rel'];
}
}
// Resolve relative/internal URLs against project URL
$jobs = []; // url => fetchUrl
foreach (array_keys($byUrl) as $u) {
$fetch = resolveLink($u, $siteUrl);
if ($fetch !== null) $jobs[$u] = $fetch;
}
$statuses = parallelCheck($jobs);
$insRes = $db->prepare(
'INSERT INTO link_check_results (run_id, project_id, url, source, status_code, error)
VALUES (?, ?, ?, ?, ?, ?)');
$total = 0; $broken = 0;
foreach ($byUrl as $url => $sources) {
$st = $statuses[$url] ?? null;
$status = $st['code'] ?? null;
$error = $st['error'] ?? null;
$isBroken = $status === null || $status >= 400;
foreach ($sources as $src) {
$insRes->execute([$runId, $pid, $url, $src, $status, $error]);
$total++;
if ($isBroken) $broken++;
}
}
$db->prepare('UPDATE link_check_runs SET finished_at = CURRENT_TIMESTAMP,
total_links = ?, broken = ? WHERE id = ?')
->execute([$total, $broken, $runId]);
return ['run_id' => $runId, 'total' => $total, 'broken' => $broken];
}
function extractLinks(string $content): array {
$urls = [];
// Markdown links [text](url)
if (preg_match_all('/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
// HTML href="..."
if (preg_match_all('/href=["\']([^"\']+)["\']/i', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
// Bare URLs (markdown auto-link)
if (preg_match_all('/<(https?:\/\/[^>]+)>/', $content, $m)) {
foreach ($m[1] as $u) $urls[] = $u;
}
return array_values(array_unique(array_filter(array_map('trim', $urls), function ($u) {
if ($u === '' || $u[0] === '#') return false;
if (str_starts_with($u, 'mailto:')) return false;
if (str_starts_with($u, 'tel:')) return false;
if (str_starts_with($u, 'javascript:')) return false;
if (str_starts_with($u, 'data:')) return false;
return true;
})));
}
function resolveLink(string $url, string $siteUrl): ?string {
if (preg_match('#^https?://#i', $url)) return $url;
if ($url[0] === '/' && $siteUrl !== '') return $siteUrl . $url;
// Pure relative refs (./foo, ../foo, foo) — can't resolve without post URL context
return null;
}
function parallelCheck(array $jobs): array {
if (!$jobs) return [];
if (!function_exists('curl_multi_init')) {
$out = [];
foreach ($jobs as $key => $url) $out[$key] = singleCheck($url);
return $out;
}
$mh = curl_multi_init();
$handles = [];
foreach ($jobs as $key => $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_NOBODY => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_USERAGENT => 'HackmanCMS-LinkChecker/1.0',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,
]);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
}
$running = null;
do { curl_multi_exec($mh, $running); curl_multi_select($mh, 0.5); } while ($running > 0);
$out = [];
foreach ($handles as $key => $ch) {
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: null;
$err = curl_error($ch) ?: null;
// Some servers reject HEAD; retry with GET for non-2xx HEAD failures
if (($code === null || $code === 0 || $code === 405 || $code === 403) && $err === '') {
$code = singleCheck($jobs[$key])['code'] ?? $code;
}
$out[$key] = ['code' => $code ?: null, 'error' => $err ?: null];
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $out;
}
function singleCheck(string $url): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 6,
CURLOPT_USERAGENT => 'HackmanCMS-LinkChecker/1.0',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_RANGE => '0-1024',
]);
curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE) ?: null;
$err = curl_error($ch) ?: null;
curl_close($ch);
return ['code' => $code ?: null, 'error' => $err ?: null];
}

87
web/api/plugins.php Normal file
View file

@ -0,0 +1,87 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$pkgFile = $base ? $base . '/package.json' : null;
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
if ($method === 'POST') {
@set_time_limit(180);
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
$name = trim($input['name'] ?? '');
if (!preg_match('/^(@[a-z0-9._~-]+\/)?[a-z0-9._~-]+$/i', $name)) {
echo json_encode(['error' => 'Invalid package name']); exit;
}
if ($action === 'install') {
$cmd = 'cd ' . escapeshellarg($base) . ' && npm install --save ' . escapeshellarg($name) . ' 2>&1';
exec($cmd, $out, $rc);
Audit::log($db, 'plugin_install', $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
if ($action === 'uninstall') {
$cmd = 'cd ' . escapeshellarg($base) . ' && npm uninstall --save ' . escapeshellarg($name) . ' 2>&1';
exec($cmd, $out, $rc);
Audit::log($db, 'plugin_uninstall', $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — list installed plugins
if (!$pkgFile || !is_file($pkgFile)) {
echo json_encode(['plugins' => [], 'no_package_json' => true]);
exit;
}
$pkg = json_decode(file_get_contents($pkgFile), true) ?: [];
$deps = array_merge($pkg['dependencies'] ?? [], $pkg['devDependencies'] ?? []);
$plugins = [];
foreach ($deps as $name => $version) {
if (!str_starts_with($name, 'hexo-')) continue;
$info = readPackageInfo($base . '/node_modules/' . $name);
$plugins[] = [
'name' => $name,
'version' => $version,
'installed' => $info['version'] ?? null,
'description' => $info['description'] ?? null,
'homepage' => $info['homepage'] ?? null,
'repository' => $info['repo'] ?? null,
'npm' => 'https://www.npmjs.com/package/' . $name,
];
}
usort($plugins, fn($a, $b) => strcmp($a['name'], $b['name']));
echo json_encode(['plugins' => $plugins]);
function readPackageInfo(string $modDir): array {
$f = $modDir . '/package.json';
if (!is_file($f)) return [];
$j = json_decode(file_get_contents($f), true) ?: [];
$repo = $j['repository']['url'] ?? ($j['repository'] ?? null);
if (is_string($repo)) {
$repo = preg_replace('#^git\+#', '', $repo);
$repo = preg_replace('#\.git$#', '', $repo);
}
return [
'version' => $j['version'] ?? null,
'description' => $j['description'] ?? null,
'homepage' => $j['homepage'] ?? null,
'repo' => is_string($repo) ? $repo : null,
];
}

306
web/api/posts.php Normal file
View file

@ -0,0 +1,306 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$method = $_SERVER['REQUEST_METHOD'];
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Project path not accessible']); exit; }
$posts_dir = $base . '/source/_posts';
$drafts_dir = $base . '/source/_drafts';
$pages_dir = $base . '/source';
// ── LIST ──────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
$type = $_GET['type'] ?? 'post';
if ($type === 'draft') {
$dir = $drafts_dir;
} elseif ($type === 'page') {
$dir = $pages_dir;
} else {
$dir = $posts_dir;
}
if (!is_dir($dir)) {
echo json_encode(['items' => [], 'missing_dir' => true]);
exit;
}
$items = [];
if ($type === 'post' || $type === 'draft') {
$srcDir = ($type === 'draft') ? $drafts_dir : $posts_dir;
$srcPfx = ($type === 'draft') ? 'source/_drafts/' : 'source/_posts/';
// Recursive scan — posts/drafts may live in subdirs
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS));
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$abs = $file->getPathname();
$relp = ltrim(str_replace($srcDir, '', $abs), '/');
$fm = parseFrontMatter(file_get_contents($abs));
$items[] = [
'filename' => $file->getFilename(),
'relpath' => $relp,
'path' => $srcPfx . $relp,
'folder' => ltrim(dirname($relp), '.'),
'title' => $fm['title'] ?? basename($abs, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($abs),
'tags' => $fm['tags'] ?? [],
'categories' => $fm['categories'] ?? [],
];
}
} else {
// Pages: source/*.md + configured subdirectories (default: p, pages)
$pdStmt = $db->prepare('SELECT value FROM project_settings WHERE project_id = ? AND key = ?');
$pdStmt->execute([$project_id, 'page_dirs']);
$pdVal = $pdStmt->fetchColumn();
$extraDirs = $pdVal !== false
? array_filter(array_map('trim', explode("\n", $pdVal)))
: ['p', 'pages'];
// Top-level pages
foreach (glob($pages_dir . '/*.md') as $file) {
$fm = parseFrontMatter(file_get_contents($file));
$items[] = [
'filename' => basename($file),
'relpath' => basename($file),
'path' => 'source/' . basename($file),
'folder' => '',
'title' => $fm['title'] ?? basename($file, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($file),
'tags' => $fm['tags'] ?? [],
'categories' => $fm['categories'] ?? [],
];
}
// Subdirectory pages
foreach ($extraDirs as $pd) {
$subdir = $pages_dir . '/' . $pd;
if (!is_dir($subdir)) continue;
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($subdir,
FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS)
);
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$abs = $file->getPathname();
$relp = ltrim(str_replace($pages_dir, '', $abs), '/');
$fm = parseFrontMatter(file_get_contents($abs));
$folder = ltrim(dirname($relp), '.');
$items[] = [
'filename' => $file->getFilename(),
'relpath' => $relp,
'path' => 'source/' . $relp,
'folder' => $folder,
'title' => $fm['title'] ?? basename($abs, '.md'),
'date' => $fm['date'] ?? null,
'modified' => filemtime($abs),
];
}
}
}
usort($items, fn($a, $b) => strcmp($b['date'] ?? '0', $a['date'] ?? '0'));
echo json_encode(['items' => $items]);
exit;
}
// ── CREATE ────────────────────────────────────────────────────────────────────
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$type = $input['type'] ?? 'post';
$title = trim($input['title'] ?? 'Untitled');
$slug = trim($input['slug'] ?? '') ?: slugify($title);
$folder = trim($input['folder'] ?? ''); // e.g. "2026" or "2026/travel"
$body = $input['body'] ?? '';
$fm = trim($input['frontmatter'] ?? '');
$date = date('Y-m-d H:i:s');
if (!$fm) {
$fm = "---\ntitle: \"" . addslashes($title) . "\"\ndate: $date\ntags: []\n---";
}
// Handle draft→post publish action
if (($input['action'] ?? '') === 'publish') {
$relpath = trim($input['relpath'] ?? '');
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid relpath']); exit;
}
$src = realpath($drafts_dir . '/' . $relpath);
if (!$src || !str_starts_with($src . '/', $drafts_dir . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
$folder = ltrim(dirname($relpath), '.');
$dest = $folder ? $posts_dir . '/' . $folder : $posts_dir;
if (!is_dir($dest)) mkdir($dest, 0755, true);
rename($src, $dest . '/' . basename($relpath));
Audit::log($db, 'post_publish', $project_id, 'source/_posts/' . $relpath);
echo json_encode(['ok' => true, 'path' => 'source/_posts/' . $relpath]);
exit;
}
// Handle duplicate action
if (($input['action'] ?? '') === 'duplicate') {
$relpath = trim($input['relpath'] ?? '');
$srcType = $input['type'] ?? 'post';
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid relpath']); exit;
}
$srcBase = ($srcType === 'draft') ? $drafts_dir : $posts_dir;
$src = realpath($srcBase . '/' . $relpath);
if (!$src || !str_starts_with($src . '/', $srcBase . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
$info = pathinfo($src);
$newName = $info['filename'] . '-copy.' . $info['extension'];
$dest = $info['dirname'] . '/' . $newName;
// Avoid collision
$i = 2;
while (file_exists($dest)) {
$dest = $info['dirname'] . '/' . $info['filename'] . '-copy' . $i . '.' . $info['extension'];
$i++;
}
copy($src, $dest);
$newRelpath = ltrim(str_replace($srcBase, '', $dest), '/');
$srcPfx = ($srcType === 'draft') ? 'source/_drafts/' : 'source/_posts/';
Audit::log($db, 'post_duplicate', $project_id, $srcPfx . $newRelpath);
echo json_encode(['ok' => true, 'relpath' => $newRelpath, 'path' => $srcPfx . $newRelpath]);
exit;
}
if ($type === 'page') {
$dir = $pages_dir;
} elseif ($type === 'draft') {
$dir = $folder ? $drafts_dir . '/' . ltrim($folder, '/') : $drafts_dir;
$real = realpath($dir) ?: $dir;
if (realpath($drafts_dir) && !str_starts_with($real . '/', realpath($drafts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
} else {
$dir = $folder ? $posts_dir . '/' . ltrim($folder, '/') : $posts_dir;
// Security: ensure target stays inside posts_dir
$real = realpath($dir) ?: $dir;
if (realpath($posts_dir) && !str_starts_with($real . '/', realpath($posts_dir) . '/')) {
http_response_code(403); echo json_encode(['error' => 'Invalid folder']); exit;
}
}
if (!is_dir($dir)) mkdir($dir, 0755, true);
$filename = $slug . '.md';
$filepath = $dir . '/' . $filename;
if (file_exists($filepath) && !($input['overwrite'] ?? false)) {
echo json_encode(['error' => 'File already exists', 'filename' => $filename]);
exit;
}
file_put_contents($filepath, $fm . "\n\n" . $body);
$pfx = match($type) {
'page' => 'source/',
'draft' => 'source/_drafts/' . ($folder ? $folder . '/' : ''),
default => 'source/_posts/' . ($folder ? $folder . '/' : ''),
};
$relPost = $pfx . $filename;
Audit::log($db, 'post_create', $project_id, $relPost);
echo json_encode(['ok' => true, 'filename' => $filename, 'path' => $relPost]);
exit;
}
// ── DELETE ────────────────────────────────────────────────────────────────────
if ($method === 'DELETE') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$relpath = trim($input['relpath'] ?? ''); // relative to posts_dir or pages_dir
$type = $input['type'] ?? 'post';
if (!$relpath || !str_ends_with($relpath, '.md')) {
echo json_encode(['error' => 'Invalid path']); exit;
}
$dir = match($type) {
'page' => $pages_dir,
'draft' => $drafts_dir,
default => $posts_dir,
};
$path = realpath($dir . '/' . $relpath);
if (!$path || !str_starts_with($path . '/', $dir . '/')) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
unlink($path);
$pfx = match($type) { 'page' => 'source/', 'draft' => 'source/_drafts/', default => 'source/_posts/' };
Audit::log($db, 'post_delete', $project_id, $pfx . $relpath);
echo json_encode(['ok' => true]);
exit;
}
function parseFrontMatter(string $content): array {
if (!str_starts_with($content, '---')) return [];
$end = strpos($content, '---', 3);
if (!$end) return [];
$yaml = substr($content, 3, $end - 3);
preg_match('/^title:\s*["\']?(.+?)["\']?\s*$/m', $yaml, $tm);
preg_match('/^date:\s*(.+)$/m', $yaml, $dm);
return [
'title' => $tm[1] ?? null,
'date' => $dm[1] ?? null,
'tags' => extractYamlList($yaml, 'tags'),
'categories' => extractYamlList($yaml, 'categories', 'category'),
];
}
function extractYamlList(string $yaml, string $key, ?string $altKey = null): array {
$kq = preg_quote($key, '/');
// 1. Inline array on same line: key: [a, b, c]
if (preg_match('/^' . $kq . ':[ \t]*\[(.+?)\]\s*$/m', $yaml, $m)) {
return array_values(array_filter(array_map(
fn($s) => trim($s, "\"' "), explode(',', $m[1])
)));
}
// 2. Block list (any leading indent, incl. none): key:\n- a\n- b
// Checked BEFORE single-value form so that `\s*` in the single-value
// pattern can't cannibalise the dash-prefixed lines below the key.
if (preg_match('/^' . $kq . ':[ \t]*\n((?:[ \t]*-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^[ \t]*-\s*(.+?)\s*$/m', $m[1], $items);
$out = [];
foreach ($items[1] as $item) {
$item = trim($item, "\"' ");
// Hexo nested category form - [Foo, Bar] → take first element
if ($item !== '' && $item[0] === '[') {
$inner = trim($item, "[]");
$first = trim(explode(',', $inner)[0] ?? '', "\"' ");
if ($first !== '') $out[] = $first;
} elseif ($item !== '') {
$out[] = $item;
}
}
return array_values($out);
}
// 3. Single value on the same line as the key: key: foo
// Whitespace must be tab/space (not newline) so this can't hop into a
// block list on the next line.
if (preg_match('/^' . $kq . ':[ \t]+([^\s\[].*?)\s*$/m', $yaml, $m)) {
return [trim($m[1], "\"' ")];
}
// 4. Alt-key fallback (e.g. `category: foo` for `categories`)
if ($altKey && preg_match('/^' . preg_quote($altKey, '/') . ':[ \t]+(.+?)\s*$/m', $yaml, $m)) {
return [trim($m[1], "\"' ")];
}
return [];
}
function slugify(string $s): string {
$s = mb_strtolower($s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-') ?: 'post-' . time();
}

146
web/api/projects.php Normal file
View file

@ -0,0 +1,146 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
echo json_encode($db->query('SELECT * FROM projects WHERE is_active = 1 ORDER BY is_pinned DESC, name')->fetchAll());
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'add_scan_path') {
$path = trim($input['path'] ?? '');
$depth = max(1, min(5, (int)($input['depth'] ?? 2)));
if (!$path) { echo json_encode(['error' => 'Path required']); exit; }
$stmt = $db->prepare('INSERT OR REPLACE INTO scan_paths (path, depth) VALUES (?, ?)');
$stmt->execute([$path, $depth]);
Audit::log($db, 'scan_path_add', null, $path . ' depth=' . $depth);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update_type') {
$id = (int)($input['id'] ?? 0);
$type = trim($input['type'] ?? '');
if (!$id || !$type) { echo json_encode(['error' => 'id and type required']); exit; }
$db->prepare('UPDATE projects SET type = ? WHERE id = ?')->execute([$type, $id]);
Audit::log($db, 'project_type_change', $id, $type);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'update_name') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$db->prepare('UPDATE projects SET name = ? WHERE id = ?')->execute([$name, $id]);
Audit::log($db, 'project_rename', $id, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'update_setting') {
$id = (int)($input['id'] ?? 0);
$key = trim($input['key'] ?? '');
$val = $input['value'] ?? null;
if (!$id || !$key) { echo json_encode(['error' => 'id and key required']); exit; }
$db->prepare('INSERT OR REPLACE INTO project_settings (project_id, key, value) VALUES (?, ?, ?)')
->execute([$id, $key, $val]);
Audit::log($db, 'project_setting', $id, $key);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'pin') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$db->prepare('UPDATE projects SET is_pinned = CASE WHEN is_pinned = 1 THEN 0 ELSE 1 END WHERE id = ?')
->execute([$id]);
$stmt = $db->prepare('SELECT is_pinned FROM projects WHERE id = ?');
$stmt->execute([$id]);
$pinned = (bool)$stmt->fetchColumn();
Audit::log($db, $pinned ? 'project_pin' : 'project_unpin', $id);
echo json_encode(['ok' => true, 'is_pinned' => $pinned]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$db->prepare('UPDATE projects SET is_active = 0 WHERE id = ?')->execute([$id]);
Audit::log($db, 'project_delete', $id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'remove_scan_path') {
$sid = (int)($input['id'] ?? 0);
$db->prepare('DELETE FROM scan_paths WHERE id = ?')->execute([$sid]);
Audit::log($db, 'scan_path_delete', null, 'id=' . $sid);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'scan') {
$path = trim($input['path'] ?? '');
$depth = max(1, min(5, (int)($input['depth'] ?? 2)));
$real = realpath($path);
if (!$real || !is_dir($real)) {
echo json_encode(['error' => 'Path not found or not a directory']);
exit;
}
$found = [];
scanForProjects($real, $depth, $found);
echo json_encode(['found' => $found]);
exit;
}
// Default: create project
$name = trim($input['name'] ?? '');
$path = trim($input['path'] ?? '');
$type = trim($input['type'] ?? 'generic');
$url = trim($input['url'] ?? '') ?: null;
if (!$name || !$path) {
echo json_encode(['error' => 'Name and path are required']);
exit;
}
$stmt = $db->prepare('INSERT INTO projects (name, path, type, url) VALUES (?, ?, ?, ?)');
$stmt->execute([$name, $path, $type, $url]);
$newId = (int)$db->lastInsertId();
Audit::log($db, 'project_add', $newId, $name . ' (' . $type . ')');
echo json_encode(['ok' => true, 'id' => $newId]);
exit;
}
if ($method === 'DELETE') {
$id = (int)($input['id'] ?? 0);
$db->prepare('UPDATE projects SET is_active = 0 WHERE id = ?')->execute([$id]);
Audit::log($db, 'project_delete', $id);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
function scanForProjects(string $base, int $maxDepth, array &$found, int $depth = 0): void {
if ($depth >= $maxDepth) return;
$items = @scandir($base);
if (!$items) return;
foreach ($items as $item) {
if ($item[0] === '.') continue;
$path = $base . '/' . $item;
if (!is_dir($path)) continue;
$type = ProjectTypes::detect($path);
$typeClass = ProjectTypes::get($type);
$found[] = [
'name' => $item,
'path' => $path,
'type' => $type,
'type_name' => $typeClass ? $typeClass::typeName() : $type,
];
scanForProjects($path, $maxDepth, $found, $depth + 1);
}
}

72
web/api/recent.php Normal file
View file

@ -0,0 +1,72 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, path FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? 'touch';
if ($action === 'touch') {
$path = trim($input['path'] ?? '');
if ($path === '') { echo json_encode(['error' => 'path required']); exit; }
$db->prepare('INSERT INTO recent_files (project_id, path, opened_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(project_id, path) DO UPDATE SET opened_at = CURRENT_TIMESTAMP')
->execute([$project_id, $path]);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'clear') {
$db->prepare('DELETE FROM recent_files WHERE project_id = ?')->execute([$project_id]);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'remove') {
$path = trim($input['path'] ?? '');
$db->prepare('DELETE FROM recent_files WHERE project_id = ? AND path = ?')
->execute([$project_id, $path]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET — list recent files, prune missing ones lazily
$limit = max(1, min(100, (int)($_GET['limit'] ?? 30)));
$rows = $db->prepare('SELECT path, opened_at FROM recent_files
WHERE project_id = ? ORDER BY opened_at DESC LIMIT ?');
$rows->execute([$project_id, $limit]);
$base = realpath($project['path']);
$out = [];
$pruned = [];
foreach ($rows->fetchAll() as $r) {
$abs = $base ? $base . '/' . ltrim($r['path'], '/') : null;
if (!$abs || !is_file($abs)) {
$pruned[] = $r['path'];
continue;
}
$out[] = [
'path' => $r['path'],
'name' => basename($r['path']),
'dir' => dirname($r['path']) === '.' ? '' : dirname($r['path']),
'opened_at' => $r['opened_at'],
'size' => @filesize($abs),
'modified' => @filemtime($abs),
];
}
if ($pruned) {
$del = $db->prepare('DELETE FROM recent_files WHERE project_id = ? AND path = ?');
foreach ($pruned as $p) $del->execute([$project_id, $p]);
}
echo json_encode(['items' => $out]);

62
web/api/run.php Normal file
View file

@ -0,0 +1,62 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
// GET: return command history for a project
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare(
'SELECT * FROM command_history WHERE project_id = ? ORDER BY run_at DESC LIMIT 50'
);
$stmt->execute([$pid]);
echo json_encode(['history' => $stmt->fetchAll()]);
exit;
}
if ($method !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'POST required']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$project_id = (int)($input['project_id'] ?? 0);
$cmd_id = $input['cmd'] ?? '';
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$type = ProjectTypes::get($project['type']);
if (!$type) { echo json_encode(['error' => 'Unknown project type']); exit; }
// Find the matching whitelisted command
$cmd = null;
foreach ($type::commands() as $c) {
if ($c['id'] === $cmd_id) { $cmd = $c; break; }
}
if (!$cmd) { http_response_code(400); echo json_encode(['error' => 'Unknown command']); exit; }
$path = realpath($project['path']);
if (!$path || !is_dir($path)) {
echo json_encode(['error' => 'Project path not accessible on this server']);
exit;
}
$output = [];
$exit_code = 0;
exec('cd ' . escapeshellarg($path) . ' && ' . $cmd['cmd'] . ' 2>&1', $output, $exit_code);
$outText = implode("\n", $output);
$db->prepare('INSERT INTO command_history (project_id, cmd_id, cmd, output, exit_code) VALUES (?, ?, ?, ?, ?)')
->execute([$project_id, $cmd_id, $cmd['cmd'], $outText, $exit_code]);
Audit::log($db, 'command_run', $project_id, $cmd_id . ' exit=' . $exit_code);
echo json_encode([
'exit_code' => $exit_code,
'output' => $outText,
'cmd' => $cmd['cmd'],
]);

68
web/api/schedules.php Normal file
View file

@ -0,0 +1,68 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
if (!$stmt->fetch()) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'GET') {
$rows = $db->prepare('SELECT * FROM scheduled_builds WHERE project_id = ? ORDER BY id');
$rows->execute([$project_id]);
echo json_encode(['items' => $rows->fetchAll()]);
exit;
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
if ($action === 'create') {
$cmdId = trim($input['cmd_id'] ?? '');
$cron = trim($input['cron'] ?? '');
$en = !empty($input['is_enabled']) ? 1 : 0;
if (!$cmdId || !$cron) { echo json_encode(['error' => 'cmd_id and cron required']); exit; }
$db->prepare('INSERT INTO scheduled_builds (project_id, cmd_id, cron, is_enabled)
VALUES (?, ?, ?, ?)')->execute([$project_id, $cmdId, $cron, $en]);
$sid = (int)$db->lastInsertId();
Audit::log($db, 'schedule_create', $project_id, $cmdId . ' "' . $cron . '"');
echo json_encode(['ok' => true, 'id' => $sid]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$sets = []; $args = [];
foreach (['cmd_id', 'cron'] as $k) {
if (array_key_exists($k, $input)) { $sets[] = "$k = ?"; $args[] = trim($input[$k]); }
}
if (array_key_exists('is_enabled', $input)) {
$sets[] = 'is_enabled = ?'; $args[] = $input['is_enabled'] ? 1 : 0;
}
if (!$sets) { echo json_encode(['ok' => true]); exit; }
$args[] = $id; $args[] = $project_id;
$db->prepare('UPDATE scheduled_builds SET ' . implode(', ', $sets)
. ' WHERE id = ? AND project_id = ?')->execute($args);
Audit::log($db, 'schedule_update', $project_id, 'id=' . $id);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
$db->prepare('DELETE FROM scheduled_builds WHERE id = ? AND project_id = ?')
->execute([$id, $project_id]);
Audit::log($db, 'schedule_delete', $project_id, 'id=' . $id);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

26
web/api/scratchpad.php Normal file
View file

@ -0,0 +1,26 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT id, scratchpad FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$row = $stmt->fetch();
if (!$row) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
if ($method === 'GET') {
echo json_encode(['content' => (string)$row['scratchpad']]);
exit;
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$content = (string)($input['content'] ?? '');
$db->prepare('UPDATE projects SET scratchpad = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
->execute([$content, $project_id]);
echo json_encode(['ok' => true]);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

32
web/api/search.php Normal file
View file

@ -0,0 +1,32 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$q = trim($_GET['q'] ?? '');
if (!$q) { echo json_encode(['results' => []]); exit; }
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Path not accessible']); exit; }
$output = [];
exec('grep -rn --include="*.md" -i ' . escapeshellarg($q) . ' ' . escapeshellarg($base) . '/source 2>/dev/null', $output);
$results = [];
foreach ($output as $line) {
if (!preg_match('#^(.+\.md):(\d+):(.+)$#', $line, $m)) continue;
$file = ltrim(str_replace($base, '', $m[1]), '/');
$results[] = [
'file' => $file,
'line' => (int)$m[2],
'content' => trim($m[3]),
];
if (count($results) >= 100) break;
}
echo json_encode(['results' => $results, 'query' => $q]);

58
web/api/snippets.php Normal file
View file

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM snippets WHERE project_id = ? ORDER BY name');
$stmt->execute([$pid]);
echo json_encode(['snippets' => $stmt->fetchAll()]);
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'create') {
$pid = (int)($input['project_id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$pid || !$name) { echo json_encode(['error' => 'project_id and name required']); exit; }
$stmt = $db->prepare('INSERT INTO snippets (project_id, name, content) VALUES (?, ?, ?)');
$stmt->execute([$pid, $name, $content]);
Audit::log($db, 'snippet_create', $pid, $name);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$row = $db->prepare('SELECT project_id FROM snippets WHERE id = ?');
$row->execute([$id]);
$pid = (int)$row->fetchColumn();
$db->prepare('UPDATE snippets SET name = ?, content = ? WHERE id = ?')
->execute([$name, $content, $id]);
Audit::log($db, 'snippet_update', $pid ?: null, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$row = $db->prepare('SELECT project_id, name FROM snippets WHERE id = ?');
$row->execute([$id]);
$r = $row->fetch();
$db->prepare('DELETE FROM snippets WHERE id = ?')->execute([$id]);
Audit::log($db, 'snippet_delete', $r ? (int)$r['project_id'] : null, $r['name'] ?? null);
echo json_encode(['ok' => true]);
exit;
}
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

58
web/api/tags.php Normal file
View file

@ -0,0 +1,58 @@
<?php
header('Content-Type: application/json');
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Not found']); exit; }
$base = realpath($project['path'] . '/source');
if (!$base || !is_dir($base)) { echo json_encode(['tags' => [], 'categories' => []]); exit; }
$tags = [];
$categories = [];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS)
);
foreach ($it as $file) {
if ($file->getExtension() !== 'md') continue;
$content = file_get_contents($file->getPathname());
$fm = parseFm($content);
foreach ($fm['tags'] as $t) $tags[$t] = ($tags[$t] ?? 0) + 1;
foreach ($fm['cats'] as $c) $categories[$c] = ($categories[$c] ?? 0) + 1;
}
arsort($tags); arsort($categories);
echo json_encode(['tags' => $tags, 'categories' => $categories]);
function parseFm(string $content): array {
$tags = []; $cats = [];
if (!str_starts_with($content, '---')) return compact('tags', 'cats');
$end = strpos($content, '---', 3);
if (!$end) return compact('tags', 'cats');
$yaml = substr($content, 3, $end - 3);
// tags: [a, b, c] or tags:\n - a\n - b
if (preg_match('/^tags:\s*\[(.+)\]/m', $yaml, $m)) {
$tags = array_map('trim', explode(',', $m[1]));
} elseif (preg_match('/^tags:\s*\n((?:\s+-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^\s+-\s*(.+)$/m', $m[1], $items);
$tags = array_map('trim', $items[1]);
}
if (preg_match('/^categories:\s*\[(.+)\]/m', $yaml, $m)) {
$cats = array_map('trim', explode(',', $m[1]));
} elseif (preg_match('/^categories:\s*\n((?:\s+-\s*.+\n?)+)/m', $yaml, $m)) {
preg_match_all('/^\s+-\s*(.+)$/m', $m[1], $items);
$cats = array_map('trim', $items[1]);
} elseif (preg_match('/^category:\s*(.+)$/m', $yaml, $m)) {
$cats = [trim($m[1])];
}
$tags = array_filter(array_map('trim', $tags));
$cats = array_filter(array_map('trim', $cats));
return compact('tags', 'cats');
}

59
web/api/templates.php Normal file
View file

@ -0,0 +1,59 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'GET') {
$pid = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM post_templates WHERE project_id = ? ORDER BY name');
$stmt->execute([$pid]);
echo json_encode(['templates' => $stmt->fetchAll()]);
exit;
}
if ($method === 'POST') {
$action = $input['action'] ?? 'create';
if ($action === 'create') {
$pid = (int)($input['project_id'] ?? 0);
$name = trim($input['name'] ?? '');
$type = $input['type'] ?? 'post';
$content = $input['content'] ?? '';
if (!$pid || !$name) { echo json_encode(['error' => 'project_id and name required']); exit; }
$stmt = $db->prepare('INSERT INTO post_templates (project_id, name, type, content) VALUES (?, ?, ?, ?)');
$stmt->execute([$pid, $name, $type, $content]);
Audit::log($db, 'template_create', $pid, $name);
echo json_encode(['ok' => true, 'id' => (int)$db->lastInsertId()]);
exit;
}
if ($action === 'update') {
$id = (int)($input['id'] ?? 0);
$name = trim($input['name'] ?? '');
$content = $input['content'] ?? '';
if (!$id || !$name) { echo json_encode(['error' => 'id and name required']); exit; }
$row = $db->prepare('SELECT project_id FROM post_templates WHERE id = ?');
$row->execute([$id]);
$pid = (int)$row->fetchColumn();
$db->prepare('UPDATE post_templates SET name = ?, content = ? WHERE id = ?')
->execute([$name, $content, $id]);
Audit::log($db, 'template_update', $pid ?: null, $name);
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'delete') {
$id = (int)($input['id'] ?? 0);
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
$row = $db->prepare('SELECT project_id, name FROM post_templates WHERE id = ?');
$row->execute([$id]);
$r = $row->fetch();
$db->prepare('DELETE FROM post_templates WHERE id = ?')->execute([$id]);
Audit::log($db, 'template_delete', $r ? (int)$r['project_id'] : null, $r['name'] ?? null);
echo json_encode(['ok' => true]);
exit;
}
}
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);

154
web/api/themes.php Normal file
View file

@ -0,0 +1,154 @@
<?php
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'];
$project_id = (int)($_GET['project_id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
$themesDir = $base ? $base . '/themes' : null;
$configFile = $base ? $base . '/_config.yml' : null;
if (!$base || !is_dir($base)) { echo json_encode(['error' => 'Project path not accessible']); exit; }
function readActiveTheme(?string $configFile): ?string {
if (!$configFile || !is_file($configFile)) return null;
foreach (file($configFile, FILE_IGNORE_NEW_LINES) as $line) {
if (preg_match('/^theme:\s*(.+?)\s*$/', $line, $m)) return trim($m[1], "\"' ");
}
return null;
}
function writeActiveTheme(string $configFile, string $name): bool {
if (!is_file($configFile)) return false;
$lines = file($configFile, FILE_IGNORE_NEW_LINES);
$found = false;
foreach ($lines as $i => $line) {
if (preg_match('/^theme:/', $line)) { $lines[$i] = 'theme: ' . $name; $found = true; break; }
}
if (!$found) $lines[] = 'theme: ' . $name;
return file_put_contents($configFile, implode("\n", $lines) . "\n") !== false;
}
function gitInfo(string $dir): array {
if (!is_dir($dir . '/.git')) return ['has_git' => false];
$run = function (string $cmd) use ($dir) {
$out = [];
exec('cd ' . escapeshellarg($dir) . ' && ' . $cmd . ' 2>/dev/null', $out);
return implode("\n", $out);
};
$branch = trim($run('git rev-parse --abbrev-ref HEAD'));
$remote = trim($run('git remote get-url origin'));
$commit = trim($run('git log -1 --pretty=format:"%h %s"'));
$status = trim($run('git status --porcelain'));
@exec('cd ' . escapeshellarg($dir) . ' && git rev-list --left-right --count @{u}...HEAD 2>/dev/null',
$countOut);
$ahead = $behind = null;
if (!empty($countOut[0]) && preg_match('/^(\d+)\s+(\d+)$/', $countOut[0], $m)) {
$behind = (int)$m[1]; $ahead = (int)$m[2];
}
return [
'has_git' => true,
'branch' => $branch,
'remote' => $remote ?: null,
'commit' => $commit ?: null,
'dirty' => $status !== '',
'ahead' => $ahead,
'behind' => $behind,
];
}
if ($method === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$action = $input['action'] ?? '';
$name = preg_replace('/[^A-Za-z0-9._-]/', '', (string)($input['name'] ?? ''));
if ($action === 'switch' && $name) {
if (!is_dir($themesDir . '/' . $name)) { echo json_encode(['error' => 'Theme not found']); exit; }
$ok = writeActiveTheme($configFile, $name);
Audit::log($db, 'theme_switch', $project_id, $name);
echo json_encode(['ok' => $ok, 'active' => $name]);
exit;
}
if ($action === 'clone') {
$url = trim($input['url'] ?? '');
if (!$url) { echo json_encode(['error' => 'url required']); exit; }
if (!$name) {
// derive from url
$name = preg_replace('/\.git$/', '', basename(parse_url($url, PHP_URL_PATH) ?: ''));
$name = preg_replace('/[^A-Za-z0-9._-]/', '', $name);
}
if (!$name) { echo json_encode(['error' => 'cannot derive name']); exit; }
$dest = $themesDir . '/' . $name;
if (is_dir($dest)) { echo json_encode(['error' => 'Theme directory already exists']); exit; }
if (!is_dir($themesDir)) mkdir($themesDir, 0755, true);
$cmd = 'git clone --depth 50 ' . escapeshellarg($url) . ' ' . escapeshellarg($dest) . ' 2>&1';
exec($cmd, $out, $rc);
$log = implode("\n", $out);
if ($rc !== 0) { echo json_encode(['error' => 'git clone failed', 'log' => $log]); exit; }
Audit::log($db, 'theme_clone', $project_id, $name);
echo json_encode(['ok' => true, 'name' => $name, 'log' => $log]);
exit;
}
if ($action === 'git' && $name) {
$op = $input['op'] ?? '';
$dir = $themesDir . '/' . $name;
if (!is_dir($dir . '/.git')) { echo json_encode(['error' => 'No git repo in theme']); exit; }
$cmd = match ($op) {
'pull' => 'git pull',
'push' => 'git push',
'fetch' => 'git fetch',
default => null,
};
if (!$cmd) { echo json_encode(['error' => 'Unknown op']); exit; }
exec('cd ' . escapeshellarg($dir) . ' && ' . $cmd . ' 2>&1', $out, $rc);
Audit::log($db, 'theme_git_' . $op, $project_id, $name);
echo json_encode(['ok' => $rc === 0, 'log' => implode("\n", $out)]);
exit;
}
if ($action === 'delete' && $name) {
if ($name === readActiveTheme($configFile)) {
echo json_encode(['error' => 'Cannot delete the active theme']); exit;
}
$dir = $themesDir . '/' . $name;
if (!is_dir($dir) || !str_starts_with(realpath($dir) . '/', realpath($themesDir) . '/')) {
echo json_encode(['error' => 'Theme not found']); exit;
}
exec('rm -rf ' . escapeshellarg($dir), $_o, $rc);
Audit::log($db, 'theme_delete', $project_id, $name);
echo json_encode(['ok' => $rc === 0]);
exit;
}
http_response_code(400);
echo json_encode(['error' => 'Unknown action']);
exit;
}
// GET list
if (!$themesDir || !is_dir($themesDir)) {
echo json_encode(['active' => null, 'themes' => [], 'no_themes_dir' => true]);
exit;
}
$active = readActiveTheme($configFile);
$themes = [];
foreach (scandir($themesDir) as $name) {
if ($name[0] === '.') continue;
$dir = $themesDir . '/' . $name;
if (!is_dir($dir)) continue;
$themes[] = [
'name' => $name,
'active' => $name === $active,
'git' => gitInfo($dir),
];
}
usort($themes, fn($a, $b) => ((int)$b['active']) - ((int)$a['active']) ?: strcmp($a['name'], $b['name']));
echo json_encode(['active' => $active, 'themes' => $themes]);

33
web/api/track.php Normal file
View file

@ -0,0 +1,33 @@
<?php
// Public visit-tracking endpoint. Whitelisted in index.php; no session required.
$pid = (int)($_GET['p'] ?? 0);
$path = substr(trim((string)($_GET['path'] ?? '/')), 0, 512);
$ref = substr(trim((string)($_GET['ref'] ?? '')), 0, 512);
$ua = (string)($_SERVER['HTTP_USER_AGENT'] ?? '');
$ip = (string)($_SERVER['REMOTE_ADDR'] ?? '');
// Truncated salted hashes to count uniques without storing raw values
$salt = 'hackmancms-site-visits';
$uaHash = $ua ? substr(hash('sha256', $salt . $ua), 0, 16) : null;
$ipHash = $ip ? substr(hash('sha256', $salt . $ip), 0, 16) : null;
// Verify the project exists and is active
$st = $db->prepare('SELECT id FROM projects WHERE id = ? AND is_active = 1');
$st->execute([$pid]);
if ($st->fetch()) {
try {
$db->prepare('INSERT INTO site_visits (project_id, path, referrer, ua_hash, ip_hash)
VALUES (?, ?, ?, ?, ?)')
->execute([$pid, $path, $ref ?: null, $uaHash, $ipHash]);
} catch (Exception $e) { /* don't fail the pixel on logging error */ }
}
// 1x1 transparent PNG, cache-busted by request
header('Content-Type: image/png');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Access-Control-Allow-Origin: *');
echo base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAYAAjCB0C8AAAAASUVORK5CYII='
);

110
web/api/upload.php Normal file
View file

@ -0,0 +1,110 @@
<?php
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); echo json_encode(['error' => 'POST required']); exit;
}
$project_id = (int)($_POST['project_id'] ?? 0);
$folder = trim($_POST['folder'] ?? '');
$stmt = $db->prepare('SELECT * FROM projects WHERE id = ? AND is_active = 1');
$stmt->execute([$project_id]);
$project = $stmt->fetch();
if (!$project) { http_response_code(404); echo json_encode(['error' => 'Project not found']); exit; }
$base = realpath($project['path']);
if (!$base) { echo json_encode(['error' => 'Project path not accessible']); exit; }
// Validate target folder (may not exist yet)
$target_dir = $folder ? $base . '/' . ltrim($folder, '/') : $base;
$real_dir = realpath($target_dir);
if ($real_dir) {
if (!str_starts_with($real_dir . '/', $base . '/') && $real_dir !== $base) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
} else {
// Directory doesn't exist yet — validate parent
$parent = realpath(dirname($target_dir));
if (!$parent || !str_starts_with($parent . '/', $base . '/') && $parent !== $base) {
http_response_code(403); echo json_encode(['error' => 'Access denied']); exit;
}
mkdir($target_dir, 0755, true);
$real_dir = realpath($target_dir);
}
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload failed (error ' . ($_FILES['file']['error'] ?? '?') . ')']);
exit;
}
$file = $_FILES['file'];
// Validate MIME type
$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
$accept = $_POST['accept'] ?? 'image';
if ($accept === 'image') {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, $allowed)) {
echo json_encode(['error' => 'Only image files allowed']); exit;
}
}
$safe_name = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $file['name']);
$dest = $real_dir . '/' . $safe_name;
// Avoid overwriting
if (file_exists($dest)) {
$info = pathinfo($safe_name);
$safe_name = $info['filename'] . '_' . time() . '.' . ($info['extension'] ?? '');
$dest = $real_dir . '/' . $safe_name;
}
move_uploaded_file($file['tmp_name'], $dest);
// Optimize: resize images wider than 2000px
if (isset($mime) && in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
optimizeImage($dest, $mime);
}
$rel_path = ltrim(str_replace($base, '', $dest), '/');
$url = $project['url'] ? rtrim($project['url'], '/') . '/' . $rel_path : null;
Audit::log($db, 'file_upload', $project_id, $rel_path);
echo json_encode([
'ok' => true,
'filename' => $safe_name,
'path' => $rel_path,
'url' => $url,
]);
function optimizeImage(string $path, string $mime): void {
if (!function_exists('imagecreatefromjpeg')) return;
$img = match($mime) {
'image/jpeg' => @imagecreatefromjpeg($path),
'image/png' => @imagecreatefrompng($path),
'image/webp' => @imagecreatefromwebp($path),
default => false,
};
if (!$img) return;
$w = imagesx($img);
$h = imagesy($img);
if ($w <= 2000) { imagedestroy($img); return; }
$nw = 2000;
$nh = (int)round($h * 2000 / $w);
$resized = imagecreatetruecolor($nw, $nh);
if ($mime === 'image/png') {
imagealphablending($resized, false);
imagesavealpha($resized, true);
}
imagecopyresampled($resized, $img, 0, 0, 0, 0, $nw, $nh, $w, $h);
match($mime) {
'image/jpeg' => imagejpeg($resized, $path, 85),
'image/png' => imagepng($resized, $path, 8),
'image/webp' => imagewebp($resized, $path, 85),
};
imagedestroy($img);
imagedestroy($resized);
}

440
web/assets/css/app.css Normal file
View file

@ -0,0 +1,440 @@
:root {
--hm-bg: #0d1117;
--hm-surface: #161b22;
--hm-border: #30363d;
--hm-muted: #6e7681;
/* Vertical budget for a project tab pane after navbar + main padding + footer */
--hm-tab-height: calc(100vh - 130px);
}
body {
background-color: var(--hm-bg);
display: flex;
flex-direction: column;
min-height: 100vh;
}
body > main { flex: 1 0 auto; }
body > footer.border-top {
border-top-color: var(--hm-border) !important;
flex-shrink: 0;
}
.navbar,
.card,
.modal-content,
.list-group-item,
.dropdown-menu {
background-color: var(--hm-surface) !important;
border-color: var(--hm-border) !important;
}
.list-group-item { color: inherit; }
.list-group-item-action:hover,
.list-group-item-action:focus { background-color: #21262d !important; }
.nav-tabs { border-color: var(--hm-border); }
.nav-tabs .nav-link { color: var(--hm-muted); border-color: transparent; }
.nav-tabs .nav-link:hover { color: #e6edf3; border-color: transparent; background: #21262d; }
.nav-tabs .nav-link.active {
background-color: var(--hm-surface);
border-color: var(--hm-border) var(--hm-border) var(--hm-surface);
color: #e6edf3;
}
/* Project page header (single-line, condensed) */
.project-header > code {
background: transparent;
padding: 0;
font-size: .8rem;
flex: 1 1 0;
min-width: 0;
}
.project-header h2 { font-weight: 500; }
main.container-fluid { padding-top: 1rem !important; padding-bottom: 1rem !important; }
/* Project page sidebar nav */
.project-sidebar {
flex: 0 0 200px;
position: sticky;
top: 1rem;
align-self: flex-start;
position: relative;
}
.project-sidebar .nav-link {
color: var(--hm-muted);
padding: .35rem .65rem;
border-radius: .375rem;
margin-bottom: .1rem;
font-size: .875rem;
display: flex;
align-items: center;
gap: .55rem;
white-space: nowrap;
}
.project-sidebar .nav-link i {
width: 1rem;
text-align: center;
font-size: 1rem;
flex-shrink: 0;
}
.project-sidebar .nav-link:hover {
background: #21262d;
color: #e6edf3;
}
.project-sidebar .nav-link.active {
background: #1f6feb33;
color: #e6edf3;
border-left: 2px solid #58a6ff;
padding-left: calc(.65rem - 2px);
}
/* Subtle separators between nav groups */
.project-sidebar hr {
border: none;
border-top: 1px solid var(--hm-border);
opacity: .35;
margin: .4rem .25rem;
}
/* Collapse toggle — floating circular button at bottom-left of viewport */
.project-sidebar .sidebar-toggle {
position: fixed;
bottom: 1rem;
left: .5rem;
z-index: 1050;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--hm-surface) !important;
border: 1px solid var(--hm-border) !important;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.project-sidebar .sidebar-toggle:hover {
background: #21262d !important;
color: #e6edf3 !important;
}
.project-sidebar .collapse-icon-collapsed { display: none; }
.project-sidebar.collapsed .collapse-icon-expanded { display: none; }
.project-sidebar.collapsed .collapse-icon-collapsed { display: inline; }
/* Collapsed state — hide labels, shrink width, centre type icon */
.project-sidebar.collapsed { flex: 0 0 48px; }
.project-sidebar.collapsed .nav-link { justify-content: center; padding: .4rem; }
.project-sidebar.collapsed .nav-link.active { padding-left: calc(.4rem - 2px); }
.project-sidebar.collapsed .sidebar-label { display: none; }
.project-sidebar.collapsed .project-sidebar-header > .d-flex {
justify-content: center;
}
/* Auto-collapse on narrow viewports */
@media (max-width: 767.98px) {
.project-sidebar { flex: 0 0 48px; }
.project-sidebar .nav-link { justify-content: center; padding: .4rem; }
.project-sidebar .nav-link.active { padding-left: calc(.4rem - 2px); }
.project-sidebar .sidebar-label { display: none; }
.project-sidebar > .project-sidebar-header > .d-flex {
justify-content: center;
}
}
code { color: #79c0ff; }
pre { color: #e6edf3; }
.breadcrumb-item + .breadcrumb-item::before { color: var(--hm-muted); }
.card-header {
background-color: #1c2128 !important;
border-color: var(--hm-border) !important;
font-size: .875rem;
}
.badge.bg-secondary { background-color: #21262d !important; }
/* CodeMirror in dark context */
.CodeMirror { height: 100%; font-size: .85rem; }
#fileEditCm .CodeMirror { height: 70vh; }
#newPostFmEditor .CodeMirror { height: 120px; }
/* EasyMDE dark tweaks */
.EasyMDE-wrapper, .editor-toolbar, .CodeMirror.cm-s-paper {
background-color: var(--hm-surface) !important;
border-color: var(--hm-border) !important;
color: #e6edf3 !important;
}
.editor-toolbar button { color: #e6edf3 !important; }
.editor-toolbar button:hover, .editor-toolbar button.active { background: #21262d !important; }
.editor-statusbar { color: var(--hm-muted); }
/* Media grid */
#mediaGrid .card { transition: border-color .15s; }
#mediaGrid .card:hover { border-color: #58a6ff !important; }
/* Small button variant */
.btn-xs { padding: .1rem .35rem; font-size: .75rem; line-height: 1.4; }
.btn-xs.btn-outline-secondary { border-color: var(--hm-border); color: var(--hm-muted); }
.btn-xs.btn-outline-secondary:hover { border-color: #8b949e; color: #e6edf3; background: transparent; }
.btn-xs.btn-outline-danger { border-color: var(--hm-border); color: var(--hm-muted); }
.btn-xs.btn-outline-danger:hover { border-color: #f85149; color: #f85149; background: transparent; }
/* Git diff viewer */
.diff-view { background: var(--hm-bg); color: #e6edf3; }
.diff-add { background: rgba(63,185,80,.15); color: #7ee787; display: block; }
.diff-remove { background: rgba(248,81,73,.15); color: #ff7b72; display: block; }
.diff-hunk { color: #79c0ff; display: block; }
.diff-meta { color: var(--hm-muted); display: block; }
/* Git status XY codes */
.git-xy {
min-width: 2em; text-align: center;
color: #d29922; background: rgba(210,153,34,.1);
border-radius: 3px; padding: 0 .3rem;
}
/* Tag cloud */
.tag-cloud { line-height: 2.2; }
.tag-item {
display: inline-block; margin: .15rem .3rem;
padding: .1rem .5rem;
background: #21262d; border-radius: 999px;
color: #79c0ff; cursor: pointer;
text-decoration: none;
transition: background .15s;
}
.tag-item:hover { background: #2d333b; color: #a5d6ff; }
/* Search results */
.search-result:hover code { color: #a5d6ff; }
/* Markdown editor pane: photos banner inside the canvas */
.md-photos-banner { flex-shrink: 0; margin: 0; }
/* Inside-the-canvas variant: above the body content in the WYSIWYG pane,
* one image per row at natural aspect (no crop), full column width. */
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor {
display: block;
padding: .75rem 1rem;
margin-bottom: .25rem;
border-bottom: 1px solid var(--hm-border);
}
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor img {
display: block;
width: 75%;
height: auto;
max-height: none;
object-fit: contain;
margin: 0 auto .5rem;
border-radius: 4px;
border: 1px solid var(--hm-border);
}
.mk-mount-wrap .ie-mk-body .md-photos-banner.inside-editor img:last-child {
margin-bottom: 0;
}
.md-editor-mount {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
/* Floating save button only visible when the tab is dirty (.d-none toggled by JS).
* Bottom-right of the editor, above the content. */
.md-pane-save {
position: absolute;
bottom: 1rem;
right: 1.25rem;
z-index: 50;
border: none;
background: #1f6feb;
color: #fff;
font-size: .9rem;
padding: .5rem 1rem;
border-radius: 999px;
box-shadow: 0 4px 12px rgba(0,0,0,.35);
cursor: pointer;
transition: transform .12s, box-shadow .12s;
}
.md-pane-save:hover {
background: #388bfd;
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(0,0,0,.4);
}
/* Kebab in the editor toolbar (right end) — Delete / Publish */
.mk-pane-kebab .btn { padding: .2rem .45rem; line-height: 1.2; }
.mk-pane-kebab .btn:hover { background: #21262d; color: #e6edf3; }
/* FM editor — slots into the same flex slot as .ie-mk-body when active */
.mk-mount-wrap .ie-mk-fm {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
background: var(--hm-surface);
}
.mk-mount-wrap .ie-mk-fm .CodeMirror { flex: 1; height: 100% !important; }
.mk-mount-wrap .mk-fm-toggle.active {
background: #21262d; color: #e6edf3;
}
/* Milkdown via Agenda-style mk-mount — dark theme + full-height inside tab pane */
.mk-mount-wrap {
border: 1px solid var(--hm-border);
border-radius: .375rem;
background: var(--hm-surface);
overflow: hidden;
display: flex; flex-direction: column;
flex: 1; min-height: 0;
}
.mk-mount-wrap .ie-mk-toolbar {
display: flex; align-items: center; gap: .25rem;
padding: .3rem .5rem;
background: #1c2128;
border-bottom: 1px solid var(--hm-border);
flex-shrink: 0;
}
.mk-mount-wrap .ie-mk-tools { display: flex; gap: .25rem; flex: 1; flex-wrap: wrap; }
.mk-mount-wrap .ie-mk-sep { width: 1px; height: 16px; background: var(--hm-border); margin: 0 .1rem; }
.mk-mount-wrap .ie-mk-toolbar .btn { padding: .2rem .45rem; line-height: 1.2; color: #c9d1d9; border-color: var(--hm-border); }
.mk-mount-wrap .ie-mk-toolbar .btn:hover { background: #21262d; color: #e6edf3; }
/* JS (_reflowMdEditor) sets explicit pixel heights on .ie-mk-body and
* .ProseMirror that's what makes overflow-y: auto fire reliably and
* gives the empty editor a definite minimum. CSS just provides a sensible
* fallback for the brief moment before the first reflow. */
.mk-mount-wrap .ie-mk-body {
flex: 1; min-height: 0;
overflow-y: auto;
position: relative;
}
.mk-mount-wrap .ie-mk-body .ProseMirror {
padding: .75rem 1rem; font-size: .92rem; line-height: 1.55;
outline: none; color: #e6edf3;
}
.mk-mount-wrap .ie-mk-body .ProseMirror p { margin-bottom: .5rem; }
.mk-mount-wrap .ie-mk-body .ProseMirror ul,
.mk-mount-wrap .ie-mk-body .ProseMirror ol { padding-left: 1.4rem; margin-bottom: .5rem; }
.mk-mount-wrap .ie-mk-body .ProseMirror code {
background: #1c2128; color: #79c0ff;
padding: .1em .35em; border-radius: 3px;
}
.mk-mount-wrap .ie-mk-body .ProseMirror pre {
background: var(--hm-bg); border: 1px solid var(--hm-border);
border-radius: 4px; padding: .5rem; color: #e6edf3;
}
.mk-mount-wrap .ie-mk-body .ProseMirror pre code { background: transparent; padding: 0; }
.mk-mount-wrap .ie-mk-body .ProseMirror blockquote {
border-left: 3px solid var(--hm-border);
margin: .5rem 0; padding: .1rem 1rem; color: var(--hm-muted);
}
.mk-mount-wrap .ie-mk-body .ProseMirror a { color: #58a6ff; }
.mk-mount-wrap .ie-mk-body .ProseMirror img {
display: block;
width: 75%;
height: auto;
max-width: 75%;
margin: .5rem auto;
border-radius: 4px;
}
.mk-mount-wrap .ie-mk-body textarea.ie-mk-ta {
display: block; width: 100%; box-sizing: border-box;
border: none !important; border-radius: 0 !important;
background: var(--hm-surface) !important; color: #e6edf3 !important;
font-family: ui-monospace, monospace; font-size: .9rem;
min-height: calc(var(--hm-tab-height) - 260px);
resize: none;
}
/* Pinned project highlight */
.card:has(.badge.bg-warning) { border-color: rgba(210,153,34,.4) !important; }
/* Audit log table */
#auditTable .table { font-size: .82rem; }
#auditTable code { font-size: .8rem; }
/* Post folder tree — no folder icon, clean section headers */
.post-section-header {
display: flex; align-items: center; gap: .4rem;
padding: .25rem .5rem; margin-top: .5rem; margin-bottom: .15rem;
font-size: .75rem; font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--hm-muted);
cursor: pointer; border-radius: 4px;
user-select: none;
}
.post-section-header:hover { background: #21262d; color: #e6edf3; }
.post-section-chevron { transition: transform .15s; font-size: .65rem; }
.post-section-header[aria-expanded="true"] .post-section-chevron { transform: rotate(90deg); }
.posts-tree .list-group-item { cursor: pointer; }
.posts-tree .list-group-item:hover { background: #21262d !important; }
.posts-tree .list-group { border-radius: 4px; }
/* File browser drag-and-drop */
.split-list.drag-over {
outline: 2px dashed #58a6ff;
outline-offset: -4px;
background: rgba(88, 166, 255, 0.04) !important;
}
/* Split-pane layout */
.h-split { height: var(--hm-tab-height); min-height: 400px; overflow: hidden; }
.split-list { overflow-y: auto; height: 100%; }
#editorPane,
#draftEditorPane { height: 100%; overflow-y: auto; display: flex; flex-direction: column; }
.editor-placeholder {
text-align: center; padding-top: 3rem;
color: var(--hm-muted); font-size: .875rem;
}
.pane-cm-container { border: 1px solid var(--hm-border); border-radius: 4px; flex: 1; overflow: hidden; }
.pane-cm-container { height: 100%; }
.pane-cm-container .CodeMirror { height: 100% !important; min-height: 260px; }
/* Editor tab bar */
.editor-tab-bar {
display: flex; align-items: stretch; gap: 0;
border-bottom: 1px solid var(--hm-border);
overflow-x: auto; flex-shrink: 0;
scrollbar-width: none;
min-height: 32px;
}
.editor-tab-bar::-webkit-scrollbar { display: none; }
.editor-tab {
display: flex; align-items: center; gap: .35rem;
padding: .25rem .65rem; font-size: .78rem;
border-right: 1px solid var(--hm-border);
white-space: nowrap; cursor: pointer;
color: var(--hm-muted); background: transparent;
min-width: 0; max-width: 180px;
border-bottom: 2px solid transparent;
flex-shrink: 0;
}
.editor-tab:hover { background: #21262d; color: #e6edf3; }
.editor-tab.active { color: #e6edf3; border-bottom-color: #58a6ff; background: #21262d; }
.editor-tab .tab-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; }
.editor-tab .tab-dirty { color: #e3b341; font-size: .9em; flex-shrink: 0; }
.editor-tab .tab-close {
flex-shrink: 0; opacity: .5; font-size: .7rem; line-height: 1;
padding: .1rem .2rem; border-radius: 3px; margin-left: .15rem;
}
.editor-tab .tab-close:hover { opacity: 1; background: rgba(255,255,255,.1); }
.editor-tab-content { flex: 1; overflow: hidden; min-height: 0; }
.editor-tab-content > div { height: 100%; }
/* File preview in pane */
.file-preview-pane {
display: flex; flex-direction: column; align-items: center;
justify-content: flex-start; padding: 1rem; height: 100%; overflow: auto;
}
.file-preview-pane img { max-width: 100%; max-height: calc(var(--hm-tab-height) - 80px); object-fit: contain; border-radius: 4px; }
.file-preview-pane embed { width: 100%; height: calc(var(--hm-tab-height) - 80px); border-radius: 4px; }
.file-preview-meta { font-size: .8rem; color: var(--hm-muted); margin-top: .75rem; text-align: center; }
/* Gallery lightbox */
.gallery-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,.9);
display: flex; align-items: center; justify-content: center;
cursor: zoom-out;
}
.gallery-overlay img { max-width: 90vw; max-height: 90vh; object-fit: contain; border-radius: 4px; }
.gallery-overlay .gallery-caption {
position: absolute; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
color: rgba(255,255,255,.7); font-size: .85rem;
}

BIN
web/assets/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

3236
web/assets/js/app.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
/**
* milkdown-mount.js auto-mount a Milkdown WYSIWYG editor on any
* <textarea class="mk-mount"> and keep it synced with the underlying
* textarea. Adapted from Agenda (/opt/agenda/web/lib/milkdown-mount.js)
* with English UI and HackmanCMS's dark theme.
*
* Relies on window.MilkdownKit being populated by the loader in view.php.
*
* data- attributes on the textarea:
* data-mk-min-height="20rem" override the editor's min-height
* data-mk-required empty content blocks form submit
*/
(function () {
'use strict';
// ── Link dialog ─────────────────────────────────────────────────────
var _linkModalEl = null;
function ensureLinkModal() {
if (_linkModalEl) return _linkModalEl;
var el = document.createElement('div');
el.className = 'modal fade';
el.tabIndex = -1;
el.setAttribute('aria-hidden', 'true');
el.innerHTML =
'<div class="modal-dialog modal-dialog-centered modal-sm">' +
'<div class="modal-content">' +
'<form class="ld-form">' +
'<div class="modal-header py-2">' +
'<h5 class="modal-title h6 mb-0"><i class="bi bi-link-45deg"></i> Link</h5>' +
'<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>' +
'</div>' +
'<div class="modal-body">' +
'<div class="mb-2">' +
'<label class="form-label small mb-1">URL</label>' +
'<input type="text" class="form-control form-control-sm ld-url" required ' +
'placeholder="https://… / /path / mailto:…">' +
'<div class="invalid-feedback small ld-url-warning"></div>' +
'</div>' +
'<div class="mb-1 ld-text-wrap">' +
'<label class="form-label small mb-1">Text <span class="text-muted">(optional)</span></label>' +
'<input type="text" class="form-control form-control-sm ld-text">' +
'</div>' +
'</div>' +
'<div class="modal-footer justify-content-between py-2">' +
'<button type="button" class="btn btn-sm btn-outline-danger ld-remove d-none">' +
'<i class="bi bi-trash"></i> Remove' +
'</button>' +
'<div class="ms-auto d-flex gap-2">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>' +
'<button type="submit" class="btn btn-sm btn-primary ld-confirm">Insert</button>' +
'</div>' +
'</div>' +
'</form>' +
'</div>' +
'</div>';
document.body.appendChild(el);
_linkModalEl = el;
return el;
}
function isPlausibleUrl(url) {
if (!url) return false;
return /^(https?:|mailto:|tel:|ftp:|#|\/|[\w.-]+\.[a-z]{2,})/i.test(url.trim());
}
window.LinkDialog = {
open: function (opts) {
opts = opts || {};
var el = ensureLinkModal();
var inst = bootstrap.Modal.getOrCreateInstance(el);
var form = el.querySelector('.ld-form');
var urlEl = el.querySelector('.ld-url');
var textEl = el.querySelector('.ld-text');
var textWrap = el.querySelector('.ld-text-wrap');
var removeBtn = el.querySelector('.ld-remove');
var warnEl = el.querySelector('.ld-url-warning');
var confirmBtn = el.querySelector('.ld-confirm');
urlEl.value = opts.url || '';
textEl.value = opts.text || '';
urlEl.classList.remove('is-invalid');
warnEl.textContent = '';
textWrap.classList.toggle('d-none', !!opts.urlOnly);
removeBtn.classList.toggle('d-none', !opts.canRemove);
confirmBtn.textContent = opts.canRemove ? 'Save' : 'Insert';
return new Promise(function (resolve) {
var done = false;
function cleanup(result) {
if (done) return; done = true;
form.removeEventListener('submit', onSubmit);
removeBtn.removeEventListener('click', onRemove);
el.removeEventListener('hidden.bs.modal', onHidden);
el.removeEventListener('shown.bs.modal', onShown);
inst.hide();
resolve(result);
}
function onSubmit(e) {
e.preventDefault();
var url = urlEl.value.trim();
if (!url) { urlEl.classList.add('is-invalid'); warnEl.textContent = 'URL required.'; return; }
if (!/^[a-z][a-z0-9+.-]*:/i.test(url) && !url.startsWith('/') && !url.startsWith('#')) {
if (/^[\w.-]+\.[a-z]{2,}/i.test(url)) url = 'https://' + url;
}
if (!isPlausibleUrl(url)) {
urlEl.classList.add('is-invalid'); warnEl.textContent = "Doesn't look like a valid URL.";
urlEl.addEventListener('input', function once() {
urlEl.classList.remove('is-invalid'); warnEl.textContent = '';
urlEl.removeEventListener('input', once);
});
return;
}
cleanup({ action: 'confirm', url: url, text: textEl.value.trim() });
}
function onRemove() { cleanup({ action: 'remove' }); }
function onHidden() { cleanup(null); }
function onShown() { urlEl.focus(); urlEl.select(); }
form.addEventListener('submit', onSubmit);
removeBtn.addEventListener('click', onRemove);
el.addEventListener('hidden.bs.modal', onHidden);
el.addEventListener('shown.bs.modal', onShown);
inst.show();
});
}
};
function mountAll() {
var kit = window.MilkdownKit;
if (!kit) return;
document.querySelectorAll('textarea.mk-mount:not([data-mk-mounted])').forEach(function (ta) {
ta.dataset.mkMounted = '1';
mountOne(ta, kit);
});
}
function mountOne(ta, kit) {
var form = ta.closest('form');
var wrap = document.createElement('div');
wrap.className = 'mk-mount-wrap';
var toolbar = document.createElement('div');
toolbar.className = 'ie-mk-toolbar';
toolbar.innerHTML =
'<div class="ie-mk-tools">' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bold" title="Bold"><i class="bi bi-type-bold"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="italic" title="Italic"><i class="bi bi-type-italic"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="strikethrough" title="Strikethrough"><i class="bi bi-type-strikethrough"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="inlineCode" title="Inline code"><i class="bi bi-code"></i></button>' +
'<span class="ie-mk-sep"></span>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="bullet" title="Bullet list"><i class="bi bi-list-ul"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="ordered" title="Ordered list"><i class="bi bi-list-ol"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="blockquote" title="Quote"><i class="bi bi-blockquote-left"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="codeBlock" title="Code block"><i class="bi bi-code-square"></i></button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary" data-cmd="link" title="Link"><i class="bi bi-link-45deg"></i></button>' +
'</div>' +
'<button type="button" class="btn btn-sm btn-outline-secondary mk-mode" title="Switch to Markdown source">' +
'<i class="bi bi-markdown"></i> MD' +
'</button>';
var body = document.createElement('div');
body.className = 'ie-mk-body';
// Only honour an explicit data-mk-min-height; otherwise let CSS size the
// body via the surrounding flex layout (so it actually fills the tab pane
// and lets the inner ProseMirror trigger overflow scroll).
var minH = ta.dataset.mkMinHeight;
if (minH) body.style.minHeight = minH;
var editorEl = document.createElement('div');
body.appendChild(editorEl);
var mdTa = document.createElement('textarea');
mdTa.className = 'form-control ie-mk-ta';
mdTa.rows = 4;
mdTa.style.display = 'none';
if (minH) mdTa.style.minHeight = minH;
body.appendChild(mdTa);
wrap.appendChild(toolbar);
wrap.appendChild(body);
var initialMd = ta.value;
ta.style.display = 'none';
var wasRequired = ta.hasAttribute('required');
if (wasRequired) { ta.removeAttribute('required'); ta.dataset.mkRequired = '1'; }
ta.parentNode.insertBefore(wrap, ta);
var mkEditor = null;
var mode = 'wysiwyg';
kit.Editor.make()
.config(function (ctx) {
ctx.set(kit.rootCtx, editorEl);
ctx.set(kit.defaultValueCtx, initialMd);
})
.use(kit.commonmark).use(kit.gfm).use(kit.history)
.create()
.then(function (ed) {
mkEditor = ed;
ta.dispatchEvent(new CustomEvent('mk-ready'));
})
.catch(function (err) { console.error('[mk-mount] init failed:', err); });
function getContent() {
if (mode === 'markdown') return mdTa.value;
if (mkEditor) return mkEditor.action(kit.getMarkdown());
return initialMd;
}
function setContent(md) {
if (mode === 'markdown') { mdTa.value = md; return; }
if (mkEditor) mkEditor.action(kit.replaceAll(md));
}
function insertMd(before, after) {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
mdTa.value = mdTa.value.substring(0, s) + before + sel + after + mdTa.value.substring(e);
mdTa.selectionStart = s + before.length;
mdTa.selectionEnd = s + before.length + sel.length;
mdTa.focus();
}
toolbar.querySelector('.ie-mk-tools').addEventListener('click', function (e) {
var btn = e.target.closest('[data-cmd]'); if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
if (mode === 'markdown') {
var md = {
bold: function () { insertMd('**', '**'); },
italic: function () { insertMd('*', '*'); },
strikethrough: function () { insertMd('~~', '~~'); },
inlineCode: function () { insertMd('`', '`'); },
bullet: function () { insertMd('- ', ''); },
ordered: function () { insertMd('1. ', ''); },
blockquote: function () { insertMd('> ', ''); },
codeBlock: function () { insertMd('```\n', '\n```'); },
link: function () {
var s = mdTa.selectionStart, e = mdTa.selectionEnd;
var sel = mdTa.value.substring(s, e);
window.LinkDialog.open({ text: sel }).then(function (r) {
if (!r || r.action !== 'confirm') { mdTa.focus(); return; }
var txt = r.text || r.url;
var out = '[' + txt + '](' + r.url + ')';
mdTa.value = mdTa.value.substring(0, s) + out + mdTa.value.substring(e);
mdTa.selectionStart = mdTa.selectionEnd = s + out.length;
mdTa.focus();
});
},
};
if (md[cmd]) md[cmd](); return;
}
if (!mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
var wy = {
bold: function () { exec(c.bold.key); },
italic: function () { exec(c.italic.key); },
strikethrough: function () { exec(c.strikethrough.key); },
inlineCode: function () { exec(c.inlineCode.key); },
bullet: function () { exec(c.bulletList.key); },
ordered: function () { exec(c.orderedList.key); },
blockquote: function () { exec(c.blockquote.key); },
codeBlock: function () { exec(c.codeBlock.key); },
link: function () {
window.LinkDialog.open({ urlOnly: true }).then(function (r) {
if (!r || r.action !== 'confirm') return;
exec(c.link.key, { href: r.url });
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
},
};
if (wy[cmd]) wy[cmd]();
var pm = editorEl.querySelector('.ProseMirror'); if (pm) pm.focus();
});
editorEl.addEventListener('click', function (evt) {
if (mode !== 'wysiwyg') return;
var a = evt.target.closest('a'); if (!a || !editorEl.contains(a)) return;
evt.preventDefault(); evt.stopPropagation();
var pm = editorEl.querySelector('.ProseMirror');
var href = a.getAttribute('href') || '', text = a.textContent || '';
function selectAnchor() {
if (!pm || !document.body.contains(a)) return false;
pm.focus();
var range = document.createRange(); range.selectNodeContents(a);
var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
return true;
}
selectAnchor();
window.LinkDialog.open({ url: href, text: text, canRemove: true, urlOnly: true }).then(function (r) {
if (!r) return;
if (!selectAnchor() || !mkEditor) return;
var c = kit.commands;
function exec(key, payload) { mkEditor.action(function (ctx) { ctx.get(kit.commandsCtx).call(key, payload); }); }
if (r.action === 'remove') { exec(c.link.key); }
else if (r.action === 'confirm') { exec(c.link.key); exec(c.link.key, { href: r.url }); }
if (pm) pm.focus();
});
});
function switchMkMode() {
if (mode === 'wysiwyg') {
mdTa.value = getContent();
editorEl.style.display = 'none';
mdTa.style.display = '';
mdTa.focus();
mode = 'markdown';
} else {
var md = mdTa.value;
mdTa.style.display = 'none';
editorEl.style.display = '';
if (mkEditor) mkEditor.action(kit.replaceAll(md));
mode = 'wysiwyg';
}
var modeBtn = toolbar.querySelector('.mk-mode');
if (modeBtn) modeBtn.innerHTML = mode === 'wysiwyg'
? '<i class="bi bi-markdown"></i> MD'
: '<i class="bi bi-eye"></i> WYSIWYG';
}
toolbar.querySelector('.mk-mode').addEventListener('click', switchMkMode);
// Track dirty changes
editorEl.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
}, true);
mdTa.addEventListener('input', function () {
if (typeof ta.onMkInput === 'function') ta.onMkInput();
});
if (form) {
form.addEventListener('submit', function (e) {
var md = getContent().trim();
if (ta.dataset.mkRequired && md === '') {
e.preventDefault();
var pm = editorEl.querySelector('.ProseMirror');
if (pm) pm.focus(); else mdTa.focus();
return;
}
ta.value = md;
});
}
ta.mkMount = {
getContent: getContent,
setContent: setContent,
getMode: function () { return mode; },
setMode: function (target) {
// target: 'wysiwyg' | 'markdown'
if (target !== 'wysiwyg' && target !== 'markdown') return;
if (mode !== target) switchMkMode();
},
};
ta.dispatchEvent(new CustomEvent('mk-mounted'));
}
if (window.MilkdownKit) mountAll();
else window.addEventListener('milkdown-ready', mountAll);
// Re-scan whenever new mk-mount textareas get inserted (e.g. opening a new tab)
var moObserver = new MutationObserver(mountAll);
moObserver.observe(document.body, { childList: true, subtree: true });
})();

48
web/index.php Normal file
View file

@ -0,0 +1,48 @@
<?php
define('ROOT', dirname(__DIR__));
require ROOT . '/lib/bootstrap.php';
$uri = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') ?: '/';
// Public routes — no auth required
if ($uri === '/login') {
if (Auth::check()) { header('Location: /'); exit; }
include ROOT . '/views/login.php';
exit;
}
if ($uri === '/api/auth') {
include ROOT . '/web/api/auth.php';
exit;
}
// All other routes require login
Auth::requireLogin();
ProjectTypes::load();
if ($uri === '/' || $uri === '/dashboard') {
include ROOT . '/views/dashboard.php';
} elseif ($uri === '/settings') {
include ROOT . '/views/settings.php';
} elseif ($uri === '/audit') {
include ROOT . '/views/audit.php';
} elseif (preg_match('#^/project/(\d+)$#', $uri, $m)) {
$project_id = (int)$m[1];
include ROOT . '/views/project/view.php';
} elseif (preg_match('#^/api/([a-z_]+)#', $uri, $m)) {
$api_file = ROOT . '/web/api/' . $m[1] . '.php';
if (file_exists($api_file)) {
include $api_file;
} else {
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'API endpoint not found']);
}
} else {
http_response_code(404);
include ROOT . '/views/error.php';
}