From 60ca58f5baee61405134b013b26f411775ef51d4 Mon Sep 17 00:00:00 2001 From: Bashy Date: Sun, 3 May 2026 20:56:15 +0300 Subject: [PATCH] Init --- .gitignore | 39 + CLAUDE.md | 374 +++ README.md | 114 + VERSION | 1 + bin/deploy.sh | 75 + bin/import-site-logs.php | 289 +++ bin/migrate.php | 10 + bin/run-schedules.php | 86 + config/config.php | 7 + data/.gitkeep | 0 deploylocal.sh | 52 + lib/Audit.php | 9 + lib/Auth.php | 46 + lib/DB.php | 41 + lib/ProjectTypes.php | 39 + lib/bootstrap.php | 38 + lib/project-types/GenericProject.php | 9 + lib/project-types/HexoProject.php | 29 + lib/project-types/ProjectTypeBase.php | 25 + lib/project-types/StorageProject.php | 17 + lib/project-types/WebsiteProject.php | 17 + sql/001_initial.sql | 29 + sql/002_milestone4.sql | 46 + sql/003_db_drafts.sql | 11 + sql/004_scratchpad.sql | 1 + sql/005_milestone5.sql | 44 + sql/006_analytics.sql | 15 + sql/007_analytics_status.sql | 5 + sql/008_analytics_rollups.sql | 37 + views/_footer.php | 44 + views/_header.php | 65 + views/audit.php | 31 + views/dashboard.php | 128 + views/error.php | 24 + views/login.php | 51 + views/project/_tab_analytics.php | 105 + views/project/_tab_config.php | 15 + views/project/_tab_dashboard.php | 44 + views/project/_tab_drafts.php | 21 + views/project/_tab_git.php | 84 + views/project/_tab_links.php | 30 + views/project/_tab_notes.php | 11 + views/project/_tab_plugins.php | 27 + views/project/_tab_recent.php | 12 + views/project/_tab_search.php | 9 + views/project/_tab_settings.php | 231 ++ views/project/_tab_tags.php | 19 + views/project/_tab_themes.php | 46 + views/project/view.php | 431 ++++ views/settings.php | 84 + web/.htaccess | 4 + web/api/analytics.php | 187 ++ web/api/analytics_import.php | 39 + web/api/audit.php | 34 + web/api/auth.php | 48 + web/api/backup.php | 68 + web/api/disk.php | 46 + web/api/drafts.php | 111 + web/api/files.php | 122 + web/api/git.php | 221 ++ web/api/links.php | 204 ++ web/api/plugins.php | 87 + web/api/posts.php | 306 +++ web/api/projects.php | 146 ++ web/api/recent.php | 72 + web/api/run.php | 62 + web/api/schedules.php | 68 + web/api/scratchpad.php | 26 + web/api/search.php | 32 + web/api/snippets.php | 58 + web/api/tags.php | 58 + web/api/templates.php | 59 + web/api/themes.php | 154 ++ web/api/track.php | 33 + web/api/upload.php | 110 + web/assets/css/app.css | 440 ++++ web/assets/img/logo.png | Bin 0 -> 38191 bytes web/assets/js/app.js | 3236 +++++++++++++++++++++++++ web/assets/js/milkdown-mount.js | 362 +++ web/index.php | 48 + 80 files changed, 9458 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 VERSION create mode 100755 bin/deploy.sh create mode 100644 bin/import-site-logs.php create mode 100755 bin/migrate.php create mode 100644 bin/run-schedules.php create mode 100644 config/config.php create mode 100644 data/.gitkeep create mode 100755 deploylocal.sh create mode 100644 lib/Audit.php create mode 100644 lib/Auth.php create mode 100644 lib/DB.php create mode 100644 lib/ProjectTypes.php create mode 100644 lib/bootstrap.php create mode 100644 lib/project-types/GenericProject.php create mode 100644 lib/project-types/HexoProject.php create mode 100644 lib/project-types/ProjectTypeBase.php create mode 100644 lib/project-types/StorageProject.php create mode 100644 lib/project-types/WebsiteProject.php create mode 100644 sql/001_initial.sql create mode 100644 sql/002_milestone4.sql create mode 100644 sql/003_db_drafts.sql create mode 100644 sql/004_scratchpad.sql create mode 100644 sql/005_milestone5.sql create mode 100644 sql/006_analytics.sql create mode 100644 sql/007_analytics_status.sql create mode 100644 sql/008_analytics_rollups.sql create mode 100644 views/_footer.php create mode 100644 views/_header.php create mode 100644 views/audit.php create mode 100644 views/dashboard.php create mode 100644 views/error.php create mode 100644 views/login.php create mode 100644 views/project/_tab_analytics.php create mode 100644 views/project/_tab_config.php create mode 100644 views/project/_tab_dashboard.php create mode 100644 views/project/_tab_drafts.php create mode 100644 views/project/_tab_git.php create mode 100644 views/project/_tab_links.php create mode 100644 views/project/_tab_notes.php create mode 100644 views/project/_tab_plugins.php create mode 100644 views/project/_tab_recent.php create mode 100644 views/project/_tab_search.php create mode 100644 views/project/_tab_settings.php create mode 100644 views/project/_tab_tags.php create mode 100644 views/project/_tab_themes.php create mode 100644 views/project/view.php create mode 100644 views/settings.php create mode 100644 web/.htaccess create mode 100644 web/api/analytics.php create mode 100644 web/api/analytics_import.php create mode 100644 web/api/audit.php create mode 100644 web/api/auth.php create mode 100644 web/api/backup.php create mode 100644 web/api/disk.php create mode 100644 web/api/drafts.php create mode 100644 web/api/files.php create mode 100644 web/api/git.php create mode 100644 web/api/links.php create mode 100644 web/api/plugins.php create mode 100644 web/api/posts.php create mode 100644 web/api/projects.php create mode 100644 web/api/recent.php create mode 100644 web/api/run.php create mode 100644 web/api/schedules.php create mode 100644 web/api/scratchpad.php create mode 100644 web/api/search.php create mode 100644 web/api/snippets.php create mode 100644 web/api/tags.php create mode 100644 web/api/templates.php create mode 100644 web/api/themes.php create mode 100644 web/api/track.php create mode 100644 web/api/upload.php create mode 100644 web/assets/css/app.css create mode 100644 web/assets/img/logo.png create mode 100644 web/assets/js/app.js create mode 100644 web/assets/js/milkdown-mount.js create mode 100644 web/index.php 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 0000000000000000000000000000000000000000..5f5368417913e53b8970ab0212c25d9c1dbd5ac6 GIT binary patch literal 38191 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%0A+JMW$|HF)k*`X~;$BelkQ+mA22! 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 sBLGmdKI;Vst00^Y=_5c6? literal 0 HcmV?d00001 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 + *