commit 60ca58f5baee61405134b013b26f411775ef51d4 Author: Bashy Date: Sun May 3 20:56:15 2026 +0300 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8093fa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Local SQLite database + WAL/SHM files +data/*.sqlite +data/*.sqlite-shm +data/*.sqlite-wal + +# Generated by deploy scripts — version/sha/branch/built fingerprint for the footer +web/BUILD + +# Local config overrides (never commit secrets) +config/config.local.php +.env +.env.local + +# Logs +*.log +logs/ + +# Editor / IDE +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Composer (if/when used) +vendor/ +composer.lock + +# npm (if a project type ever needs it locally) +node_modules/ + +# PHP-CGI temp / coverage +.phpunit.result.cache +.phpunit.cache/ + +# Backups produced locally by the backup endpoint when invoked from CLI +*.bak +*.zip diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0409ded --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,374 @@ +# HackmanCMS + +Web UI for managing Hexo sites and other server-side projects. + +## Stack + +- PHP 8.0+, no framework +- SQLite via PDO (`data/hackmancms.sqlite`) +- Bootstrap 5.3 dark theme + Bootstrap Icons +- Vanilla JS (no jQuery) + +## Directory layout + +``` +web/ Apache document root (index.php front controller) +web/api/ JSON API endpoints (included by front controller) +web/assets/ app.css, app.js (vanilla, no bundler) +lib/ PHP classes loaded via bootstrap.php +lib/project-types/ one file per project type +views/ PHP templates (_header.php + _footer.php wrap each page) +views/project/ project page + _tab_*.php partials (one per tab) +sql/ numbered migration files (001_, 002_, …) +bin/ migrate.php, deploy.sh, run-schedules.php +config/ config.php +data/ SQLite database (gitignored) +deploylocal.sh local rsync deploy with chown www-data + apache reload +``` + +## Routing + +All requests go through `web/index.php`. Routes match URI paths with preg_match. +**Public routes** (no session required): `/login`, `/api/auth`, `/api/track`. +Everything else requires a valid session. + +The site-visit tracker (`/api/track`) must be reachable from third-party browsers +hitting managed Hexo sites — that's why it's whitelisted alongside `/api/auth`. + +## Versioning + +Same pattern as `/opt/agenda` so the footer markup is identical: + +- `VERSION` at the repo root holds **MAJOR.MINOR** only (e.g. `0.3`). +- The deploy script (`deploylocal.sh` and `bin/deploy.sh`) computes a patch + number = git commit count since the last touch of `VERSION`, so bumping + the file resets to `.0` and each commit auto-increments. +- The deploy script writes `web/BUILD` (gitignored) with key=value lines: + ``` + version=0.3.42 + sha=ab12cd3 + branch=main + built=2026-05-03 14:22:11 + ``` +- `lib/bootstrap.php::buildInfo()` reads `web/BUILD` (cached static); falls + back to `version=dev`, empty sha/branch/built when the file is missing. +- `views/_footer.php` renders `HackmanCMS v{version} · {sha} · build {built}` + matching Agenda's layout. + +Bump `VERSION` whenever you cut a release; redeploy and the footer updates +on the next request. + +## Adding a project type + +1. Create `lib/project-types/MyType.php` +2. Extend `ProjectTypeBase`, implement `typeSlug()`, `typeName()`, `typeIcon()` +3. Override `tabs()`, `commands()`, `detectFromPath()` as needed +4. No registration — auto-discovered via `get_declared_classes()` on boot + +`ProjectTypeBase::tabs()` returns `['overview', 'files', 'recent', 'notes', 'settings']` by +default — every type that doesn't override gets those four universal tabs. + +### Tab IDs + +Universal (in default `ProjectTypeBase::tabs()`): `dashboard`, `analytics`, `files`, `notes`, `settings`. + +Hexo-only (in `HexoProject::tabs()`): `posts`, `config`, `run`, `themes`, `plugins`, `git`. + +Storage-only: `media`. + +Tag/category cloud lives **inside** the per-project Dashboard tab — clicking +a tag links to `?tab=posts&filter=tag:` (or `category:`), +and the posts panel reads that query param to filter the list. The link-checker +report lives in the Settings tab. Recent files are also rendered in the +project Dashboard tab. Analytics has its own tab. + +Each tab string maps 1:1 to a ` + ServerName hackmancms.bashynx.com + DocumentRoot /opt/hackmancms/web + + AllowOverride All + Require all granted + + +``` + +The `.htaccess` in `web/` handles rewrite rules — `mod_rewrite` must be enabled. + +## Local dev (bashyMint) + +```bash +./bin/deploy.sh local # rsync to /var/www/hackmancms, run migrate +``` + +Apache vhost locally: + +```apache + + ServerName hackmancms.local + DocumentRoot /var/www/hackmancms/web + + AllowOverride All + Require all granted + + +``` + +Add `127.0.0.1 hackmancms.local` to `/etc/hosts`. + +## Scheduled builds + +`bin/run-schedules.php` is a cron-driven dispatcher: it iterates `scheduled_builds`, +matches each row's 5-field cron expression against the current minute, and runs any +that are due via the project type's whitelisted commands. Output goes to +`command_history`; `last_run_at` / `last_status` are updated on the schedule row. + +It only fires if a host cron entry runs it every minute. As www-data on the box that +serves the app: + +```bash +echo "* * * * * /usr/bin/php /opt/hackmancms/bin/run-schedules.php >/dev/null 2>&1" \ + | sudo crontab -u www-data - +``` + +UI for managing schedules lives in the Settings tab of any Hexo project (see +`views/project/_tab_settings.php` → "Scheduled builds" section). + +## Audit log + +`audit_log` is the source of truth for the dashboard activity feed and `/audit`. +Write entries via `Audit::log($db, $action, $project_id = null, $detail = null)` — +the helper swallows DB exceptions so a logging failure can't break the calling op. + +**Convention: every state-changing action gets logged.** The only deliberate exception +is the per-project scratchpad (auto-saves several times per minute would flood the +feed; `recent_files` covers "what was the user touching" already). + +Currently logged action strings: + +| Source | Actions | +|------------------------|--------------------------------------------------------------------------------------------------------| +| `web/api/git.php` | `git_stage`, `git_unstage`, `git_discard`, `git_pull`, `git_push`, `git_fetch`, `git_commit` (detail=message), `git_merge` (detail=branch), `git_reset` | +| `web/api/backup.php` | `backup_download` | +| `web/api/links.php` | `link_scan` (detail=`broken=N of M`) | +| `web/api/themes.php` | `theme_switch`, `theme_clone`, `theme_delete`, `theme_git_pull`, `theme_git_push`, `theme_git_fetch` (detail=theme name) | +| `web/api/plugins.php` | `plugin_install`, `plugin_uninstall` (detail=package name) | +| `web/api/files.php` | `file_write`, `file_delete` (detail=relative path) | +| `web/api/upload.php` | `file_upload` (detail=relative path) | +| `web/api/posts.php` | `post_create`, `post_delete`, `post_publish`, `post_duplicate` (detail=path) | +| `web/api/drafts.php` | `draft_create`, `draft_update`, `draft_delete` (detail=title), `draft_publish` (detail=published path) | +| `web/api/run.php` | `command_run` (detail=`cmd_id exit=N`) | +| `web/api/projects.php` | `project_add`, `project_delete`, `project_rename`, `project_type_change`, `project_pin`, `project_unpin`, `project_setting` (detail=key), `scan_path_add`, `scan_path_delete` | +| `web/api/schedules.php`| `schedule_create`, `schedule_update`, `schedule_delete` | +| `web/api/templates.php`| `template_create`, `template_update`, `template_delete` (detail=name) | +| `web/api/snippets.php` | `snippet_create`, `snippet_update`, `snippet_delete` (detail=name) | +| `bin/run-schedules.php`| `scheduled_build` (detail=`cmd_id status=...`) | + +The activity feed renderer (`#activityFeed` in `app.js`) maps these to labels + icons +in `ACTION_LABELS` / `ACTION_ICONS`. Unmapped actions still render — they just show the +raw string and a fallback circle icon. **When adding a new logged action, also add it +to both maps in `app.js`.** + +## Site visit analytics + +Analytics is **server-log based**, not pixel based. HackmanCMS tails the web +server's access log, parses each line, and inserts rows into `site_visits`. +This means zero footprint on the managed Hexo site — no JS, no pixel, no +client-side change required. + +**Per-project Analytics → "Server-log import setup"** captures the import +config; the importer + cull track their own state. Keys in `project_settings`: + +| Key | Purpose | +|---------------------------|------------------------------------------------------------------| +| `analytics_log_path` | absolute path to the access log (e.g. `/var/log/apache2/foo_access.log`) | +| `analytics_log_format` | `combined` (Apache) or `nginx` — same field layout for our parser | +| `analytics_log_filter` | optional URL-path prefix; lines whose path doesn't start with it are skipped | +| `analytics_last_size` | byte cursor — last position read; reset on rotation | +| `analytics_last_inode` | inode of the file at last read; mismatch ⇒ rotation detected | +| `analytics_imported_at` | last import timestamp (display only) | +| `analytics_imported_count`| running total of rows imported (display only) | +| `analytics_last_rollup` | last time the rollup + prune ran (throttled to once / 24h) | + +`bin/import-site-logs.php`: +- Iterates active projects (or one with `--project=N`) +- Opens each project's log, seeks to last byte cursor +- Parses Combined Log Format (works for nginx default too) +- Drops asset hits (`.css/.js/.png/...`), non-GETs, and obvious bots (`bot`, `curl`, `wget`, `headless`, ...). Keeps 2xx + 3xx + **404** so the analytics tab can surface broken-path hits. +- Hashes UA + IP with a fixed salt (truncated SHA-256, 16 chars) so we can count uniques without retaining raw values +- Inserts into `site_visits` (with `status` column tracked from the log line) and audit-logs as `analytics_import` +- **Runs the tiered cull** at the end of each project's import (no-op if <24h since last cull) + +**Tiered rollup pipeline** (`maybeRollupAndPrune()` in the importer): + +| Age window | Storage | What's preserved | +|------------------|----------------------------------|-----------------------------------| +| today | `site_visits` (raw events) | full sub-hour timestamps | +| 1–90 days | `site_visits` (raw) + rollups | full timestamps, plus rollups | +| 91–365 days | `site_visits_hourly` + `_daily` | hour-granular path/status/referrer | +| > 365 days | `site_visits_daily` only | day-granular path/status/referrer | + +Rollups are built nightly (throttled to once per project per 24h) by +aggregating raw rows GROUP BY (hour or day, path, status, referrer). The +INSERT OR REPLACE on the rollup tables' UNIQUE constraint makes the rollup +**idempotent** — re-rolling a day produces the same rows. + +Aging-out drops raw rows >90d and hourly rows >365d. By the time a row is +dropped, the equivalent aggregate is already in the next tier — no count +information is lost. Audit-logged as `analytics_rollup`. + +**Daily-rotating salt for IP hashes.** The importer hashes IPs with +`base_salt + visit_date`, so the same IP gets a different `ip_hash` on +different days. Within a day, distinct counts are exact; across days, +visitors look like new visitors. This makes the stored data anonymized +rather than pseudonymized for GDPR purposes — once the salt has rotated +past, no one (including the controller) can re-link yesterday's hashes to +today's visits. Trade-off: "unique visitors over multiple days" is the +sum of per-day unique counts (each visitor counted once per day they +visited), not deduplicated across days. UI surfaces this in a tooltip. + +**Cron entry** (run on the box hosting both HackmanCMS and the web server, as a +user with read access to the log files — typically root or a member of `adm`): + +```bash +*/5 * * * * /usr/bin/php /opt/hackmancms/bin/import-site-logs.php +``` + +`web/api/analytics_import.php` (auth required) is the same code path with +three actions: `run` (the "Import now" button), `reset` (clear cursors so +the next run reimports from start), `wipe` (drop all visits + cursors). + +`web/api/analytics.php` aggregates over a configurable window (7/30/90/365 +days) and returns: window + previous-window totals (for delta KPIs), +all-time totals, top pages, top referrers, daily series (current + previous +period for chart overlay), hour-of-day distribution, top 404s, and +status-code mix. + +**Note:** `web/api/track.php` (the old 1×1 pixel endpoint) is no longer wired +into the public route table in `index.php`. The file is left in place as a +dormant fallback for cases where the managed site is *not* on the same box — +re-add the whitelist line in `index.php` to bring it back. + +## Markdown editor (Milkdown) + +`*.md` and `*.markdown` files open in a **Milkdown** WYSIWYG editor mounted +via the Agenda-style `mk-mount.js` pattern. Milkdown is loaded as ESM from +`esm.sh`'s pre-compiled `/es2022/` paths — same trick Agenda uses to keep +all `@milkdown/*` sub-packages on a single shared `core` instance (otherwise +ProseMirror's `SchemaReady` timer fails). The loader lives in `view.php`: + +```html + + +``` + +`mk-mount.js` auto-mounts on any ` + diff --git a/views/project/_tab_plugins.php b/views/project/_tab_plugins.php new file mode 100644 index 0000000..d6bfedf --- /dev/null +++ b/views/project/_tab_plugins.php @@ -0,0 +1,27 @@ +
+
+ + + Hexo plugins listed from package.json. Install/uninstall runs npm. + +
+ +
+
+
+ + + npm install --save +
+
+
+
+ +
+
Loading…
+
+
diff --git a/views/project/_tab_recent.php b/views/project/_tab_recent.php new file mode 100644 index 0000000..bbc19a2 --- /dev/null +++ b/views/project/_tab_recent.php @@ -0,0 +1,12 @@ +
+
+ + Recently opened or saved files in this project. + +
+
+
Loading…
+
+
diff --git a/views/project/_tab_search.php b/views/project/_tab_search.php new file mode 100644 index 0000000..531f282 --- /dev/null +++ b/views/project/_tab_search.php @@ -0,0 +1,9 @@ +
+
+ + + +
+
+
diff --git a/views/project/_tab_settings.php b/views/project/_tab_settings.php new file mode 100644 index 0000000..c65c8fd --- /dev/null +++ b/views/project/_tab_settings.php @@ -0,0 +1,231 @@ +prepare('SELECT key, value FROM project_settings WHERE project_id = ?'); +$psStmt->execute([$pid]); +$pSettings = []; +foreach ($psStmt->fetchAll() as $row) { + $pSettings[$row['key']] = $row['value']; +} +?> +prepare('SELECT * FROM scheduled_builds WHERE project_id = ? ORDER BY id'); + $sStmt->execute([$pid]); + $schedules = $sStmt->fetchAll(); +} +?> +
+ + +
Project
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+ + +
Broken link checker
+

+ Scans posts/pages for HTTP/HTTPS links (and internal links if a project URL is set) + and reports any that are unreachable. +

+
+
+ +
+ + +
+ +
+
+
+ + +
+ + +
Backup & export
+

+ Download a zip of the project source. + Excludes node_modules, public, and .git. +

+ + Download backup (.zip) + + + +
+ + +
Scheduled builds
+

+ Run a project command on a cron schedule. Output is saved to command history. + Requires the system cron entry to be installed (see docs/scheduled-builds.md). +

+
+ +
No schedules yet.
+ +
+
+ > +
+ + + + + last: + () + + never run + + + + +
+ +
+ + +
+ + +
Page directories
+

+ Subdirectories of source/ scanned for pages, one per line. + Leave empty to scan only source/*.md. +

+ + + +
+ + +
Post Templates
+

+ Full post file content (front matter + body). Applied when creating a new post. +

+
+ + +
+ + +
Snippets
+

+ Reusable markdown fragments. Insert into post body via the snippet picker. +

+
+ + + +
+ + + + + + + + diff --git a/views/project/_tab_tags.php b/views/project/_tab_tags.php new file mode 100644 index 0000000..a3eb7d3 --- /dev/null +++ b/views/project/_tab_tags.php @@ -0,0 +1,19 @@ +
+
Loading…
+
+
+
+
+ Tags 0 +
+
+
+
+
+ Categories 0 +
+
+
+
+
+
diff --git a/views/project/_tab_themes.php b/views/project/_tab_themes.php new file mode 100644 index 0000000..ceef1a2 --- /dev/null +++ b/views/project/_tab_themes.php @@ -0,0 +1,46 @@ +
+
+ + + Manage Hexo themes installed under themes/. Switching writes to _config.yml. + + +
+ +
+
Loading…
+
+
+ + + diff --git a/views/project/view.php b/views/project/view.php new file mode 100644 index 0000000..c279db6 --- /dev/null +++ b/views/project/view.php @@ -0,0 +1,431 @@ +prepare('SELECT * FROM projects WHERE id = ?'); +$stmt->execute([$project_id]); +$project = $stmt->fetch(); +if (!$project) { http_response_code(404); include ROOT . '/views/error.php'; exit; } + +$type = ProjectTypes::get($project['type']); +$tabs = $type ? $type::tabs() : ['files']; +$isHexo = $project['type'] === 'hexo'; +$isStorage = $project['type'] === 'storage'; +$pid = (int)$project['id']; + +// Remove 'git' tab if no git repo found at project root or one level deep +if (in_array('git', $tabs)) { + $projectPath = $project['path']; + $hasGit = is_dir($projectPath . '/.git'); + if (!$hasGit) { + foreach (@scandir($projectPath) ?: [] as $item) { + if ($item[0] === '.') continue; + if (is_dir($projectPath . '/' . $item . '/.git')) { $hasGit = true; break; } + } + } + if (!$hasGit) $tabs = array_values(array_diff($tabs, ['git'])); +} + +$tab = $_GET['tab'] ?? $tabs[0]; +if (!in_array($tab, $tabs)) $tab = $tabs[0]; + +$page_title = $project['name']; +$nav_active = ''; +include ROOT . '/views/_header.php'; +?> + + 'Dashboard', 'analytics' => 'Analytics', + 'posts' => 'Posts', 'config' => 'Config', + 'files' => 'Files', 'media' => 'Media', + 'run' => 'Run', 'themes' => 'Themes', + 'plugins' => 'Plugins', 'git' => 'Git', + 'notes' => 'Notes', 'settings' => 'Settings', +]; +$tabIcons = [ + 'dashboard' => 'bi-grid-1x2', 'analytics' => 'bi-graph-up', + 'posts' => 'bi-file-earmark-text','config' => 'bi-sliders', + 'files' => 'bi-folder2', 'media' => 'bi-images', + 'run' => 'bi-terminal', 'themes' => 'bi-palette', + 'plugins' => 'bi-puzzle', 'git' => 'bi-git', + 'notes' => 'bi-sticky', 'settings' => 'bi-gear', +]; +$tabGroups = [ + ['dashboard', 'analytics'], + ['posts', 'config', 'files', 'media'], + ['run', 'themes', 'plugins', 'git'], + ['notes', 'settings'], +]; +?> + + +
+ + +
+ + + +
+
+
+ + + + +
+ + +
+
+
+
+
+ +
+
+
+
+ + Select a post to edit +
+
+
+
+ + + +
+
+
+ + +
+
+
Loading…
+
+
+
+
+
+
+ + Select a file to edit +
+
+
+
+ + + +
+
+
+
+
Commands
+
+ + + +
+
+
+
+
+
+ Output +
+ + +
+
+
+

+        
+
+
+
+
+ + + +
+ +
+
Loading…
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +'; +include ROOT . '/views/_footer.php'; +?> diff --git a/views/settings.php b/views/settings.php new file mode 100644 index 0000000..3e15b00 --- /dev/null +++ b/views/settings.php @@ -0,0 +1,84 @@ +query('SELECT * FROM scan_paths ORDER BY path')->fetchAll(); +?> +

Settings

+ +
+
+
+
Project discovery
+
+

+ Directories to scan. HackmanCMS detects project types automatically by looking for marker files + (_config.yml → Hexo, index.php → Website, etc.). +

+ +
    + +
  • No scan paths configured yet.
  • + + +
  • + + depth + + +
  • + +
+ +
+ + + +
+
+
+
+ +
+
+
Project types
+
+

+ Drop a PHP file extending ProjectTypeBase into + lib/project-types/ to register a new type — no config needed. +

+
    + $class): ?> +
  • + + + + + + +
  • + +
+
+
+
+
+ +
+
+
Scan results
+ +
+
+
+ + diff --git a/web/.htaccess b/web/.htaccess new file mode 100644 index 0000000..66ef8f6 --- /dev/null +++ b/web/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^ index.php [QSA,L] diff --git a/web/api/analytics.php b/web/api/analytics.php new file mode 100644 index 0000000..c9079bd --- /dev/null +++ b/web/api/analytics.php @@ -0,0 +1,187 @@ +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', + ], +]); diff --git a/web/api/analytics_import.php b/web/api/analytics_import.php new file mode 100644 index 0000000..0890721 --- /dev/null +++ b/web/api/analytics_import.php @@ -0,0 +1,39 @@ +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]); diff --git a/web/api/audit.php b/web/api/audit.php new file mode 100644 index 0000000..0e48c86 --- /dev/null +++ b/web/api/audit.php @@ -0,0 +1,34 @@ +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, +]); diff --git a/web/api/auth.php b/web/api/auth.php new file mode 100644 index 0000000..04b068b --- /dev/null +++ b/web/api/auth.php @@ -0,0 +1,48 @@ +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); diff --git a/web/api/disk.php b/web/api/disk.php new file mode 100644 index 0000000..b36922d --- /dev/null +++ b/web/api/disk.php @@ -0,0 +1,46 @@ + []]); 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'; +} diff --git a/web/api/drafts.php b/web/api/drafts.php new file mode 100644 index 0000000..32c0c0b --- /dev/null +++ b/web/api/drafts.php @@ -0,0 +1,111 @@ +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(); +} diff --git a/web/api/files.php b/web/api/files.php new file mode 100644 index 0000000..6b13363 --- /dev/null +++ b/web/api/files.php @@ -0,0 +1,122 @@ +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]); diff --git a/web/api/git.php b/web/api/git.php new file mode 100644 index 0000000..f02fa9a --- /dev/null +++ b/web/api/git.php @@ -0,0 +1,221 @@ +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'); diff --git a/web/api/links.php b/web/api/links.php new file mode 100644 index 0000000..343bda3 --- /dev/null +++ b/web/api/links.php @@ -0,0 +1,204 @@ +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]; +} diff --git a/web/api/plugins.php b/web/api/plugins.php new file mode 100644 index 0000000..adcd0b1 --- /dev/null +++ b/web/api/plugins.php @@ -0,0 +1,87 @@ +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, + ]; +} diff --git a/web/api/posts.php b/web/api/posts.php new file mode 100644 index 0000000..fd034ea --- /dev/null +++ b/web/api/posts.php @@ -0,0 +1,306 @@ +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(); +} diff --git a/web/api/projects.php b/web/api/projects.php new file mode 100644 index 0000000..73311bc --- /dev/null +++ b/web/api/projects.php @@ -0,0 +1,146 @@ +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); + } +} diff --git a/web/api/recent.php b/web/api/recent.php new file mode 100644 index 0000000..15b15bf --- /dev/null +++ b/web/api/recent.php @@ -0,0 +1,72 @@ +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]); diff --git a/web/api/run.php b/web/api/run.php new file mode 100644 index 0000000..1ce1018 --- /dev/null +++ b/web/api/run.php @@ -0,0 +1,62 @@ +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'], +]); diff --git a/web/api/schedules.php b/web/api/schedules.php new file mode 100644 index 0000000..9af121b --- /dev/null +++ b/web/api/schedules.php @@ -0,0 +1,68 @@ +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']); diff --git a/web/api/scratchpad.php b/web/api/scratchpad.php new file mode 100644 index 0000000..dec369c --- /dev/null +++ b/web/api/scratchpad.php @@ -0,0 +1,26 @@ +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']); diff --git a/web/api/search.php b/web/api/search.php new file mode 100644 index 0000000..9ba6cc1 --- /dev/null +++ b/web/api/search.php @@ -0,0 +1,32 @@ + []]); 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]); diff --git a/web/api/snippets.php b/web/api/snippets.php new file mode 100644 index 0000000..4baa3ef --- /dev/null +++ b/web/api/snippets.php @@ -0,0 +1,58 @@ +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']); diff --git a/web/api/tags.php b/web/api/tags.php new file mode 100644 index 0000000..c8177f4 --- /dev/null +++ b/web/api/tags.php @@ -0,0 +1,58 @@ +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'); +} diff --git a/web/api/templates.php b/web/api/templates.php new file mode 100644 index 0000000..c9a7490 --- /dev/null +++ b/web/api/templates.php @@ -0,0 +1,59 @@ +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']); diff --git a/web/api/themes.php b/web/api/themes.php new file mode 100644 index 0000000..e214258 --- /dev/null +++ b/web/api/themes.php @@ -0,0 +1,154 @@ +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]); diff --git a/web/api/track.php b/web/api/track.php new file mode 100644 index 0000000..febc419 --- /dev/null +++ b/web/api/track.php @@ -0,0 +1,33 @@ +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=' +); diff --git a/web/api/upload.php b/web/api/upload.php new file mode 100644 index 0000000..d470826 --- /dev/null +++ b/web/api/upload.php @@ -0,0 +1,110 @@ + '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); +} diff --git a/web/assets/css/app.css b/web/assets/css/app.css new file mode 100644 index 0000000..d34bd78 --- /dev/null +++ b/web/assets/css/app.css @@ -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; +} + diff --git a/web/assets/img/logo.png b/web/assets/img/logo.png new file mode 100644 index 0000000..5f53684 Binary files /dev/null and b/web/assets/img/logo.png differ diff --git a/web/assets/js/app.js b/web/assets/js/app.js new file mode 100644 index 0000000..02e9783 --- /dev/null +++ b/web/assets/js/app.js @@ -0,0 +1,3236 @@ +'use strict'; + +// ── Toast / error helpers ───────────────────────────────────────────────────── +function showToast(msg, type = 'secondary') { + let box = document.getElementById('toastContainer'); + if (!box) { + box = document.createElement('div'); + box.id = 'toastContainer'; + box.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:9999;min-width:260px'; + document.body.appendChild(box); + } + const t = document.createElement('div'); + t.className = `toast align-items-center text-bg-${type} border-0 show mb-2`; + t.setAttribute('role', 'alert'); + t.innerHTML = `
${esc(msg)}
+
`; + box.appendChild(t); + bootstrap.Toast.getOrCreateInstance(t, { delay: 4000 }).show(); + t.addEventListener('hidden.bs.toast', () => t.remove()); +} +const showError = msg => showToast(msg, 'danger'); +const showSuccess = msg => showToast(msg, 'success'); + +// ── Confirm modal (no browser dialogs) ─────────────────────────────────────── +// The modal is a single shared element. Earlier versions left the previous +// confirm-handler attached when the user cancelled, so a second call would +// add a *second* listener — clicking Confirm then ran the cancelled call's +// callback. This wires both confirm and the modal's hidden event each time +// and tears them down regardless of how the modal closes. +function confirmAction(msg, cb) { + let modal = document.getElementById('_confirmModal'); + if (!modal) { + modal = document.createElement('div'); + modal.className = 'modal fade'; modal.id = '_confirmModal'; + modal.innerHTML = ``; + document.body.appendChild(modal); + } + document.getElementById('_confirmMsg').textContent = msg; + const bsM = bootstrap.Modal.getOrCreateInstance(modal); + const ok = document.getElementById('_confirmOk'); + + let confirmed = false; + const onConfirm = () => { confirmed = true; bsM.hide(); }; + const onHidden = () => { + ok.removeEventListener('click', onConfirm); + modal.removeEventListener('hidden.bs.modal', onHidden); + if (confirmed) cb(); + }; + ok.addEventListener('click', onConfirm); + modal.addEventListener('hidden.bs.modal', onHidden); + bsM.show(); +} + +// ── Utilities ───────────────────────────────────────────────────────────────── +function esc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} +function fmtSize(b) { + if (b == null) return ''; + if (b < 1024) return b + ' B'; + if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; + return (b / 1024 / 1024).toFixed(1) + ' MB'; +} +function modeForFile(name) { + const ext = (name.split('.').pop() || '').toLowerCase(); + return { js:'javascript', ts:'javascript', json:'javascript', css:'css', + php:'php', html:'htmlmixed', htm:'htmlmixed', xml:'xml', svg:'xml', + md:'markdown', markdown:'markdown', yml:'yaml', yaml:'yaml', + sh:'shell', bash:'shell' }[ext] || null; +} +function isImage(name) { return /\.(jpe?g|png|gif|webp|svg)$/i.test(name); } +function isVideo(name) { return /\.(mp4|webm|mov)$/i.test(name); } +function isAudio(name) { return /\.(mp3|wav|ogg|flac)$/i.test(name); } + +// ── Dashboard: add project ──────────────────────────────────────────────────── +const addProjectForm = document.getElementById('addProjectForm'); +if (addProjectForm) { + addProjectForm.addEventListener('submit', async e => { + e.preventDefault(); + const err = document.getElementById('addProjectError'); + err.classList.add('d-none'); + const data = Object.fromEntries(new FormData(addProjectForm)); + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data), + }); + const json = await res.json(); + if (json.ok) { location.reload(); } + else { err.textContent = json.error || 'Error'; err.classList.remove('d-none'); } + }); +} + +// ── Project: delete ─────────────────────────────────────────────────────────── +const confirmDelete = document.getElementById('confirmDelete'); +if (confirmDelete) { + confirmDelete.addEventListener('click', async () => { + const id = parseInt(confirmDelete.dataset.projectId); + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', id }), + }); + const json = await res.json(); + if (json.ok) location.href = '/'; + else showError(json.error || 'Error'); + }); +} + +// ── File editor — tabbed pane system ───────────────────────────────────────── +// Falls back to modal when no tab bar exists (e.g. posts pane) + +let _tabCounter = 0; +const _tabs = []; // [{id, pid, path, name, kind}] kind: 'editor'|'preview' +let _activeTabId = null; +const _tabCMs = {}; // id → CodeMirror instance + +function _isMediaFile(name) { + return /\.(jpe?g|png|gif|webp|svg|mp4|webm|mp3|wav|ogg|flac|pdf)$/i.test(name); +} + +function _mediaKind(name) { + if (/\.(jpe?g|png|gif|webp|svg)$/i.test(name)) return 'image'; + if (/\.(mp4|webm)$/i.test(name)) return 'video'; + if (/\.(mp3|wav|ogg|flac)$/i.test(name)) return 'audio'; + if (/\.pdf$/i.test(name)) return 'pdf'; + return null; +} + +function openFileEditor(pid, path, name) { + const tabBar = document.getElementById('editorTabBar'); + const tabContent = document.getElementById('editorTabContent'); + const browser = document.getElementById('fileBrowser'); + const isStorage = browser?.dataset.projectType === 'storage'; + + // ── Files tab: full tab system ──────────────────────────────────────────── + if (tabBar && tabContent) { + // Storage images → gallery overlay instead of pane preview + if (isStorage && _mediaKind(name) === 'image') { + _showGalleryOverlay(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`, name); + return; + } + + // Already open? Switch to it. + const existing = _tabs.find(t => t.pid === pid && t.path === path); + if (existing) { _switchTab(existing.id); return; } + + // Media preview tab + if (_isMediaFile(name)) { + const id = 'tab' + (++_tabCounter); + _tabs.push({ id, pid, path, name, kind: 'preview' }); + _renderTabBar(); + const div = document.createElement('div'); + div.id = 'tc-' + id; + div.style.display = 'none'; + div.style.height = '100%'; + div.innerHTML = _buildPreviewHTML(pid, path, name); + tabContent.appendChild(div); + _switchTab(id); + return; + } + + // Text editor tab + fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) + .then(r => r.json()) + .then(data => { + if (data.error) { showError(data.error); return; } + const isMd = /\.(md|markdown)$/i.test(name); + const isDraft = path.startsWith('source/_drafts/'); + if (isMd) { _openMdEditorTab(pid, path, name, data.content ?? '', isDraft); return; } + + const id = 'tab' + (++_tabCounter); + _tabs.push({ id, pid, path, name, kind: 'editor', isDraft, dirty: false }); + _renderTabBar(); + const div = document.createElement('div'); + div.id = 'tc-' + id; + div.style.display = 'none'; + div.style.height = '100%'; + div.style.position = 'relative'; + div.innerHTML = ` +
+ `; + tabContent.appendChild(div); + const cm = CodeMirror(div.querySelector('#paneCm-' + id), { + value: data.content ?? '', mode: modeForFile(name), theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + extraKeys: { 'Ctrl-S': () => _paneTabSave(id), 'Cmd-S': () => _paneTabSave(id) }, + }); + cm.on('change', () => _markDirty(id)); + _tabCMs[id] = cm; + div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id)); + // Ctrl/Cmd+S anywhere in the pane saves. + div.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + _paneTabSave(id); + } + }); + _switchTab(id); + }) + .catch(err => showError('Error: ' + err.message)); + return; + } + + // ── Fallback: modal ─────────────────────────────────────────────────────── + fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) + .then(r => r.json()) + .then(data => { + if (data.error) { showError(data.error); return; } + _renderModalEditor(pid, path, name, data.content ?? ''); + }) + .catch(err => showError('Error: ' + err.message)); +} + +function _buildPreviewHTML(pid, path, name) { + const src = `/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`; + const mk = _mediaKind(name); + if (mk === 'image') return `
${esc(name)}

${esc(name)}

`; + if (mk === 'pdf') return `

${esc(name)}

`; + if (mk === 'video') return `

${esc(name)}

`; + if (mk === 'audio') return `

${esc(name)}

`; + return `

${esc(name)}

`; +} + +function _showGalleryOverlay(src, name) { + let ov = document.getElementById('_galleryOverlay'); + if (!ov) { + ov = document.createElement('div'); + ov.id = '_galleryOverlay'; + ov.className = 'gallery-overlay'; + ov.innerHTML = ``; + ov.addEventListener('click', () => ov.remove()); + document.body.appendChild(ov); + } + document.getElementById('_galleryImg').src = src; + document.getElementById('_galleryCaption').textContent = name; + if (!document.body.contains(ov)) document.body.appendChild(ov); +} + +function _renderTabBar() { + const bar = document.getElementById('editorTabBar'); + if (!bar) { _saveTabState(); return; } + if (!_tabs.length) { bar.innerHTML = ''; _saveTabState(); return; } + bar.innerHTML = _tabs.map(t => { + const cmDirty = t.kind === 'editor' && _tabCMs[t.id]?.isClean() === false; + const dirty = (t.dirty || cmDirty) ? '' : ''; + const icon = t.kind === 'preview' ? '' : ''; + return `
+ ${icon}${esc(t.name)}${dirty} + +
`; + }).join(''); + bar.querySelectorAll('.editor-tab').forEach(el => { + el.addEventListener('click', ev => { + if (ev.target.closest('[data-close-tab]')) return; + _switchTab(el.dataset.tabId); + }); + }); + bar.querySelectorAll('[data-close-tab]').forEach(el => { + el.addEventListener('click', () => _closeTab(el.dataset.closeTab)); + }); + _saveTabState(); +} + +function _tabStorageKey() { + const fb = document.getElementById('fileBrowser'); + const pp = document.getElementById('postsPanel'); + const el = fb || pp; + if (!el) return null; + return 'hackmancms_tabs_' + el.dataset.projectId + '_' + (fb ? 'files' : 'posts'); +} + +function _saveTabState() { + const key = _tabStorageKey(); + if (!key) return; + const items = _tabs + .filter(t => t.path && (t.kind === 'editor' || t.kind === 'preview')) + .map(t => ({ pid: t.pid, path: t.path, name: t.name, kind: t.kind })); + if (!items.length) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify({ tabs: items, active: _activeTabId })); +} + +function _restoreTabState() { + const key = _tabStorageKey(); + if (!key) return; + let saved; + try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { saved = null; } + if (!saved?.tabs?.length) return; + saved.tabs.forEach(t => openFileEditor(t.pid, t.path, t.name)); +} + +function _switchTab(id) { + _tabs.forEach(t => { + const div = document.getElementById('tc-' + t.id); + if (div) div.style.display = 'none'; + }); + const div = document.getElementById('tc-' + id); + if (div) { + div.style.display = 'flex'; + div.style.flexDirection = 'column'; + div.style.height = '100%'; + requestAnimationFrame(() => _tabCMs[id]?.refresh()); + } + _activeTabId = id; + _renderTabBar(); + + // Hide placeholder + const content = document.getElementById('editorTabContent'); + const placeholder = content?.querySelector('.editor-placeholder'); + if (placeholder) placeholder.style.display = 'none'; +} + +function _closeTab(id) { + const idx = _tabs.findIndex(t => t.id === id); + if (idx === -1) return; + if (_tabCMs[id]) { try { _tabCMs[id].toTextArea(); } catch(e) {} delete _tabCMs[id]; } + delete _tabMdMounts[id]; + if (_tabFmCMs[id]) { try { _tabFmCMs[id].toTextArea(); } catch (e) {} delete _tabFmCMs[id]; } + if (_tabMdReflowers[id]) { + try { _tabMdReflowers[id].disconnect(); } catch (e) {} + delete _tabMdReflowers[id]; + } + if (_tabImgObservers[id]) { + try { _tabImgObservers[id].disconnect(); } catch (e) {} + delete _tabImgObservers[id]; + } + document.getElementById('tc-' + id)?.remove(); + _tabs.splice(idx, 1); + if (_activeTabId === id) { + const next = _tabs[Math.min(idx, _tabs.length - 1)]; + if (next) { _switchTab(next.id); } + else { + _activeTabId = null; + const content = document.getElementById('editorTabContent'); + if (content) { + const ph = content.querySelector('.editor-placeholder'); + if (ph) ph.style.display = ''; + else content.innerHTML = `
Select a file to edit
`; + } + } + } + _renderTabBar(); +} + +function _markDirty(id) { + const tab = _tabs.find(t => t.id === id); + if (tab) tab.dirty = true; + _renderTabBar(); + _updateSaveBtnState(id); +} + +// ── Markdown editor (Milkdown via Agenda-style mk-mount) ──────────────────── +// Each .md tab gets a textarea.mk-mount whose value is the body markdown +// (image URLs pre-rewritten to served URLs); milkdown-mount.js wraps it in +// a WYSIWYG editor with a built-in MD/WYSIWYG toolbar toggle. +const _tabMdMounts = {}; // id → textarea handle (.mkMount) +const _tabFmCMs = {}; // id → CodeMirror (front matter YAML) +const _tabMdReflowers = {}; // id → ResizeObserver + +// Walk the rendered ProseMirror DOM and rewrite each 's `src` to the +// served URL — without touching the editor's underlying model. The model +// keeps the authored path (e.g. `images/foo.png`), so getMarkdown() returns +// clean markdown on save and Hexo sees exactly what the user wrote. +const _tabImgObservers = {}; + +function _rewriteImgsInPlace(root, pid) { + if (!root) return; + root.querySelectorAll('img').forEach(img => { + const cur = img.getAttribute('src') || ''; + if (!cur) return; + if (cur.includes('/api/files')) return; // already rewritten + if (/^(https?:|data:|\/\/)/i.test(cur)) return; // absolute, leave as-is + const resolved = _resolveImagePathForEditor(cur, pid); + if (resolved !== cur) { + img.setAttribute('data-original-src', cur); + img.setAttribute('src', resolved); + } + }); +} + +function _attachImgSrcRewriter(id, pid) { + const root = document.getElementById('tc-' + id); + const pm = root?.querySelector('.ProseMirror'); + if (!pm) return; + // Initial pass for whatever's already rendered + _rewriteImgsInPlace(pm, pid); + // Watch for newly added or changed elements (paste, image insert, + // ProseMirror re-render). Filtering attributeFilter to `src` avoids loops + // when our own setAttribute fires a notification. + const obs = new MutationObserver(() => _rewriteImgsInPlace(pm, pid)); + obs.observe(pm, { childList: true, subtree: true, + attributes: true, attributeFilter: ['src'] }); + // Replace any existing observer (e.g. on re-mount) + if (_tabImgObservers[id]) try { _tabImgObservers[id].disconnect(); } catch (e) {} + _tabImgObservers[id] = obs; +} + +function _reflowMdEditor(id) { + const mount = document.getElementById('paneEditor-' + id); + if (!mount) return; + const stackH = mount.clientHeight; + if (stackH < 1) return; + + // Editor mount may not be present yet (mk-mount wraps the textarea async). + const wrap = mount.querySelector('.mk-mount-wrap'); + if (!wrap) return; + const body = wrap.querySelector('.ie-mk-body'); + if (!body) return; + + const wrapToolbar = wrap.querySelector('.ie-mk-toolbar'); + const innerPhotos = body.querySelector('.md-photos-banner'); + const toolbarH = wrapToolbar ? wrapToolbar.offsetHeight : 40; + const photosH = innerPhotos && !innerPhotos.classList.contains('d-none') + ? innerPhotos.offsetHeight : 0; + const wrapBd = 2; + + // Lock wrap and body to definite pixel heights — ProseMirror's percentage + // min-height doesn't cascade reliably through Milkdown's wrappers, so we + // size them manually to make body's overflow-y: auto fire dependably. + wrap.style.flex = '0 0 auto'; + wrap.style.height = stackH + 'px'; + + const bodyH = Math.max(80, stackH - toolbarH - wrapBd); + body.style.flex = '0 0 auto'; + body.style.height = bodyH + 'px'; + + const editorMinH = Math.max(60, bodyH - photosH); + const milkdownRoot = body.querySelector(':scope > div:not(.md-photos-banner)'); + if (milkdownRoot) milkdownRoot.style.minHeight = editorMinH + 'px'; + body.querySelectorAll('.milkdown, .editor, .ProseMirror').forEach(el => { + el.style.minHeight = editorMinH + 'px'; + }); + const taSrc = body.querySelector('textarea.ie-mk-ta'); + if (taSrc) { + taSrc.style.minHeight = editorMinH + 'px'; + taSrc.style.height = editorMinH + 'px'; + } + + // FM editor (when visible) fills the same body slot + const fmContainer = wrap.querySelector('.ie-mk-fm'); + const fmCM = _tabFmCMs[id]; + if (fmContainer && fmContainer.style.display !== 'none' && fmCM) { + fmContainer.style.height = bodyH + 'px'; + try { fmCM.setSize('100%', bodyH + 'px'); } catch (e) {} + } +} + +async function _openMdEditorTab(pid, path, name, content, isDraft) { + const tabContent = document.getElementById('editorTabContent'); + if (!tabContent) return; + + const id = 'tab' + (++_tabCounter); + // Split content into FM + body. FM goes into a separate CodeMirror toggled + // via the FM button on mk-mount's toolbar; body goes into Milkdown. On save + // they are merged back so the file round-trips byte-identical. + const { fm, body, photos } = _parseMdSource(content); + const hadFm = content.startsWith('---'); + _tabs.push({ id, pid, path, name, kind: 'md-editor', isDraft, dirty: false, hadFm }); + _renderTabBar(); + + const div = document.createElement('div'); + div.id = 'tc-' + id; + div.style.display = 'none'; + div.style.height = '100%'; + div.style.position = 'relative'; + div.innerHTML = ` +
+ `; + tabContent.appendChild(div); + + // Mount Milkdown over the FULL document (front matter included). The + // frontmatter plugin renders the YAML block as code at the top of the + // editor — no separate CodeMirror, no merge-on-save dance. Image URLs are + // kept as authored; only the rendered .src is rewritten via the + // observer below so ProseMirror's model stays clean. + const mountEl = div.querySelector('#paneEditor-' + id); + const ta = document.createElement('textarea'); + ta.className = 'mk-mount'; + ta.value = body; // body only — FM is edited separately + mountEl.appendChild(ta); + ta.onMkInput = () => _markDirty(id); + _tabMdMounts[id] = ta; + + // Recompute editor height on every layout-affecting change. + const ro = new ResizeObserver(() => _reflowMdEditor(id)); + ro.observe(mountEl); + _tabMdReflowers[id] = ro; + + ta.addEventListener('mk-mounted', () => { + _injectFmEditor(id, fm, pid); + _injectMkToolbarExtras(id, pid, path, isDraft); + _reflowMdEditor(id); + }); + ta.addEventListener('mk-ready', () => { + const editorBody = div.querySelector('.ie-mk-body'); + if (editorBody) { + let banner = editorBody.querySelector('.md-photos-banner'); + if (!banner) { + banner = document.createElement('div'); + banner.className = 'md-photos-banner inside-editor'; + banner.id = 'panePhotos-' + id; + editorBody.insertBefore(banner, editorBody.firstChild); + } + _renderPhotosBanner(banner, photos, pid); + } + _reflowMdEditor(id); + _attachImgSrcRewriter(id, pid); + }); + + div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id)); + + // Ctrl/Cmd+S anywhere in the pane saves. + div.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + _paneTabSave(id); + } + }); + + _switchTab(id); + requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id))); +} + +// Refresh the photos banner from the FM CodeMirror's current value. +function _refreshPhotosBanner(id, pid) { + const fmCM = _tabFmCMs[id]; + const banner = document.querySelector('#tc-' + id + ' .md-photos-banner'); + if (!banner) return; + const fmText = fmCM ? fmCM.getValue() : ''; + const { photos } = _parseMdSource('---\n' + fmText + '\n---\n'); + _renderPhotosBanner(banner, photos, pid); +} + +// Inject the FM CodeMirror as a sibling of .ie-mk-body (hidden by default) +// plus an "FM" toggle button on the mk-mount toolbar. +function _injectFmEditor(id, initialFm, pid) { + const root = document.getElementById('tc-' + id); + if (!root) return; + const wrap = root.querySelector('.mk-mount-wrap'); + const ieBody = wrap?.querySelector('.ie-mk-body'); + const tb = wrap?.querySelector('.ie-mk-toolbar'); + if (!wrap || !ieBody || !tb || _tabFmCMs[id]) return; + + // FM container — hidden by default, slots in alongside .ie-mk-body + const fmContainer = document.createElement('div'); + fmContainer.className = 'ie-mk-fm'; + fmContainer.style.display = 'none'; + wrap.insertBefore(fmContainer, ieBody.nextSibling); + + const fmCM = CodeMirror(fmContainer, { + value: initialFm, mode: 'yaml', theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + extraKeys: { 'Ctrl-S': () => _paneTabSave(id), 'Cmd-S': () => _paneTabSave(id) }, + }); + fmCM.on('change', () => { + _markDirty(id); + _refreshPhotosBanner(id, pid); + }); + _tabFmCMs[id] = fmCM; + + // FM toggle on the editor toolbar (next to mk-mount's MD button) + const fmBtn = document.createElement('button'); + fmBtn.type = 'button'; + fmBtn.className = 'btn btn-sm btn-outline-secondary mk-fm-toggle'; + fmBtn.title = 'Edit front matter (YAML)'; + fmBtn.innerHTML = ' FM'; + const mkMode = tb.querySelector('.mk-mode'); + if (mkMode) tb.insertBefore(fmBtn, mkMode); + else tb.appendChild(fmBtn); + + fmBtn.addEventListener('click', () => { + const showingFm = fmContainer.style.display !== 'none'; + if (showingFm) { + fmContainer.style.display = 'none'; + ieBody.style.display = ''; + fmBtn.classList.remove('active'); + } else { + ieBody.style.display = 'none'; + fmContainer.style.display = ''; + fmBtn.classList.add('active'); + requestAnimationFrame(() => fmCM.refresh()); + } + _reflowMdEditor(id); + }); +} + +// Inject the kebab (Delete + Publish) at the right end of mk-mount's toolbar +// — keeps it in the editor's own row instead of squatting between the file +// tab strip and the editor canvas. +function _injectMkToolbarExtras(id, pid, path, isDraft) { + const root = document.getElementById('tc-' + id); + if (!root) return; + const tb = root.querySelector('.ie-mk-toolbar'); + if (!tb || tb.querySelector('.mk-pane-kebab')) return; + const publishItem = isDraft + ? `
  • ` + : ''; + const wrap = document.createElement('div'); + wrap.className = 'dropdown mk-pane-kebab ms-1'; + wrap.innerHTML = ` + + `; + tb.appendChild(wrap); + wrap.querySelector('#paneDeleteBtn-' + id).addEventListener('click', () => _paneTabDelete(id)); + if (isDraft) { + wrap.querySelector('#panePublishBtn-' + id)?.addEventListener('click', () => _paneTabPublish(id, pid, path)); + } +} + +function _updateSaveBtnState(id) { + const tab = _tabs.find(t => t.id === id); + const btn = document.getElementById('paneSaveBtn-' + id); + if (!tab || !btn) return; + btn.classList.toggle('d-none', !tab.dirty); +} + +// Parse a markdown source into { fm: yamlString, body: string, photos: string[] } +function _parseMdSource(text) { + const out = { fm: '', body: text, photos: [] }; + if (!text.startsWith('---')) return out; + const end = text.indexOf('\n---', 3); + if (end === -1) return out; + out.fm = text.substring(3, end).replace(/^\n/, '').replace(/\n$/, ''); + out.body = text.substring(end + 4).replace(/^\s*\n/, ''); + + // photos: inline array → photos: [a, b, c] + const inline = out.fm.match(/^photos:\s*\[(.+?)\]\s*$/m); + if (inline) { + out.photos = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } else { + // photos: block list → photos:\n - a\n - b + const block = out.fm.match(/^photos:\s*\n((?:[ \t]*-\s*.+\n?)+)/m); + if (block) { + out.photos = [...block[1].matchAll(/^[ \t]*-\s*(.+?)\s*$/gm)] + .map(m => m[1].trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); + } else { + // photos: single value on the same line → photos: foo.jpg + const single = out.fm.match(/^photos:\s*([^\s\[].*?)\s*$/m); + if (single) out.photos = [single[1].replace(/^["']|["']$/g, '')]; + } + } + return out; +} + +function _renderPhotosBanner(el, photos, pid) { + if (!el) return; + if (!photos.length) { el.classList.add('d-none'); el.innerHTML = ''; return; } + el.classList.remove('d-none'); + el.innerHTML = photos.map(p => { + const url = _resolveImagePathForEditor(p, pid); + return ``; + }).join(''); +} + +// images/foo.png → /api/files?project_id=X&action=serve&path=source/images/foo.png +// Already-absolute or already-rewritten URLs pass through unchanged. +function _resolveImagePathForEditor(src, pid) { + if (!src) return src; + if (/^(https?:|data:|\/\/)/i.test(src)) return src; + if (src.includes('/api/files')) return src; + let rel = src.replace(/^\.?\//, ''); + if (!rel.startsWith('source/')) rel = 'source/' + rel; + return `/api/files?project_id=${pid}&action=serve&path=${encodeURIComponent(rel)}`; +} + +// Rewrite ![]() image URLs for in-editor display. +function _rewriteImagePathsForEditor(markdown, pid) { + return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => + pre + _resolveImagePathForEditor(src, pid) + post); +} + +// Reverse: strip the /api/files prefix back to the original relative form. +function _reverseRewriteImagePaths(markdown, pid) { + const prefix = `/api/files?project_id=${pid}&action=serve&path=`; + return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => { + if (src.startsWith(prefix)) { + let p = decodeURIComponent(src.substring(prefix.length)); + if (p.startsWith('source/')) p = p.substring(7); + return pre + p + post; + } + return m; + }); +} + +async function _paneTabSave(id) { + const tab = _tabs.find(t => t.id === id); + if (!tab) return; + const status = document.getElementById('paneStatus-' + id); + let content; + + if (tab.kind === 'md-editor') { + const mount = _tabMdMounts[id]; + if (!mount?.mkMount) { showError('Editor not ready'); return; } + const body = mount.mkMount.getContent() ?? ''; + const fmCM = _tabFmCMs[id]; + const fm = fmCM ? fmCM.getValue() : ''; + const fmTrimmed = fm.replace(/^\n+|\n+$/g, ''); + if (tab.hadFm || fmTrimmed) { + content = '---\n' + fmTrimmed + '\n---\n\n' + body; + } else { + content = body; + } + } else { + if (!_tabCMs[id]) return; + content = _tabCMs[id].getValue(); + } + + if (status) status.textContent = 'Saving…'; + const res = await fetch('/api/files?project_id=' + tab.pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'write', path: tab.path, content }), + }); + const data = await res.json(); + if (data.ok) { + tab.dirty = false; + if (tab.kind !== 'md-editor') _tabCMs[id]?.markClean(); + if (status) status.textContent = 'Saved.'; + showSuccess('Saved'); + _renderTabBar(); + _updateSaveBtnState(id); + } else { + if (status) status.textContent = ''; + showError(data.error || 'Save failed'); + } +} + +async function _paneTabDelete(id) { + const tab = _tabs.find(t => t.id === id); + if (!tab) return; + confirmAction(`Delete "${tab.name}"? This cannot be undone.`, async () => { + const res = await fetch('/api/files?project_id=' + tab.pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', path: tab.path }), + }); + const data = await res.json(); + if (data.ok) { + showSuccess('Deleted ' + tab.name); + _closeTab(id); + const browser = document.getElementById('fileBrowser'); + if (browser) { + const currentPath = browser.dataset.currentPath || ''; + if (typeof loadDir === 'function') loadDir(currentPath); + } + if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post'); + } else { + showError(data.error || 'Delete failed'); + } + }); +} + +async function _paneTabPublish(id, pid, path) { + const tab = _tabs.find(t => t.id === id); + if (!tab) return; + confirmAction('Publish draft to _posts? This moves the file.', async () => { + const relpath = tab.path.replace('source/_drafts/', ''); + const res = await fetch('/api/posts?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'publish', relpath }), + }); + const data = await res.json(); + if (data.ok) { + showSuccess('Published!'); + _closeTab(id); + _postsLoadFn('draft'); + } else { + showError(data.error || 'Publish failed'); + } + }); +} + +// Modal editor (used from posts pane where there's no tab bar) +let _modalCM = null; + +function _renderModalEditor(pid, path, name, content) { + document.getElementById('fileEditName').textContent = name; + document.getElementById('fileEditPath').value = path; + document.getElementById('fileEditPid').value = pid; + document.getElementById('fileEditStatus').textContent = ''; + + const container = document.getElementById('fileEditCm'); + container.innerHTML = ''; + if (_modalCM) { try { _modalCM.toTextArea(); } catch(e) {} _modalCM = null; } + _modalCM = CodeMirror(container, { + value: content, mode: modeForFile(name), theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + extraKeys: { 'Ctrl-S': saveFile, 'Cmd-S': saveFile }, + }); + + const modal = document.getElementById('fileEditModal'); + bootstrap.Modal.getOrCreateInstance(modal).show(); + modal.addEventListener('shown.bs.modal', () => _modalCM?.refresh(), { once: true }); +} + +async function saveFile() { + const pid = document.getElementById('fileEditPid').value; + const path = document.getElementById('fileEditPath').value; + const content = _modalCM ? _modalCM.getValue() : ''; + const status = document.getElementById('fileEditStatus'); + status.textContent = 'Saving…'; + const res = await fetch('/api/files?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'write', path, content }), + }); + const data = await res.json(); + if (data.ok) { status.textContent = 'Saved.'; showSuccess('Saved'); } + else { status.textContent = ''; showError(data.error || 'Save failed'); } +} + +document.getElementById('fileEditSave')?.addEventListener('click', saveFile); + +// ── File browser ────────────────────────────────────────────────────────────── +const fileBrowser = document.getElementById('fileBrowser'); +if (fileBrowser) { + const pid = fileBrowser.dataset.projectId; + let fileBrowserCurrentPath = ''; + + async function loadDir(relPath) { + fileBrowserCurrentPath = relPath; + fileBrowser.dataset.currentPath = relPath; + const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`); + const data = await res.json(); + if (data.error) { renderBrowserError(data.error); return; } + renderCrumb(relPath, '#fileCrumb', loadDir); + renderEntries(data.entries); + } + + function renderEntries(entries) { + const list = document.getElementById('fileList'); + if (!entries.length) { list.innerHTML = '
    Empty directory
    '; return; } + list.innerHTML = entries.map(e => { + const icon = e.type === 'dir' ? 'bi-folder-fill text-warning' : 'bi-file-text text-secondary'; + const kebab = e.type === 'file' ? ` + ` : ''; + return `
    + + ${esc(e.name)} + ${e.size != null ? `${fmtSize(e.size)}` : ''} + ${kebab} +
    `; + }).join(''); + list.querySelectorAll('[data-type]').forEach(row => { + row.addEventListener('click', ev => { + if (ev.target.closest('button')) return; + row.dataset.type === 'dir' ? loadDir(row.dataset.path) : openFileEditor(pid, row.dataset.path, row.dataset.name); + }); + }); + list.querySelectorAll('.btn-open-file').forEach(btn => { + btn.addEventListener('click', () => openFileEditor(pid, btn.dataset.path, btn.dataset.name)); + }); + list.querySelectorAll('.btn-del-file').forEach(btn => { + btn.addEventListener('click', () => { + confirmAction(`Delete "${btn.dataset.name}"?`, async () => { + const r = await fetch('/api/files?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', path: btn.dataset.path }), + }); + const d = await r.json(); + if (d.ok) { + showSuccess('Deleted ' + btn.dataset.name); + // Close the editor tab if this file was open + const openTab = _tabs.find(t => t.path === btn.dataset.path); + if (openTab) _closeTab(openTab.id); + loadDir(fileBrowserCurrentPath); + } else showError(d.error || 'Delete failed'); + }); + }); + }); + } + + function renderBrowserError(msg) { + document.getElementById('fileList').innerHTML = `
    ${esc(msg)}
    `; + } + + // Drag-and-drop upload — whole left pane as drop zone + const dropZone = fileBrowser.querySelector('.split-list') || fileBrowser; + let dragDepth = 0; + dropZone.addEventListener('dragenter', e => { + e.preventDefault(); dragDepth++; + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => { + if (--dragDepth <= 0) { dragDepth = 0; dropZone.classList.remove('drag-over'); } + }); + dropZone.addEventListener('dragover', e => { + e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; + }); + dropZone.addEventListener('drop', async e => { + e.preventDefault(); dragDepth = 0; dropZone.classList.remove('drag-over'); + const files = [...(e.dataTransfer.files || [])]; + if (!files.length) return; + let ok = 0; + for (const file of files) { + const fd = new FormData(); + fd.append('project_id', pid); + fd.append('folder', fileBrowserCurrentPath); + fd.append('accept', 'any'); + fd.append('file', file); + try { + const res = await fetch('/api/upload', { method: 'POST', body: fd }); + const data = await res.json(); + data.ok ? ok++ : showError(`${file.name}: ${data.error || 'Upload failed'}`); + } catch(err) { showError(`${file.name}: ${err.message}`); } + } + if (ok > 0) { showSuccess(`Uploaded ${ok} file${ok > 1 ? 's' : ''}`); loadDir(fileBrowserCurrentPath); } + }); + + const initialPath = new URLSearchParams(location.search).get('path') || ''; + loadDir(initialPath); +} + +// ── Command runner ──────────────────────────────────────────────────────────── +const commandRunner = document.getElementById('commandRunner'); +if (commandRunner) { + const pid = commandRunner.dataset.projectId; + const output = document.getElementById('cmdOutput'); + document.getElementById('clearOutput')?.addEventListener('click', () => { output.textContent = ''; }); + commandRunner.querySelectorAll('.btn-run-cmd').forEach(btn => { + btn.addEventListener('click', async () => { + const cmd = btn.dataset.cmd; + output.textContent += `\n$ ${cmd}\n`; + btn.disabled = true; + const res = await fetch('/api/run', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ project_id: parseInt(pid), cmd }), + }); + const data = await res.json(); + output.textContent += data.error ? `ERROR: ${data.error}\n` : (data.output || '(no output)') + `\nExit: ${data.exit_code}\n`; + output.scrollTop = output.scrollHeight; + btn.disabled = false; + }); + }); +} + +// ── Post tree helpers ───────────────────────────────────────────────────────── +let _postFolderCounter = 0; + +function buildPostTree(items) { + const root = { posts: [], children: {} }; + for (const item of items) { + const segs = item.folder ? item.folder.split('/').filter(Boolean) : []; + let node = root; + for (const seg of segs) { + if (!node.children[seg]) node.children[seg] = { posts: [], children: {} }; + node = node.children[seg]; + } + node.posts.push(item); + } + return root; +} + +function countTreePosts(node) { + let c = node.posts.length; + for (const child of Object.values(node.children)) c += countTreePosts(child); + return c; +} + +function renderPostItem(p, pid, type) { + const rel = esc(p.relpath); + return `
    +
    +
    ${esc(p.title)}
    + ${p.date ? esc(p.date.substring(0,10)) : '—'} • ${esc(p.filename)} +
    + +
    `; +} + +function renderPostTree(node, pid, type, expandPath = []) { + let html = ''; + if (node.posts.length) { + html += '
    '; + for (const p of node.posts) html += renderPostItem(p, pid, type); + html += '
    '; + } + for (const [name, child] of Object.entries(node.children)) { + const id = 'pfg' + (++_postFolderCounter); + const count = countTreePosts(child); + const isOpen = expandPath.length > 0 && expandPath[0] === name; + const sub = isOpen ? expandPath.slice(1) : []; + html += `
    +
    + + ${esc(name)} + ${count} +
    +
    + ${renderPostTree(child, pid, type, sub)} +
    +
    `; + } + return html; +} + +function _wirePostList(list, pid, currentTypeRef) { + list.querySelectorAll('.post-item-row').forEach(row => { + row.addEventListener('click', ev => { + if (ev.target.closest('button')) return; + openFileEditor(pid, row.dataset.path, row.dataset.name); + }); + }); + list.querySelectorAll('.btn-del-post').forEach(btn => { + btn.addEventListener('click', () => confirmAction(`Delete "${btn.dataset.relpath}"?`, async () => { + const res = await fetch('/api/posts?project_id=' + pid, { + method: 'DELETE', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ relpath: btn.dataset.relpath, type: btn.dataset.type }), + }); + const d = await res.json(); + d.ok ? _postsLoadFn(currentTypeRef.type) : showError(d.error || 'Error'); + })); + }); + list.querySelectorAll('.btn-dup-post').forEach(btn => { + btn.addEventListener('click', async () => { + const res = await fetch('/api/posts?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'duplicate', relpath: btn.dataset.relpath, type: btn.dataset.type }), + }); + const d = await res.json(); + d.ok ? (showSuccess('Duplicated as ' + d.relpath), _postsLoadFn(currentTypeRef.type)) : showError(d.error || 'Error'); + }); + }); +} + +// Shared reference so modal "Create" button can reload the list +let _postsLoadFn = () => {}; + +// ── Posts panel (Hexo) ─────────────────────────────────────────────────────── +const postsPanel = document.getElementById('postsPanel'); +if (postsPanel) { + const pid = postsPanel.dataset.projectId; + const postsList = document.getElementById('postsList'); + const searchResults= document.getElementById('searchResults'); + let currentType = 'post'; + _postsLoadFn = type => loadPostsByType(type); + + function setActiveBtn(btnId) { + ['showPosts','showPages','showDrafts'].forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle('btn-outline-primary', id === btnId); + el.classList.toggle('active', id === btnId); + el.classList.toggle('btn-outline-secondary', id !== btnId); + }); + } + + // ── Filter pill (tag/category from Dashboard) ─────────────────────────────── + let _activeFilter = null; // {kind, value} + function _renderFilterBar() { + let bar = document.getElementById('postsFilterBar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'postsFilterBar'; + bar.className = 'mb-2'; + postsList.parentElement.insertBefore(bar, postsList); + } + if (!_activeFilter) { bar.innerHTML = ''; return; } + bar.innerHTML = ` + + ${esc(_activeFilter.kind)}: ${esc(_activeFilter.value)} + + `; + document.getElementById('clearFilterBtn').addEventListener('click', () => { + _activeFilter = null; + const url = new URL(location); + url.searchParams.delete('filter'); + history.replaceState({}, '', url); + loadPostsByType(currentType); + }); + } + + function _applyFilter(items) { + if (!_activeFilter) return items; + const { kind, value } = _activeFilter; + return items.filter(it => { + const list = kind === 'tag' ? (it.tags || []) : (it.categories || []); + return list.some(v => String(v).toLowerCase() === value.toLowerCase()); + }); + } + + async function loadPostsByType(type) { + currentType = type; + window._postsCurrentType = type; + setActiveBtn(type === 'page' ? 'showPages' : type === 'draft' ? 'showDrafts' : 'showPosts'); + const sq = document.getElementById('searchQuery'); + if (sq) sq.value = ''; + searchResults.classList.add('d-none'); + postsList.classList.remove('d-none'); + postsList.innerHTML = '
    Loading…
    '; + const res = await fetch(`/api/posts?project_id=${pid}&type=${type}`); + const data = await res.json(); + if (data.missing_dir) { + const dirs = { post: 'source/_posts/', page: 'source/', draft: 'source/_drafts/' }; + postsList.innerHTML = `
    ${dirs[type] ?? type} not found.
    `; + _renderFilterBar(); + return; + } + const items = _applyFilter(data.items); + if (!items.length) { + postsList.innerHTML = `
    ${ + _activeFilter ? `No ${type}s match this filter.` : `No ${type}s yet.` + }
    `; + _renderFilterBar(); + return; + } + const expandPath = items[0]?.folder ? items[0].folder.split('/').filter(Boolean) : []; + _postFolderCounter = 0; + postsList.innerHTML = `
    ` + renderPostTree(buildPostTree(items), pid, type, expandPath) + `
    `; + _wirePostList(postsList, pid, { type }); + _renderFilterBar(); + } + + // Read ?filter=tag:foo or ?filter=category:bar from URL + const urlFilter = new URLSearchParams(location.search).get('filter'); + if (urlFilter && urlFilter.includes(':')) { + const [k, ...rest] = urlFilter.split(':'); + if (k === 'tag' || k === 'category') { + _activeFilter = { kind: k, value: rest.join(':') }; + } + } + + document.getElementById('showPosts')?.addEventListener('click', () => loadPostsByType('post')); + document.getElementById('showPages')?.addEventListener('click', () => loadPostsByType('page')); + document.getElementById('showDrafts')?.addEventListener('click', () => { + currentType = 'draft'; + window._postsCurrentType = 'draft'; + setActiveBtn('showDrafts'); + const sq = document.getElementById('searchQuery'); + if (sq) sq.value = ''; + searchResults.classList.add('d-none'); + postsList.classList.remove('d-none'); + loadDraftsList(); + }); + + document.getElementById('newItemBtn')?.addEventListener('click', () => { + createNewDraft(); + }); + + async function loadDraftsList() { + postsList.innerHTML = '
    Loading…
    '; + const res = await fetch(`/api/drafts?project_id=${pid}`); + const data = await res.json(); + if (!data.drafts?.length) { + postsList.innerHTML = '
    No drafts yet.
    '; + return; + } + postsList.innerHTML = data.drafts.map(d => ` +
    +
    +
    ${esc(d.title || 'Untitled')}
    + ${esc((d.updated_at ?? '').substring(0,10))} + ${d.folder ? ' • ' + esc(d.folder) + '' : ''} +
    + +
    `).join(''); + + postsList.querySelectorAll('.draft-list-row').forEach(row => { + row.addEventListener('click', ev => { + if (ev.target.closest('button')) return; + const d = data.drafts.find(x => x.id === parseInt(row.dataset.id)); + if (d) openDraftInTab(d); + }); + }); + postsList.querySelectorAll('.btn-del-draft').forEach(btn => { + btn.addEventListener('click', () => confirmAction('Delete this draft?', async () => { + const r = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id), project_id: parseInt(pid) }), + }); + const d = await r.json(); + if (d.ok) { + const tab = _tabs.find(t => t.kind === 'draft' && t.draftId === parseInt(btn.dataset.id)); + if (tab) _closeTab(tab.id); + loadDraftsList(); + } else showError(d.error || 'Error'); + })); + }); + postsList.querySelectorAll('.btn-publish-draft').forEach(btn => { + btn.addEventListener('click', async () => { + const r = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'publish', id: parseInt(btn.dataset.id), project_id: parseInt(pid) }), + }); + const d = await r.json(); + if (d.ok) { + showSuccess('Published as ' + (d.path || d.filename || 'post')); + const tab = _tabs.find(t => t.kind === 'draft' && t.draftId === parseInt(btn.dataset.id)); + if (tab) _closeTab(tab.id); + loadDraftsList(); + } else showError(d.error || 'Error'); + }); + }); + } + + async function createNewDraft() { + const res = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'create', project_id: parseInt(pid), title: 'New draft' }), + }); + const data = await res.json(); + if (!data.ok) { showError(data.error || 'Error'); return; } + await loadDraftsList(); + const r2 = await fetch(`/api/drafts?project_id=${pid}`); + const d2 = await r2.json(); + const newD = d2.drafts?.find(x => x.id === data.id); + if (newD) openDraftInTab(newD); + } + + function openDraftInTab(draft) { + const existing = _tabs.find(t => t.kind === 'draft' && t.draftId === draft.id); + if (existing) { _switchTab(existing.id); return; } + + const id = 'tab' + (++_tabCounter); + _tabs.push({ + id, pid, path: null, name: draft.title || 'Untitled', + kind: 'draft', draftId: draft.id, dirty: false, + }); + _renderTabBar(); + + const div = document.createElement('div'); + div.id = 'tc-' + id; + div.style.height = '100%'; + div.style.display = 'none'; + div.style.position = 'relative'; + div.innerHTML = ` +
    + + +
    + _posts/ + +
    +
    +
    + `; + document.getElementById('editorTabContent').appendChild(div); + + // Auto-slug from title (kept from original) + div.querySelector('#dtTitle-' + id)?.addEventListener('input', (e) => { + const slug = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const slugEl = div.querySelector('#dtSlug-' + id); + if (slugEl && !slugEl.dataset.userEdited) slugEl.value = slug; + _markDirty(id); + }); + ['#dtSlug-', '#dtFolder-'].forEach(prefix => { + div.querySelector(prefix + id)?.addEventListener('input', function () { + this.dataset.userEdited = '1'; + _markDirty(id); + }); + }); + + // Milkdown body + const mountEl = div.querySelector('#paneEditor-' + id); + const ta = document.createElement('textarea'); + ta.className = 'mk-mount'; + ta.value = draft.body || ''; + mountEl.appendChild(ta); + ta.onMkInput = () => _markDirty(id); + _tabMdMounts[id] = ta; + + const ro = new ResizeObserver(() => _reflowMdEditor(id)); + ro.observe(mountEl); + _tabMdReflowers[id] = ro; + + ta.addEventListener('mk-mounted', () => { + _injectDraftKebab(id, pid, draft.id); + _reflowMdEditor(id); + }); + ta.addEventListener('mk-ready', () => { + _reflowMdEditor(id); + _attachImgSrcRewriter(id, pid); + }); + + div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => saveDraftTab(id)); + div.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + saveDraftTab(id); + } + }); + + _switchTab(id); + requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id))); + } + + // Kebab in mk-mount's toolbar — Publish + Delete for drafts. + function _injectDraftKebab(id, pid, draftId) { + const root = document.getElementById('tc-' + id); + const tb = root?.querySelector('.ie-mk-toolbar'); + if (!tb || tb.querySelector('.mk-pane-kebab')) return; + const wrap = document.createElement('div'); + wrap.className = 'dropdown mk-pane-kebab ms-1'; + wrap.innerHTML = ` + + `; + tb.appendChild(wrap); + wrap.querySelector('#dtPublishBtn-' + id).addEventListener('click', () => publishDraftTab(id)); + wrap.querySelector('#dtDeleteBtn-' + id).addEventListener('click', () => { + confirmAction('Delete this draft?', async () => { + const r = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', id: draftId, project_id: parseInt(pid) }), + }); + const d = await r.json(); + if (d.ok) { showSuccess('Deleted'); _closeTab(id); loadDraftsList(); } + else showError(d.error || 'Error'); + }); + }); + } + + async function saveDraftTab(id) { + const tab = _tabs.find(t => t.id === id); + if (!tab || tab.kind !== 'draft') return; + const mount = _tabMdMounts[id]; + if (!mount?.mkMount) { showError('Editor not ready'); return; } + const title = document.getElementById('dtTitle-' + id)?.value.trim() || ''; + const slug = document.getElementById('dtSlug-' + id)?.value.trim() || ''; + const folder = document.getElementById('dtFolder-' + id)?.value.trim() || ''; + const body = mount.mkMount.getContent() || ''; + const res = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'update', id: tab.draftId, project_id: parseInt(pid), title, slug, folder, body }), + }); + const data = await res.json(); + if (data.ok) { + tab.dirty = false; + tab.name = title || 'Untitled'; + showSuccess('Draft saved'); + _renderTabBar(); + _updateSaveBtnState(id); + loadDraftsList(); + } else { + showError(data.error || 'Save failed'); + } + } + + async function publishDraftTab(id) { + const tab = _tabs.find(t => t.id === id); + if (!tab || tab.kind !== 'draft') return; + await saveDraftTab(id); + confirmAction('Publish draft to _posts?', async () => { + const res = await fetch('/api/drafts', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'publish', id: tab.draftId, project_id: parseInt(pid) }), + }); + const data = await res.json(); + if (data.ok) { + showSuccess('Published: ' + data.filename); + _closeTab(id); + loadDraftsList(); + _postsLoadFn('post'); + } else { + showError(data.error || 'Publish failed'); + } + }); + } + + let _searchTimer = null; + document.getElementById('searchQuery')?.addEventListener('input', e => { + clearTimeout(_searchTimer); + const q = e.target.value.trim(); + if (!q) { + searchResults.classList.add('d-none'); + postsList.classList.remove('d-none'); + return; + } + postsList.classList.add('d-none'); + searchResults.classList.remove('d-none'); + searchResults.innerHTML = '
    Searching…
    '; + _searchTimer = setTimeout(async () => { + const res = await fetch(`/api/search?project_id=${pid}&q=${encodeURIComponent(q)}`); + const data = await res.json(); + if (data.error) { searchResults.innerHTML = `
    ${esc(data.error)}
    `; return; } + if (!data.results.length) { searchResults.innerHTML = '
    No results.
    '; return; } + searchResults.innerHTML = data.results.map(r => ` +
    +
    + ${esc(r.file)} + L${r.line} +
    + ${esc(r.content)} +
    `).join(''); + searchResults.querySelectorAll('.search-result').forEach(row => { + row.addEventListener('click', () => openFileEditor(pid, row.dataset.path, row.dataset.name)); + }); + }, 350); + }); + + document.getElementById('searchClearBtn')?.addEventListener('click', () => { + const sq = document.getElementById('searchQuery'); + if (sq) sq.value = ''; + searchResults.classList.add('d-none'); + postsList.classList.remove('d-none'); + }); + + loadPostsByType('post'); +} + + +// ── Upload modal ────────────────────────────────────────────────────────────── +// Pre-fill folder from file browser when modal opens +document.getElementById('uploadModal')?.addEventListener('show.bs.modal', () => { + const browser = document.getElementById('fileBrowser'); + const folder = document.getElementById('uploadFolder'); + if (browser && folder) folder.value = browser.dataset.currentPath || ''; + document.getElementById('uploadResult')?.classList.add('d-none'); + const uf = document.getElementById('uploadFile'); + if (uf) uf.value = ''; +}); + +const uploadSubmit = document.getElementById('uploadSubmit'); +if (uploadSubmit) { + uploadSubmit.addEventListener('click', async () => { + const pid = document.getElementById('uploadPid').value; + const accept = document.getElementById('uploadAccept').value; + const folder = document.getElementById('uploadFolder').value.trim(); + const file = document.getElementById('uploadFile').files[0]; + + if (!file) { showError('No file selected'); return; } + + const fd = new FormData(); + fd.append('project_id', pid); + fd.append('folder', folder); + fd.append('accept', accept); + fd.append('file', file); + + uploadSubmit.disabled = true; + const res = await fetch('/api/upload', { method: 'POST', body: fd }); + const data = await res.json(); + uploadSubmit.disabled = false; + + if (!data.ok) { showError(data.error || 'Upload failed'); return; } + + const resultDiv = document.getElementById('uploadResult'); + const urlInput = document.getElementById('uploadResultUrl'); + resultDiv.classList.remove('d-none'); + document.getElementById('uploadResultMsg').textContent = `Uploaded: ${data.filename}`; + urlInput.value = data.url || data.path; + + document.getElementById('uploadCopy')?.addEventListener('click', () => { + navigator.clipboard.writeText(urlInput.value).then(() => showSuccess('Copied!')); + }, { once: true }); + }); +} + +// ── Media grid (Storage) ────────────────────────────────────────────────────── +const mediaPanel = document.getElementById('mediaPanel'); +if (mediaPanel) { + const pid = mediaPanel.dataset.projectId; + const projectUrl = mediaPanel.dataset.projectUrl; + + async function loadMedia(relPath) { + const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`); + const data = await res.json(); + if (data.error) { document.getElementById('mediaGrid').innerHTML = `
    ${esc(data.error)}
    `; return; } + renderCrumb(relPath, '#mediaCrumb', loadMedia); + renderMediaGrid(data.entries, relPath); + } + + function renderMediaGrid(entries, relPath) { + const grid = document.getElementById('mediaGrid'); + if (!entries.length) { grid.innerHTML = '
    Empty directory
    '; return; } + + grid.innerHTML = entries.map(e => { + if (e.type === 'dir') { + return `
    +
    +
    + +
    + +
    +
    `; + } + const thumb = isImage(e.name) + ? `` + : ``; + + const fileUrl = projectUrl ? rtrim(projectUrl, '/') + '/' + e.path : e.path; + return `
    +
    +
    + ${thumb} +
    + +
    +
    `; + }).join(''); + + grid.querySelectorAll('[data-nav-path]').forEach(el => { + el.addEventListener('click', () => loadMedia(el.dataset.navPath)); + }); + grid.querySelectorAll('.btn-copy-url').forEach(btn => { + btn.addEventListener('click', () => { + navigator.clipboard.writeText(btn.dataset.url).then(() => showSuccess('Copied!')); + }); + }); + } + + loadMedia(''); +} + +function rtrim(s, c) { return s.endsWith(c) ? s.slice(0, -c.length) : s; } + +// ── Shared: breadcrumb renderer ─────────────────────────────────────────────── +function renderCrumb(path, selector, onNavigate) { + const ol = document.querySelector(selector + ' ol'); + if (!ol) return; + const parts = path ? path.split('/').filter(Boolean) : []; + let html = ''; + let acc = ''; + for (const p of parts) { + acc = acc ? acc + '/' + p : p; + html += ``; + } + ol.innerHTML = html; + ol.querySelectorAll('a').forEach(a => + a.addEventListener('click', ev => { ev.preventDefault(); onNavigate(a.dataset.path); }) + ); +} + +// (project type selector moved to Settings tab — handled in settingsPanel block) + +// ── Dashboard: pin toggle ───────────────────────────────────────────────────── +document.querySelectorAll('.btn-pin-project').forEach(btn => { + btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'pin', id }), + }); + const data = await res.json(); + if (data.ok) location.reload(); + else showError(data.error || 'Error'); + }); +}); + + +// ── Git tab ─────────────────────────────────────────────────────────────────── +const gitPanel = document.getElementById('gitPanel'); +if (gitPanel) { + const pid = gitPanel.dataset.projectId; + let gitSubdir = ''; + + function gitParams(extra = '') { + const base = `/api/git?project_id=${pid}${gitSubdir ? '&subdir=' + encodeURIComponent(gitSubdir) : ''}`; + return base + (extra ? '&' + extra : ''); + } + + async function loadGitStatus() { + const res = await fetch(gitParams('action=status')); + const data = await res.json(); + if (data.no_git) { + if (data.subdirs?.length) { + const sel = document.getElementById('gitSubdirSelector'); + const opts = document.getElementById('gitSubdirSelect'); + opts.innerHTML = data.subdirs.map(s => ``).join(''); + sel.classList.remove('d-none'); + document.getElementById('gitSubdirUseBtn').onclick = () => { + gitSubdir = opts.value; + sel.classList.add('d-none'); + loadGitStatus(); + loadGitLog(); + }; + } else { + document.getElementById('gitNoRepo').classList.remove('d-none'); + } + return; + } + if (data.error) { showError(data.error); return; } + + document.getElementById('gitStatus').classList.remove('d-none'); + document.getElementById('gitBranch').innerHTML = `${esc(data.branch)}`; + const files = data.files || []; + const stash = data.stash_count || 0; + let summary = files.length + ? `${files.length} changed file${files.length > 1 ? 's' : ''}` + : 'Working tree clean'; + if (stash) summary += ` · ${stash} stash${stash > 1 ? 'es' : ''}`; + document.getElementById('gitStatusSummary').textContent = summary; + + const div = document.getElementById('gitFiles'); + if (files.length) { + div.innerHTML = `
    ` + + files.map(f => `
    + + ${esc(f.xy)} + ${esc(f.file)} + +
    `).join('') + `
    + `; + div.querySelectorAll('.btn-git-diff').forEach(btn => { + btn.addEventListener('click', () => showGitDiff('', btn.dataset.file)); + }); + document.getElementById('gitAddSelectedBtn')?.addEventListener('click', async () => { + const checked = [...div.querySelectorAll('.git-file-check:checked')].map(cb => cb.dataset.file); + if (!checked.length) { showError('Select files to add first'); return; } + const d = await (await gitPost({ action: 'stage', files: checked })).json(); + d.ok ? (showSuccess(`Staged ${checked.length} file${checked.length > 1 ? 's' : ''}`), loadGitStatus()) + : showError(d.output || 'Stage failed'); + }); + } else { + div.innerHTML = ''; + } + } + + async function loadGitLog() { + const res = await fetch(gitParams('action=log')); + const data = await res.json(); + const tbl = document.getElementById('gitLogTable'); + if (!data.commits?.length) { tbl.innerHTML = '
    No commits yet.
    '; return; } + tbl.innerHTML = `
    ` + data.commits.map(c => ` +
    +
    + ${esc(c.subject)} + ${esc(c.rel)} +
    + ${esc(c.short)} • ${esc(c.author)} +
    `).join('') + `
    `; + tbl.querySelectorAll('.btn-git-show-commit').forEach(row => { + row.addEventListener('click', () => showGitDiff(row.dataset.hash, '')); + }); + } + + async function loadGitBranches() { + const res = await fetch(gitParams('action=branches')); + const data = await res.json(); + if (!data.branches) return; + const list = document.getElementById('gitBranchList'); + list.innerHTML = data.branches.map(b => ` +
    + ${esc(b.name)} + ${b.current ? 'current' : ''} + ${!b.current ? `` : ''} +
    `).join(''); + list.querySelectorAll('.btn-checkout').forEach(btn => { + btn.addEventListener('click', async () => { + const d = await (await gitPost({ action: 'checkout', branch: btn.dataset.branch })).json(); + if (d.ok) { showSuccess('Switched to ' + btn.dataset.branch); loadGitStatus(); loadGitBranches(); } + else showError(d.output || 'Checkout failed'); + }); + }); + } + + async function showGitDiff(hash, file) { + const params = new URLSearchParams({ action: 'diff' }); + if (gitSubdir) params.set('subdir', gitSubdir); + if (hash) params.append('hash', hash); + if (file) params.append('file', file); + const res = await fetch(`/api/git?project_id=${pid}&` + params); + const data = await res.json(); + document.getElementById('gitDiffContent').innerHTML = colorDiff(data.diff || '(empty)'); + document.getElementById('gitDiffTitle').textContent = hash + ? `Commit ${hash.substring(0, 7)}` : `Diff: ${file}`; + bootstrap.Modal.getOrCreateInstance(document.getElementById('gitDiffModal')).show(); + } + + function colorDiff(raw) { + return raw.split('\n').map(line => { + if (line.startsWith('+') && !line.startsWith('+++')) return `${esc(line)}`; + if (line.startsWith('-') && !line.startsWith('---')) return `${esc(line)}`; + if (line.startsWith('@@')) return `${esc(line)}`; + if (/^(diff |index |---|[+]{3})/.test(line)) return `${esc(line)}`; + return esc(line); + }).join('\n'); + } + + function gitPost(body) { + if (gitSubdir) body.subdir = gitSubdir; + return fetch('/api/git?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + } + + async function runGitStream(action) { + const out = document.getElementById('gitStreamOutput'); + out.classList.remove('d-none'); + out.textContent = `git ${action}…\n`; + const res = await gitPost({ action }); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const d = JSON.parse(line.slice(6)); + if (d.line) out.textContent += d.line; + if (d.done) { + out.textContent += `\nExit: ${d.exit_code}`; + d.exit_code === 0 + ? showSuccess(`git ${action} done`) + : showError(`git ${action} failed (exit ${d.exit_code})`); + loadGitStatus(); + if (action === 'pull') loadGitLog(); + } + if (d.error) showError(d.error); + } catch(e) {} + } + out.scrollTop = out.scrollHeight; + } + } + + document.getElementById('gitPullBtn')?.addEventListener('click', () => runGitStream('pull')); + document.getElementById('gitPushBtn')?.addEventListener('click', () => runGitStream('push')); + + document.getElementById('gitCommitBtn')?.addEventListener('click', async () => { + const msg = document.getElementById('gitCommitMsg').value.trim(); + if (!msg) { showError('Commit message required'); return; } + const res = await gitPost({ action: 'commit', message: msg }); + const data = await res.json(); + if (data.ok) { + showSuccess('Committed'); + document.getElementById('gitCommitMsg').value = ''; + loadGitStatus(); loadGitLog(); + } else { + showError(data.output || 'Commit failed'); + } + }); + + document.getElementById('gitStashBtn')?.addEventListener('click', async () => { + const d = await (await gitPost({ action: 'stash' })).json(); + d.ok ? (showSuccess('Stashed'), loadGitStatus()) : showError(d.output || 'Error'); + }); + + document.getElementById('gitStashPopBtn')?.addEventListener('click', async () => { + const d = await (await gitPost({ action: 'stash_pop' })).json(); + d.ok ? (showSuccess('Applied stash'), loadGitStatus()) : showError(d.output || 'Error'); + }); + + document.getElementById('gitResetBtn')?.addEventListener('click', () => { + confirmAction('Reset to HEAD? All uncommitted changes will be lost.', async () => { + const d = await (await gitPost({ action: 'reset' })).json(); + d.ok ? (showSuccess('Reset to HEAD'), loadGitStatus()) : showError(d.output || 'Error'); + }); + }); + + document.getElementById('gitCreateBranchBtn')?.addEventListener('click', async () => { + const branch = document.getElementById('gitNewBranch').value.trim(); + if (!branch) return; + const d = await (await gitPost({ action: 'create_branch', branch })).json(); + if (d.ok) { + showSuccess('Created ' + branch); + document.getElementById('gitNewBranch').value = ''; + loadGitBranches(); loadGitStatus(); + } else { + showError(d.output || 'Error'); + } + }); + + // Sub-tab switching + document.getElementById('gitTabLogBtn')?.addEventListener('click', () => { + document.getElementById('gitTabLogBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary'); + document.getElementById('gitTabLogBtn').classList.add('active'); + document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary'); + document.getElementById('gitTabBranchesBtn').classList.remove('active'); + document.getElementById('gitLogPanel').classList.remove('d-none'); + document.getElementById('gitBranchesPanel').classList.add('d-none'); + }); + document.getElementById('gitTabBranchesBtn')?.addEventListener('click', () => { + document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary'); + document.getElementById('gitTabBranchesBtn').classList.add('active'); + document.getElementById('gitTabLogBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary'); + document.getElementById('gitTabLogBtn').classList.remove('active'); + document.getElementById('gitBranchesPanel').classList.remove('d-none'); + document.getElementById('gitLogPanel').classList.add('d-none'); + loadGitBranches(); + }); + + loadGitStatus(); + loadGitLog(); +} + +// ── Config editor (multi-file) ──────────────────────────────────────────────── +const configPanel = document.getElementById('configPanel'); +if (configPanel) { + const pid = configPanel.dataset.projectId; + let configCM = null; + let configCurrentPath = '_config.yml'; + + // Discover available config files (root _config*.yml + themes/*/ _config.yml) + async function discoverConfigs() { + const sel = document.getElementById('configFileSelect'); + if (!sel) return; + try { + const res = await fetch(`/api/files?project_id=${pid}&path=`); + const data = await res.json(); + const yamls = (data.entries || []) + .filter(e => e.type === 'file' && /^_config.*\.ya?ml$/i.test(e.name)) + .map(e => ({ path: e.path, label: e.name + (e.name === '_config.yml' ? ' (blog)' : '') })); + // Also check themes/ for theme configs + try { + const tr = await fetch(`/api/files?project_id=${pid}&path=themes`); + const td = await tr.json(); + for (const d of (td.entries || []).filter(e => e.type === 'dir')) { + const cr = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent('themes/' + d.name)}`); + const cd = await cr.json(); + if ((cd.entries || []).some(e => e.name === '_config.yml')) { + yamls.push({ path: 'themes/' + d.name + '/_config.yml', label: d.name + ' theme config' }); + } + } + } catch(e) {} + if (yamls.length > 1) { + sel.innerHTML = yamls.map(y => + `` + ).join(''); + } + } catch(e) {} + } + + function loadConfig(path) { + configCurrentPath = path; + fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) + .then(r => r.json()) + .then(data => { + const container = document.getElementById('configEditor'); + if (data.error) { + container.innerHTML = `
    ${esc(data.error)}
    `; + return; + } + container.innerHTML = ''; + if (configCM) { try { configCM.toTextArea(); } catch(e) {} configCM = null; } + configCM = CodeMirror(container, { + value: data.content, mode: 'yaml', theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + extraKeys: { 'Ctrl-S': saveConfig, 'Cmd-S': saveConfig }, + }); + configCM.setSize('100%', 'var(--hm-tab-height)'); + requestAnimationFrame(() => requestAnimationFrame(() => configCM?.refresh())); + }); + } + + async function saveConfig() { + if (!configCM) return; + const status = document.getElementById('configSaveStatus'); + status.textContent = 'Saving…'; + const res = await fetch('/api/files?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'write', path: configCurrentPath, content: configCM.getValue() }), + }); + const data = await res.json(); + if (data.ok) { status.textContent = 'Saved.'; showSuccess('Config saved'); } + else { status.textContent = ''; showError(data.error || 'Save failed'); } + } + + document.getElementById('configSaveBtn')?.addEventListener('click', saveConfig); + document.getElementById('configFileSelect')?.addEventListener('change', function() { + loadConfig(this.value); + }); + + discoverConfigs().then(() => loadConfig('_config.yml')); +} + +// ── Full-text search ────────────────────────────────────────────────────────── + +// ── Tag/category browser ────────────────────────────────────────────────────── +const tagsPanel = document.getElementById('tagsPanel'); +if (tagsPanel) { + const pid = tagsPanel.dataset.projectId; + + fetch(`/api/tags?project_id=${pid}`) + .then(r => r.json()) + .then(data => { + document.getElementById('tagsLoading').classList.add('d-none'); + document.getElementById('tagsContent').classList.remove('d-none'); + const tags = data.tags || {}; + const cats = data.categories || {}; + document.getElementById('tagsCount').textContent = Object.keys(tags).length; + document.getElementById('catsCount').textContent = Object.keys(cats).length; + document.getElementById('tagCloud').innerHTML = renderTagCloud(tags, 'tag'); + document.getElementById('catCloud').innerHTML = renderTagCloud(cats, 'category'); + }) + .catch(() => { + document.getElementById('tagsLoading').textContent = 'Failed to load.'; + }); + + function renderTagCloud(map, kind) { + const entries = Object.entries(map); + if (!entries.length) return '
    None found.
    '; + const max = entries[0][1]; + return entries.map(([name, count]) => { + const size = Math.max(75, Math.min(160, Math.round(count / max * 100 + 60))); + const href = `/project/${pid}?tab=posts&filter=${encodeURIComponent(kind + ':' + name)}`; + return `${esc(name)}${count}`; + }).join(' '); + } +} + +// ── Command history ─────────────────────────────────────────────────────────── +document.getElementById('showHistoryBtn')?.addEventListener('click', async () => { + const runner = document.getElementById('commandRunner'); + if (!runner) return; + const pid = runner.dataset.projectId; + const res = await fetch(`/api/run?project_id=${pid}`); + const data = await res.json(); + const list = document.getElementById('cmdHistoryList'); + if (!list) return; + if (!data.history?.length) { + list.innerHTML = '
    No history yet.
    '; + } else { + list.innerHTML = data.history.map(h => ` +
    +
    + ${esc(h.cmd)} + + Exit ${h.exit_code} + +
    + ${esc(h.run_at)} + ${h.output ? `
    ${esc(h.output.substring(0,500))}
    ` : ''} +
    `).join(''); + } + bootstrap.Modal.getOrCreateInstance(document.getElementById('cmdHistoryModal')).show(); +}); + +// ── Post duplicate ──────────────────────────────────────────────────────────── +// Handled via event delegation inside loadPosts(); trigger is .btn-dup-post + +// ── Settings tab ───────────────────────────────────────────────────────────── +const settingsPanel = document.getElementById('settingsPanel'); +if (settingsPanel) { + const pid = settingsPanel.dataset.projectId; + let tplCM = null, snipCM = null; + + // ── Project rename ── + document.getElementById('projectNameSaveBtn')?.addEventListener('click', async () => { + const name = document.getElementById('projectNameInput')?.value.trim(); + if (!name) { showError('Name required'); return; } + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'update_name', id: parseInt(pid), name }), + }); + const data = await res.json(); + if (data.ok) showSuccess('Name updated'); + else showError(data.error || 'Error'); + }); + + // ── Project type ── + document.getElementById('projectTypeSaveBtn')?.addEventListener('click', async () => { + const type = document.getElementById('projectTypeSelectSettings')?.value; + if (!type) return; + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'update_type', id: parseInt(pid), type }), + }); + const data = await res.json(); + if (data.ok) location.reload(); + else showError(data.error || 'Error'); + }); + + // ── Page directories ── + document.getElementById('pageDirsSaveBtn')?.addEventListener('click', async () => { + const dirs = document.getElementById('pageDirsInput')?.value ?? ''; + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'update_setting', id: parseInt(pid), key: 'page_dirs', value: dirs }), + }); + const data = await res.json(); + if (data.ok) showSuccess('Page dirs saved'); + else showError(data.error || 'Error'); + }); + + // ── Templates ── + async function loadTemplates() { + const res = await fetch(`/api/templates?project_id=${pid}`); + const data = await res.json(); + const list = document.getElementById('templatesList'); + if (!data.templates?.length) { + list.innerHTML = '
    No templates yet.
    '; + return; + } + list.innerHTML = data.templates.map(t => ` +
    +
    +
    + ${esc(t.name)} + ${esc(t.type)} +
    + + +
    +
    `).join(''); + list.querySelectorAll('.btn-edit-template').forEach(btn => { + btn.addEventListener('click', () => openTemplateModal(btn.dataset.id, btn.dataset.name, btn.dataset.content)); + }); + list.querySelectorAll('.btn-del-template').forEach(btn => { + btn.addEventListener('click', () => confirmAction('Delete this template?', async () => { + const r = await fetch('/api/templates', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }), + }); + const d = await r.json(); + d.ok ? loadTemplates() : showError(d.error || 'Error'); + })); + }); + } + + function openTemplateModal(id, name, content) { + document.getElementById('templateId').value = id || ''; + document.getElementById('templateName').value = name || ''; + document.getElementById('templateModalTitle').textContent = id ? 'Edit template' : 'New template'; + + const container = document.getElementById('templateContentEditor'); + container.innerHTML = ''; + if (tplCM) { try { tplCM.toTextArea(); } catch(e) {} tplCM = null; } + tplCM = CodeMirror(container, { + value: content || '', mode: 'markdown', theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + }); + bootstrap.Modal.getOrCreateInstance(document.getElementById('templateModal')).show(); + setTimeout(() => tplCM && tplCM.refresh(), 200); + } + + document.getElementById('newTemplateBtn')?.addEventListener('click', () => openTemplateModal('', '', '')); + + document.getElementById('templateSaveBtn')?.addEventListener('click', async () => { + const id = document.getElementById('templateId').value; + const name = document.getElementById('templateName').value.trim(); + const content = tplCM ? tplCM.getValue() : ''; + if (!name) { showError('Name required'); return; } + const body = id + ? { action: 'update', id: parseInt(id), name, content } + : { action: 'create', project_id: parseInt(pid), name, type: 'post', content }; + const res = await fetch('/api/templates', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (data.ok) { + bootstrap.Modal.getInstance(document.getElementById('templateModal')).hide(); + showSuccess(id ? 'Template updated' : 'Template created'); + loadTemplates(); + } else { + showError(data.error || 'Error'); + } + }); + + // ── Snippets ── + async function loadSnippets() { + const res = await fetch(`/api/snippets?project_id=${pid}`); + const data = await res.json(); + const list = document.getElementById('snippetsList'); + if (!data.snippets?.length) { + list.innerHTML = '
    No snippets yet.
    '; + return; + } + list.innerHTML = data.snippets.map(s => ` +
    +
    + ${esc(s.name)} + + +
    +
    `).join(''); + list.querySelectorAll('.btn-edit-snippet').forEach(btn => { + btn.addEventListener('click', () => openSnippetModal(btn.dataset.id, btn.dataset.name, btn.dataset.content)); + }); + list.querySelectorAll('.btn-del-snippet').forEach(btn => { + btn.addEventListener('click', () => confirmAction('Delete this snippet?', async () => { + const r = await fetch('/api/snippets', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }), + }); + const d = await r.json(); + d.ok ? loadSnippets() : showError(d.error || 'Error'); + })); + }); + } + + function openSnippetModal(id, name, content) { + document.getElementById('snippetId').value = id || ''; + document.getElementById('snippetName').value = name || ''; + document.getElementById('snippetModalTitle').textContent = id ? 'Edit snippet' : 'New snippet'; + + const container = document.getElementById('snippetContentEditor'); + container.innerHTML = ''; + if (snipCM) { try { snipCM.toTextArea(); } catch(e) {} snipCM = null; } + snipCM = CodeMirror(container, { + value: content || '', mode: 'markdown', theme: 'dracula', + lineNumbers: true, lineWrapping: true, tabSize: 2, + }); + bootstrap.Modal.getOrCreateInstance(document.getElementById('snippetModal')).show(); + setTimeout(() => snipCM && snipCM.refresh(), 200); + } + + document.getElementById('newSnippetBtn')?.addEventListener('click', () => openSnippetModal('', '', '')); + + document.getElementById('snippetSaveBtn')?.addEventListener('click', async () => { + const id = document.getElementById('snippetId').value; + const name = document.getElementById('snippetName').value.trim(); + const content = snipCM ? snipCM.getValue() : ''; + if (!name) { showError('Name required'); return; } + const body = id + ? { action: 'update', id: parseInt(id), name, content } + : { action: 'create', project_id: parseInt(pid), name, content }; + const res = await fetch('/api/snippets', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (data.ok) { + bootstrap.Modal.getInstance(document.getElementById('snippetModal')).hide(); + showSuccess(id ? 'Snippet updated' : 'Snippet created'); + loadSnippets(); + } else { + showError(data.error || 'Error'); + } + }); + + if (document.getElementById('templatesList')) loadTemplates(); + if (document.getElementById('snippetsList')) loadSnippets(); +} + +// ── Audit log page ──────────────────────────────────────────────────────────── +const auditTable = document.getElementById('auditTable'); +if (auditTable) { + let auditOffset = 0; + const auditLimit = 50; + + async function loadAudit(offset = 0) { + auditOffset = offset; + const pid = document.getElementById('auditProjectFilter')?.value || ''; + const params = new URLSearchParams({ limit: auditLimit, offset }); + if (pid) params.append('project_id', pid); + + const res = await fetch('/api/audit?' + params); + const data = await res.json(); + + if (!data.entries?.length) { + auditTable.innerHTML = '
    No entries.
    '; + } else { + auditTable.innerHTML = ` + + + + ` + data.entries.map(e => ` + + + + + + + `).join('') + `
    TimeUserProjectActionDetailIP
    ${esc(e.created_at)}${esc(e.username ?? e.user_id ?? '—')}${esc(e.project_name ?? (e.project_id ? '#' + e.project_id : '—'))}${esc(e.action)}${esc(e.detail ?? '')}${esc(e.ip ?? '')}
    `; + } + + const total = data.total ?? 0; + document.getElementById('auditPrev').classList.toggle('d-none', offset === 0); + document.getElementById('auditNext').classList.toggle('d-none', offset + auditLimit >= total); + } + + document.getElementById('auditProjectFilter')?.addEventListener('change', () => loadAudit(0)); + document.getElementById('auditPrev')?.addEventListener('click', () => loadAudit(Math.max(0, auditOffset - auditLimit))); + document.getElementById('auditNext')?.addEventListener('click', () => loadAudit(auditOffset + auditLimit)); + + loadAudit(); +} + +// ── Settings: scan paths ────────────────────────────────────────────────────── +const addScanPathForm = document.getElementById('addScanPathForm'); +if (addScanPathForm) { + addScanPathForm.addEventListener('submit', async e => { + e.preventDefault(); + const fd = new FormData(addScanPathForm); + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'add_scan_path', path: fd.get('path'), depth: parseInt(fd.get('depth')) }), + }); + const data = await res.json(); + data.ok ? location.reload() : showError(data.error || 'Error'); + }); + + document.querySelectorAll('.btn-remove-scan').forEach(btn => { + btn.addEventListener('click', () => confirmAction('Remove this scan path?', async () => { + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'remove_scan_path', id: parseInt(btn.dataset.id) }), + }); + const data = await res.json(); + if (data.ok) location.reload(); + })); + }); + + document.querySelectorAll('.btn-scan').forEach(btn => { + btn.addEventListener('click', async () => { + btn.disabled = true; + const res = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'scan', path: btn.dataset.path, depth: parseInt(btn.dataset.depth) }), + }); + const data = await res.json(); + btn.disabled = false; + if (data.error) { showError(data.error); return; } + + const resultsDiv = document.getElementById('scanResults'); + const listDiv = document.getElementById('scanResultsList'); + resultsDiv.classList.remove('d-none'); + + if (!data.found.length) { listDiv.innerHTML = '

    No projects found.

    '; return; } + + listDiv.innerHTML = data.found.map(p => ` +
    +
    + ${esc(p.name)} + ${esc(p.path)} +
    + ${esc(p.type_name)} + +
    `).join(''); + + listDiv.querySelectorAll('.btn-add-found').forEach(b => { + b.addEventListener('click', async () => { + const r = await fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ name: b.dataset.name, path: b.dataset.path, type: b.dataset.type }), + }); + const d = await r.json(); + if (d.ok) { b.innerHTML = ''; b.disabled = true; b.classList.replace('btn-primary','btn-success'); } + else showError(d.error || 'Error'); + }); + }); + }); + }); + + document.getElementById('closeScanResults')?.addEventListener('click', () => { + document.getElementById('scanResults').classList.add('d-none'); + }); +} + +// ── Scratchpad: per-project notes ───────────────────────────────────────────── +(() => { + const card = document.getElementById('scratchpadCard'); + if (!card) return; + const pid = parseInt(card.dataset.projectId); + const input = document.getElementById('scratchpadInput'); + const status = document.getElementById('scratchpadStatus'); + let timer = null; + let lastSaved = input.value; + + const setStatus = (msg, cls = 'text-muted') => { + status.className = 'small ms-auto ' + cls; + status.textContent = msg; + }; + + const save = async () => { + if (input.value === lastSaved) return; + setStatus('Saving…'); + try { + const r = await fetch('/api/scratchpad?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ content: input.value }), + }); + const j = await r.json(); + if (j.ok) { + lastSaved = input.value; + setStatus('Saved', 'text-success'); + setTimeout(() => setStatus(''), 1500); + } else { + setStatus(j.error || 'Save failed', 'text-danger'); + } + } catch (e) { + setStatus('Save failed', 'text-danger'); + } + }; + + input.addEventListener('input', () => { + setStatus('Editing…'); + clearTimeout(timer); + timer = setTimeout(save, 800); + }); + input.addEventListener('blur', () => { clearTimeout(timer); save(); }); +})(); + +// ── Recent files tab ────────────────────────────────────────────────────────── +(() => { + const panel = document.getElementById('recentPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const list = document.getElementById('recentList'); + + const fmtAgo = ts => { + const t = Date.parse(ts.replace(' ', 'T') + 'Z'); + const s = Math.max(1, Math.floor((Date.now() - t) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.floor(s / 60) + 'm ago'; + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; + if (s < 86400 * 7) return Math.floor(s / 86400) + 'd ago'; + return new Date(t).toLocaleDateString(); + }; + + async function load() { + const r = await fetch('/api/recent?project_id=' + pid); + const d = await r.json(); + if (!d.items?.length) { + list.innerHTML = '
    No recent files yet — open or save a file to start tracking.
    '; + return; + } + list.innerHTML = d.items.map(it => ` +
    + +
    +
    ${esc(it.name)}
    + ${esc(it.dir || '/')} +
    + ${fmtAgo(it.opened_at)} + + +
    `).join(''); + list.querySelectorAll('.btn-edit-recent').forEach(b => { + b.addEventListener('click', () => openFileEditor(pid, b.dataset.path, b.dataset.name)); + }); + list.querySelectorAll('.btn-remove-recent').forEach(b => { + b.addEventListener('click', async () => { + await fetch('/api/recent?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'remove', path: b.dataset.path }), + }); + load(); + }); + }); + } + + document.getElementById('recentClearBtn').addEventListener('click', () => { + confirmAction('Clear all recent files for this project?', async () => { + await fetch('/api/recent?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'clear' }), + }); + load(); + }); + }); + + load(); +})(); + +// ── Disk usage: dashboard cards + project page badge ───────────────────────── +(() => { + // Project header pill + const badge = document.getElementById('diskBadge'); + if (badge) { + const pid = badge.dataset.projectId; + fetch('/api/disk?project_id=' + pid).then(r => r.json()).then(d => { + if (d.size?.human) { + document.getElementById('diskBadgeValue').textContent = d.size.human; + badge.classList.remove('d-none'); + } + }); + } + + // Dashboard card pills (bulk) + const cards = [...document.querySelectorAll('.project-disk')]; + if (cards.length) { + const ids = cards.map(c => c.dataset.projectId).join(','); + fetch('/api/disk?ids=' + ids).then(r => r.json()).then(d => { + cards.forEach(c => { + const item = d.items?.[c.dataset.projectId]; + if (item?.human) { + c.querySelector('.project-disk-value').textContent = item.human; + c.classList.remove('d-none'); + } + }); + }); + } +})(); + +// ── Activity feed (dashboard) ──────────────────────────────────────────────── +(() => { + const feed = document.getElementById('activityFeed'); + if (!feed) return; + const ACTION_LABELS = { + git_commit: 'commit', git_push: 'push', git_pull: 'pull', git_merge: 'merge', + git_reset: 'reset', git_stage: 'stage', git_unstage: 'unstage', + git_discard: 'discard', git_fetch: 'fetch', + backup_download: 'backup', link_scan: 'link scan', + theme_switch: 'theme switch', theme_clone: 'theme clone', theme_delete: 'theme delete', + theme_git_pull: 'theme pull', theme_git_push: 'theme push', theme_git_fetch: 'theme fetch', + plugin_install: 'plugin install', plugin_uninstall: 'plugin uninstall', + scheduled_build: 'scheduled build', + file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload', + post_create: 'post created', post_delete: 'post deleted', + post_publish: 'post published', post_duplicate: 'post duplicated', + draft_create: 'draft created', draft_update: 'draft updated', + draft_delete: 'draft deleted', draft_publish: 'draft published', + command_run: 'command run', + project_add: 'project added', project_delete: 'project removed', + project_rename: 'project renamed', project_type_change: 'type changed', + project_pin: 'pinned', project_unpin: 'unpinned', + project_setting: 'setting changed', + scan_path_add: 'scan path added', scan_path_delete: 'scan path removed', + schedule_create: 'schedule added', schedule_update: 'schedule updated', + schedule_delete: 'schedule removed', + template_create: 'template added', template_update: 'template updated', + template_delete: 'template deleted', + snippet_create: 'snippet added', snippet_update: 'snippet updated', + snippet_delete: 'snippet deleted', + }; + const ACTION_ICONS = { + git_commit: 'bi-git', git_push: 'bi-arrow-up-circle', git_pull: 'bi-arrow-down-circle', + git_fetch: 'bi-arrow-repeat', git_merge: 'bi-sign-merge-right', + git_reset: 'bi-arrow-counterclockwise', + git_stage: 'bi-plus-circle', git_unstage: 'bi-dash-circle', git_discard: 'bi-x-circle', + backup_download: 'bi-download', link_scan: 'bi-link-45deg', + theme_switch: 'bi-palette', theme_clone: 'bi-cloud-download', theme_delete: 'bi-trash', + theme_git_pull: 'bi-arrow-down-circle', theme_git_push: 'bi-arrow-up-circle', + theme_git_fetch: 'bi-arrow-repeat', + plugin_install: 'bi-puzzle', plugin_uninstall: 'bi-puzzle', + scheduled_build: 'bi-clock', + file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload', + post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x', + post_publish: 'bi-send', post_duplicate: 'bi-files', + draft_create: 'bi-pencil-square', draft_update: 'bi-pencil-square', + draft_delete: 'bi-trash', draft_publish: 'bi-send', + command_run: 'bi-terminal', + project_add: 'bi-plus-square', project_delete: 'bi-trash', + project_rename: 'bi-pencil', project_type_change: 'bi-arrow-left-right', + project_pin: 'bi-pin-fill', project_unpin: 'bi-pin', + project_setting: 'bi-sliders', + scan_path_add: 'bi-folder-plus', scan_path_delete: 'bi-folder-minus', + schedule_create: 'bi-clock', schedule_update: 'bi-clock', schedule_delete: 'bi-clock', + template_create: 'bi-file-earmark-text', template_update: 'bi-file-earmark-text', + template_delete: 'bi-file-earmark-text', + snippet_create: 'bi-code-square', snippet_update: 'bi-code-square', + snippet_delete: 'bi-code-square', + }; + fetch('/api/audit?limit=20').then(r => r.json()).then(d => { + if (!d.entries?.length) { + feed.innerHTML = '
    No activity yet.
    '; + return; + } + feed.innerHTML = d.entries.map(e => { + const label = ACTION_LABELS[e.action] || e.action; + const icon = ACTION_ICONS[e.action] || 'bi-circle'; + const proj = e.project_name + ? `${esc(e.project_name)}` + : ''; + const detail = e.detail + ? `${esc(e.detail)}` : ''; + return `
    + + ${esc(label)} + ${proj} + ${detail} + ${esc(e.created_at)} +
    `; + }).join(''); + }); +})(); + +// ── Broken link checker (links tab) ────────────────────────────────────────── +(() => { + const panel = document.getElementById('linksPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const wrap = document.getElementById('linksTableWrap'); + const info = document.getElementById('linksRunInfo'); + const onlyB = document.getElementById('linksOnlyBroken'); + const btn = document.getElementById('linksScanBtn'); + + function statusBadge(code, error) { + if (error) return `err ${esc(error)}`; + if (code == null) return `?`; + if (code >= 400) return `${code}`; + if (code >= 300) return `${code}`; + return `${code}`; + } + + async function load() { + const url = '/api/links?project_id=' + pid + (onlyB.checked ? '&broken_only=1' : ''); + const d = await (await fetch(url)).json(); + if (!d.run) { + info.textContent = 'No scan run yet.'; + wrap.innerHTML = ''; + return; + } + const r = d.run; + info.innerHTML = `Last scan ${esc(r.finished_at || r.started_at)} — + ${r.total_links} links, ${r.broken} broken`; + if (!d.results.length) { + wrap.innerHTML = `
    ${onlyB.checked ? 'No broken links 🎉' : 'No links recorded.'}
    `; + return; + } + wrap.innerHTML = ` + + ${d.results.map(x => ` + + + + + `).join('')} +
    StatusURLSource
    ${statusBadge(x.status_code, x.error)}${esc(x.url)}${esc(x.source)}
    `; + } + + btn.addEventListener('click', async () => { + btn.disabled = true; + const orig = btn.innerHTML; + btn.innerHTML = 'Scanning…'; + info.textContent = 'Scanning… this can take a while for large projects.'; + wrap.innerHTML = ''; + try { + const r = await fetch('/api/links?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'scan' }), + }); + const d = await r.json(); + if (d.error) showError(d.error); + else showSuccess(`Scan done — ${d.broken} broken of ${d.total}`); + await load(); + } catch (e) { + showError(e.message); + } finally { + btn.disabled = false; + btn.innerHTML = orig; + } + }); + + onlyB.addEventListener('change', load); + load(); +})(); + +// ── Themes tab ─────────────────────────────────────────────────────────────── +(() => { + const panel = document.getElementById('themesPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const list = document.getElementById('themesList'); + + function gitChip(g) { + if (!g.has_git) return 'no git'; + const parts = [` ${esc(g.branch || '?')}`]; + if (g.dirty) parts.push('● dirty'); + if (g.ahead) parts.push(`↑${g.ahead}`); + if (g.behind) parts.push(`↓${g.behind}`); + return `${parts.join(' ')}`; + } + + function renderTheme(t) { + const g = t.git; + return `
    +
    +
    +
    + +
    ${esc(t.name)}
    + ${t.active ? 'active' : ''} +
    +
    ${gitChip(g)}
    + ${g.remote ? `
    ${esc(g.remote)}
    ` : ''} + ${g.commit ? `
    ${esc(g.commit)}
    ` : ''} +
    + +
    `; + } + + async function load() { + list.innerHTML = '
    Loading…
    '; + const d = await (await fetch('/api/themes?project_id=' + pid)).json(); + if (d.no_themes_dir) { + list.innerHTML = '
    No themes/ directory found in this project.
    '; + return; + } + if (!d.themes.length) { + list.innerHTML = '
    No themes installed yet. Clone one from a git URL.
    '; + return; + } + list.innerHTML = d.themes.map(renderTheme).join(''); + + list.querySelectorAll('.btn-theme-switch').forEach(b => { + b.addEventListener('click', async () => { + const r = await fetch('/api/themes?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'switch', name: b.dataset.name }), + }); + const j = await r.json(); + if (j.ok) { showSuccess('Theme switched to ' + b.dataset.name); load(); } + else showError(j.error || 'Switch failed'); + }); + }); + list.querySelectorAll('.btn-theme-git').forEach(b => { + b.addEventListener('click', async () => { + b.disabled = true; + const r = await fetch('/api/themes?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'git', name: b.dataset.name, op: b.dataset.op }), + }); + const j = await r.json(); + b.disabled = false; + if (j.ok) { showSuccess(b.dataset.op + ' ok'); load(); } + else showError(j.error || j.log || 'Git op failed'); + }); + }); + list.querySelectorAll('.btn-theme-delete').forEach(b => { + b.addEventListener('click', () => { + confirmAction('Delete theme "' + b.dataset.name + '"? Files will be removed from disk.', async () => { + const r = await fetch('/api/themes?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'delete', name: b.dataset.name }), + }); + const j = await r.json(); + if (j.ok) { showSuccess('Deleted'); load(); } + else showError(j.error || 'Delete failed'); + }); + }); + }); + } + + document.getElementById('cloneThemeSubmit').addEventListener('click', async () => { + const url = document.getElementById('cloneThemeUrl').value.trim(); + const name = document.getElementById('cloneThemeName').value.trim(); + const log = document.getElementById('cloneThemeLog'); + if (!url) { log.textContent = 'URL required'; return; } + log.textContent = 'Cloning…'; + const r = await fetch('/api/themes?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'clone', url, name }), + }); + const j = await r.json(); + log.textContent = j.log || (j.error || 'Done'); + if (j.ok) { + showSuccess('Cloned theme ' + j.name); + bootstrap.Modal.getInstance(document.getElementById('cloneThemeModal')).hide(); + document.getElementById('cloneThemeUrl').value = ''; + document.getElementById('cloneThemeName').value = ''; + load(); + } else { + showError(j.error || 'Clone failed'); + } + }); + + load(); +})(); + +// ── Plugins tab ────────────────────────────────────────────────────────────── +(() => { + const panel = document.getElementById('pluginsPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const list = document.getElementById('pluginsList'); + const log = document.getElementById('pluginInstallLog'); + + function renderPlugin(p) { + const installed = p.installed + ? `v${esc(p.installed)}` + : `not installed (run npm install)`; + return `
    +
    +
    +
    + +
    ${esc(p.name)}
    +
    +
    + ${installed} + range ${esc(p.version)} +
    + ${p.description ? `

    ${esc(p.description)}

    ` : ''} +
    + npm + ${p.repository ? ` · repo` : ''} + ${p.homepage && p.homepage !== p.repository ? ` · docs` : ''} +
    +
    + +
    `; + } + + async function load() { + list.innerHTML = '
    Loading…
    '; + const d = await (await fetch('/api/plugins?project_id=' + pid)).json(); + if (d.no_package_json) { + list.innerHTML = '
    No package.json found in this project.
    '; + return; + } + if (!d.plugins.length) { + list.innerHTML = '
    No hexo-* plugins installed.
    '; + return; + } + list.innerHTML = d.plugins.map(renderPlugin).join(''); + list.querySelectorAll('.btn-plugin-uninstall').forEach(b => { + b.addEventListener('click', () => { + confirmAction('Uninstall ' + b.dataset.name + '?', async () => { + b.disabled = true; + log.textContent = 'Uninstalling…'; log.classList.remove('d-none'); + const r = await fetch('/api/plugins?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'uninstall', name: b.dataset.name }), + }); + const j = await r.json(); + log.textContent = j.log || j.error || 'Done'; + if (j.ok) { showSuccess('Uninstalled'); load(); } + else showError(j.error || 'Uninstall failed'); + }); + }); + }); + } + + document.getElementById('pluginInstallBtn').addEventListener('click', async () => { + const name = document.getElementById('pluginInstallName').value.trim(); + if (!name) return; + log.textContent = 'Installing ' + name + '…'; log.classList.remove('d-none'); + const r = await fetch('/api/plugins?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'install', name }), + }); + const j = await r.json(); + log.textContent = j.log || j.error || 'Done'; + if (j.ok) { + showSuccess('Installed ' + name); + document.getElementById('pluginInstallName').value = ''; + load(); + } else { + showError(j.error || 'Install failed'); + } + }); + + load(); +})(); + +// ── Scheduled builds (settings tab) ────────────────────────────────────────── +(() => { + const panel = document.getElementById('settingsPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const list = document.getElementById('schedulesList'); + const newBtn = document.getElementById('newScheduleBtn'); + if (!list || !newBtn) return; + + async function api(body) { + const r = await fetch('/api/schedules?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + return r.json(); + } + + function bindRow(row) { + const id = parseInt(row.dataset.id); + row.querySelector('.schedule-save').addEventListener('click', async () => { + const j = await api({ + action: 'update', id, + cmd_id: row.querySelector('.schedule-cmd').value, + cron: row.querySelector('.schedule-cron').value.trim(), + is_enabled: row.querySelector('.schedule-enabled').checked, + }); + j.ok ? showSuccess('Saved') : showError(j.error || 'Save failed'); + }); + row.querySelector('.schedule-enabled').addEventListener('change', async (e) => { + await api({ action: 'update', id, is_enabled: e.target.checked }); + }); + row.querySelector('.schedule-delete').addEventListener('click', () => { + confirmAction('Delete this schedule?', async () => { + const j = await api({ action: 'delete', id }); + if (j.ok) row.remove(); + }); + }); + } + + list.querySelectorAll('.schedule-row').forEach(bindRow); + + newBtn.addEventListener('click', async () => { + const firstCmd = panel.querySelector('.schedule-cmd')?.options[0]?.value + || document.querySelector('.btn-run-cmd')?.dataset.cmd + || 'generate'; + const j = await api({ action: 'create', cmd_id: firstCmd, cron: '0 3 * * *', is_enabled: 1 }); + if (j.id) location.reload(); + else showError(j.error || 'Create failed'); + }); +})(); + +// ── Server-log analytics — Settings tab controls ──────────────────────────── +(() => { + const root = document.getElementById('analyticsSettings'); + if (!root) return; + const pid = parseInt(root.dataset.projectId); + const status = document.getElementById('analyticsStatus'); + + async function saveSetting(key, value) { + return fetch('/api/projects', { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'update_setting', id: pid, key, value }), + }).then(r => r.json()); + } + + document.getElementById('analyticsSaveBtn').addEventListener('click', async () => { + await saveSetting('analytics_log_path', document.getElementById('analyticsLogPath').value.trim()); + await saveSetting('analytics_log_format', document.getElementById('analyticsLogFormat').value); + await saveSetting('analytics_log_filter', document.getElementById('analyticsLogFilter').value.trim()); + showSuccess('Saved'); + }); + + document.getElementById('analyticsImportBtn').addEventListener('click', async () => { + const btn = document.getElementById('analyticsImportBtn'); + btn.disabled = true; + const orig = btn.innerHTML; + btn.innerHTML = 'Importing…'; + status.textContent = 'Importing…'; + const r = await fetch('/api/analytics_import?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'run' }), + }); + const j = await r.json(); + btn.disabled = false; btn.innerHTML = orig; + if (j.ok) { + showSuccess(`Imported ${j.imported} new rows (skipped ${j.skipped})`); + status.textContent = `Last import just now — added ${j.imported} rows, skipped ${j.skipped}.`; + } else { + showError(j.error || 'Import failed'); + status.textContent = 'Last import failed: ' + (j.error || 'unknown error'); + } + }); + + document.getElementById('analyticsResetBtn').addEventListener('click', () => { + confirmAction('Reset import position? Next "Import now" will re-scan the log from the start.', async () => { + const r = await fetch('/api/analytics_import?project_id=' + pid, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ action: 'reset' }), + }); + const j = await r.json(); + if (j.ok) showSuccess('Position reset'); + else showError(j.error || 'Reset failed'); + }); + }); +})(); + +// ── Analytics (project Analytics tab) ──────────────────────────────────────── +(() => { + const panel = document.getElementById('analyticsPanel'); + if (!panel) return; + const pid = parseInt(panel.dataset.projectId); + const body = document.getElementById('analyticsBody'); + const range = document.getElementById('analyticsRange'); + const refresh = document.getElementById('analyticsRefreshBtn'); + + function renderEmpty() { + body.innerHTML = `
    + No visits recorded yet. Configure the access-log path below and click + Import now to load history. +
    `; + } + + function delta(cur, prev) { + if (prev === 0) return cur === 0 ? { txt: '', cls: 'text-muted' } : { txt: 'new', cls: 'text-success' }; + const pct = Math.round((cur - prev) / prev * 100); + if (pct === 0) return { txt: '0%', cls: 'text-muted' }; + if (pct > 0) return { txt: '+' + pct + '%', cls: 'text-success' }; + return { txt: pct + '%', cls: 'text-danger' }; + } + + function dayKey(date) { return date.toISOString().slice(0, 10); } + + // SVG line chart with current + previous-period overlay. + function renderLineChart(series, prevSeries, days) { + const w = 720, h = 160, padL = 30, padR = 8, padT = 8, padB = 22; + const innerW = w - padL - padR; + const innerH = h - padT - padB; + const today = new Date(); today.setHours(0,0,0,0); + + const cur = {}; series.forEach(s => cur[s.day] = s.views); + const prv = {}; prevSeries.forEach(s => prv[s.day] = s.views); + + // Build full-day arrays so missing days appear as 0. + const curArr = [], prvArr = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); d.setDate(today.getDate() - i); + const dPrev = new Date(today); dPrev.setDate(today.getDate() - i - days); + curArr.push(cur[dayKey(d)] || 0); + prvArr.push(prv[dayKey(dPrev)] || 0); + } + const max = Math.max(1, ...curArr, ...prvArr); + const stepX = days > 1 ? innerW / (days - 1) : innerW; + const toXY = (i, v) => [padL + i * stepX, padT + innerH - (v / max) * innerH]; + + const path = (arr) => arr.map((v, i) => { + const [x, y] = toXY(i, v); + return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + + const fill = curArr.map((v, i) => { + const [x, y] = toXY(i, v); + return (i === 0 ? `M${x.toFixed(1)},${(padT+innerH).toFixed(1)} L${x.toFixed(1)},${y.toFixed(1)}` + : ` L${x.toFixed(1)},${y.toFixed(1)}`); + }).join('') + ` L${(padL + innerW).toFixed(1)},${(padT+innerH).toFixed(1)} Z`; + + // Y-axis ticks (0 / max/2 / max) + const ticks = [0, Math.round(max / 2), max].map(v => { + const y = padT + innerH - (v / max) * innerH; + return ` + ${v}`; + }).join(''); + + // X-axis labels — first/middle/last + const labels = [0, Math.floor((days - 1) / 2), days - 1].map(i => { + const d = new Date(today); d.setDate(today.getDate() - (days - 1 - i)); + const x = padL + i * stepX; + return `${ + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + }`; + }).join(''); + + return ` + ${ticks} + + + + ${labels} + `; + } + + // 24-hour bar chart + function renderHours(hours) { + const byHour = {}; hours.forEach(h => byHour[h.hour] = h.views); + const max = Math.max(1, ...Object.values(byHour)); + const w = 360, h = 64, gap = 2, barW = (w - 23 * gap) / 24; + const bars = []; + for (let i = 0; i < 24; i++) { + const v = byHour[i] || 0; + const bh = (v / max) * (h - 14); + const x = i * (barW + gap); + const y = h - 12 - bh; + bars.push(``); + } + const labels = [0, 6, 12, 18].map(i => { + const x = i * (barW + gap) + barW / 2; + return `${i}h`; + }).join(''); + return ` + ${bars.join('')}${labels} + `; + } + + function renderTable(rows, html) { + if (!rows.length) return '
    '; + return html; + } + + async function load() { + body.innerHTML = '
    Loading…
    '; + const days = parseInt(range.value); + const r = await fetch(`/api/analytics?project_id=${pid}&days=${days}`); + const d = await r.json(); + if (d.error) { body.innerHTML = `
    ${esc(d.error)}
    `; return; } + if (d.all_time.views === 0) { renderEmpty(); return; } + + const dV = delta(d.window.views, d.previous.views); + const dU = delta(d.window.uniques, d.previous.uniques); + + const top = d.top_pages.map(p => ` + + ${esc(p.path)} + ${p.views} + ${p.uniques} + `).join(''); + + const refs = d.top_refs.length + ? d.top_refs.map(r => ` + ${esc(r.referrer)} + ${r.views} + `).join('') + : ''; + + const rows404 = (d.top_404s || []).map(r => ` + ${esc(r.path)} + ${r.hits} + ${esc((r.last_hit || '').replace('T', ' ').slice(0, 16))} + `).join(''); + + body.innerHTML = ` +
    +
    +
    +
    Views (last ${d.days}d)
    +
    ${d.window.views}
    + ${dV.txt} + vs prev ${d.days}d (${d.previous.views}) +
    +
    +
    +
    +
    + Unique visitors +
    +
    ${d.window.uniques}
    + ${dU.txt} + vs prev (${d.previous.uniques}) +
    +
    +
    +
    +
    All-time views
    +
    ${d.all_time.views}
    + ${d.all_time.uniques} uniques +
    +
    +
    +
    +
    Tracking since
    +
    ${esc((d.all_time.first_seen || '—').slice(0, 10))}
    + last hit ${esc((d.all_time.last_seen || '—').slice(0, 16).replace('T', ' '))} +
    +
    +
    + +
    +
    + last ${d.days}d + — — previous ${d.days}d +
    + ${renderLineChart(d.series, d.prev_series || [], d.days)} +
    + +
    +
    +
    Top pages
    + + + ${top} +
    PathViewsUniques
    +
    +
    +
    Referrers
    + ${renderTable(d.top_refs, ` + + ${refs} +
    FromViews
    `)} + +
    Hour of day
    +
    ${renderHours(d.hours || [])}
    +
    +
    + + ${rows404 ? ` +
    + + Top 404s in this window +
    + + + ${rows404} +
    PathHitsLast seen
    ` : ''}`; + } + + range.addEventListener('change', load); + refresh?.addEventListener('click', load); + load(); +})(); + +// ── Project sidebar collapse toggle ────────────────────────────────────────── +(() => { + const sb = document.getElementById('projectSidebar'); + if (!sb) return; + const KEY = 'hackmancms_sidebar_collapsed'; + if (localStorage.getItem(KEY) === '1') sb.classList.add('collapsed'); + document.getElementById('sidebarToggle')?.addEventListener('click', () => { + sb.classList.toggle('collapsed'); + localStorage.setItem(KEY, sb.classList.contains('collapsed') ? '1' : '0'); + }); +})(); + +// ── Keyboard shortcuts ─────────────────────────────────────────────────────── +(() => { + let chord = null; + let chordTimer = null; + const isTyping = () => { + const el = document.activeElement; + if (!el) return false; + if (el.isContentEditable) return true; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + }; + + function handle(key) { + if (chord === 'g') { + chord = null; + if (key === 'd') location.href = '/'; + else if (key === 's') location.href = '/settings'; + else if (key === 'a') location.href = '/audit'; + return; + } + if (key === '?') { + const m = document.getElementById('shortcutsModal'); + if (m) bootstrap.Modal.getOrCreateInstance(m).show(); + return; + } + if (key === 'g') { + chord = 'g'; + clearTimeout(chordTimer); + chordTimer = setTimeout(() => { chord = null; }, 1200); + return; + } + // Project page only + if (key === 'n') { + const btn = document.getElementById('newItemBtn'); + if (btn) { btn.click(); return; } + } + if (key === 'b') { + const btn = document.querySelector('.btn-run-cmd[data-cmd="generate"]'); + if (btn) { btn.click(); showSuccess('Triggered generate'); return; } + } + } + + document.addEventListener('keydown', e => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (isTyping()) return; + if (e.key === '?') { e.preventDefault(); handle('?'); return; } + if (/^[a-z]$/.test(e.key)) handle(e.key); + }); +})(); + +// ── Restore persisted editor tabs on page load ─────────────────────────────── +window.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { try { _restoreTabState(); } catch(e) {} }, 50); +}); diff --git a/web/assets/js/milkdown-mount.js b/web/assets/js/milkdown-mount.js new file mode 100644 index 0000000..df52bfb --- /dev/null +++ b/web/assets/js/milkdown-mount.js @@ -0,0 +1,362 @@ +/** + * milkdown-mount.js — auto-mount a Milkdown WYSIWYG editor on any + *