commit e9f221fccb15da68e7c43c82a7c11a4d9a4e9af1
author: arianit <arianitkukaj@gmail.com>
date: 2026-03-26 21:42
parents: (none)
Nexus.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1fcee1 --- /dev/null +++ b/README.md @@ -0,0 +1,749 @@ +<div align="center"> + +<img src="https://img.shields.io/badge/PHP-8.0+-777BB4?style=for-the-badge&logo=php&logoColor=white" alt="PHP 8.0+"> +<img src="https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite"> +<img src="https://img.shields.io/badge/MySQL-4479A1?style=for-the-badge&logo=mysql&logoColor=white" alt="MySQL"> +<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="MIT License"> +<img src="https://img.shields.io/badge/Zero-Dependencies-orange?style=for-the-badge" alt="Zero Dependencies"> + +<br><br> + +``` +███╗ ██╗███████╗██╗ ██╗██╗ ██╗███████╗ +████╗ ██║██╔════╝╚██╗██╔╝██║ ██║██╔════╝ +██╔██╗ ██║█████╗ ╚███╔╝ ██║ ██║███████╗ +██║╚██╗██║██╔══╝ ██╔██╗ ██║ ██║╚════██║ +██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║ +╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ +``` + +### A modern, full-featured discussion platform built with pure PHP 8 + +**No frameworks · No npm · No Docker · Just upload and run** + +[Features](#-features) · [Quick Start](#-quick-start) · [Installation](#-installation) · [Configuration](#️-configuration) · [Addons](#-addon-system) · [API](#-api-reference) · [FAQ](#-faq) + +</div> + +--- + +## ✨ Features + +### 💬 Forum Core +- **Categories** with icons, colours, sub-categories, and per-role permissions +- **Topics & threaded replies** with pagination +- **Full Markdown editor** — Google Docs-style toolbar with SVG icons, Write/Preview tabs +- **Syntax-highlighted code blocks** via Prism.js (200+ languages, lazy-loaded only when needed) +- **Styled blockquotes** with gradient left border +- **Post permalinks** — every post gets `#post-{id}` + a 🔗 copy-link button (pagination-aware) +- **Inline image upload** — paste, drag-drop, or file picker directly in the editor +- **Media auto-embeds** — paste a URL and it becomes a player (14 platforms) +- **@mentions** with live autocomplete +- **Live search** — finds topics AND post content, links directly to the matching post on the correct page + +### 🔐 Roles & Permissions + +| Role | Level | Can Do | +|---|---|---| +| **Guest** | 0 | Read public categories | +| **Member** | 10 | Post, reply, like, message, friend | +| **Moderator** | 20 | + Pin/close topics, edit any post | +| **Admin** | 30 | Full access + admin panel | + +**Per-category permissions** — set independently for reading, posting, and replying: + +| Permission | Options | +|---|---| +| Who can **read** | 🌐 Everyone · 👤 Members · 🛡️ Moderators+ · 👑 Admins | +| Who can **post topics** | Same four options | +| Who can **reply** | Same four options | + +### ⭐ Karma System + +Eight progressive tiers earned through activity: + +| Tier | Points | Icon | +|---|---|---| +| Newcomer | 0–9 | 🌱 | +| Member | 10–49 | 💬 | +| Regular | 50–99 | ⭐ | +| Contributor | 100–249 | 🌟 | +| Veteran | 250–499 | 🔥 | +| Expert | 500–999 | 💎 | +| Elite | 1000–2499 | 👑 | +| Legend | 2500+ | 🏆 | + +Admins can manually adjust karma (Add / Subtract / Set) with an optional reason that notifies the user. + +### 📬 Private Messages +- Inbox/Sent with unread badges +- Conversation threads displayed as chat bubbles +- Read receipts (✓ sent · ✓✓ read) +- Online status indicator (green if active in last 5 min) +- Live user search autocomplete + +### 🔍 Search +- **Topics tab** — title matches +- **Posts tab** — content matches, jumps directly to the exact post on the correct page +- **Users tab** — username + bio search +- Live header dropdown shows topic + post results simultaneously + +### 🧩 Addon System +Extend the forum by dropping a folder into `addons/` and clicking Activate. No core file edits needed. Full PHP API access with 9 event hooks. + +### 🔒 Security +- CSRF tokens on all forms and AJAX +- bcrypt password hashing (cost 12) +- Math captcha (admin toggle, separate for posts and new topics) +- Rate limiting with live countdown +- Auto-generated `.htaccess` protection for `data/` and `uploads/` +- Security headers: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, HSTS on HTTPS +- All SQL via PDO prepared statements + +### 🗄️ Database Support + +| Driver | Version | +|---|---| +| **SQLite** | 3.x — zero configuration, single file | +| **MySQL** | 5.7+ | +| **MariaDB** | 10.3+ | + +Schema migrates automatically on every request — update files and existing installs upgrade themselves. + +--- + +## 🚀 Quick Start + +### Shared Hosting (5 minutes) + +```bash +# 1. Upload to your server +scp -r forum-clean/ user@host:~/public_html/forum/ + +# 2. Set directory permissions +chmod 755 data/ public/uploads/ public/uploads/avatars/ + +# 3. Visit the installer +# https://yoursite.com/forum/install/ + +# 4. Complete the 3-step wizard, then remove /install/ +rm -rf install/ +``` + +### Local Development + +```bash +# PHP built-in server — SQLite, zero config +cd forum-clean/ +php -S localhost:8080 +# open http://localhost:8080/install/ +``` + +### Docker (Apache) + +```dockerfile +# Dockerfile +FROM php:8.2-apache +RUN docker-php-ext-install pdo pdo_sqlite +RUN a2enmod rewrite +COPY forum-clean/ /var/www/html/ +RUN chown -R www-data:www-data /var/www/html/data \ + /var/www/html/public/uploads +``` + +```bash +docker build -t nexus-forum . +docker run -p 8080:80 nexus-forum +# open http://localhost:8080/install/ +``` + +--- + +## 📦 Installation + +### Requirements + +| Item | Minimum | Notes | +|---|---|---| +| PHP | **8.0** | 8.2+ recommended | +| PDO | Required | `pdo_sqlite` or `pdo_mysql` | +| GD | Optional | For image thumbnails | +| Web server | Apache or Nginx | See configs below | +| Disk | 10 MB | Plus user uploads | + +### Step-by-step + +**1 — Upload files** + +The forum works at any URL path: +- `https://yoursite.com/` +- `https://yoursite.com/forum/` +- `https://yoursite.com/community/board/` + +The `BASE` path is auto-detected. No `.env` changes needed. + +**2 — Set permissions** + +```bash +chmod 755 data/ +chmod 755 public/uploads/ +chmod 755 public/uploads/avatars/ +``` + +**3 — Run the web installer** + +Visit `/install/` — the 3-step wizard: + +| Step | What happens | +|---|---| +| **1 — Requirements** | Checks PHP version, extensions, directory permissions | +| **2 — Database** | Choose SQLite or MySQL, enter site name + admin credentials | +| **3 — Done** | Writes config, runs migration, shows security checklist | + +**4 — Post-install (automatic)** + +The installer automatically creates: +- `data/.htaccess` — denies all web access to the database directory +- `public/uploads/.htaccess` — blocks PHP execution in uploads folder +- `data/db_config.php` → `chmod 0640` +- `data/forum.db` → `chmod 0640` (SQLite only) +- `data/installed.lock` — prevents re-running the installer + +### MySQL Setup + +```sql +CREATE DATABASE nexus_forum + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +CREATE USER 'nexus'@'localhost' IDENTIFIED BY 'your_strong_password'; +GRANT ALL PRIVILEGES ON nexus_forum.* TO 'nexus'@'localhost'; +FLUSH PRIVILEGES; +``` + +Then select "MySQL / MariaDB" in the installer. + +--- + +## ⚙️ Configuration + +### Apache + +```apache +<VirtualHost *:80> + ServerName forum.yoursite.com + DocumentRoot /var/www/nexus-forum + + <Directory /var/www/nexus-forum> + AllowOverride All + Require all granted + </Directory> + + # Protect database directory + <Directory /var/www/nexus-forum/data> + Require all denied + </Directory> +</VirtualHost> +``` + +### Nginx + +```nginx +server { + listen 80; + server_name forum.yoursite.com; + root /var/www/nexus-forum; + index index.php; + + # Block sensitive paths + location ~ ^/(data|includes)/ { + deny all; + return 404; + } + location ~ \.(db|sqlite|lock)$ { + deny all; + return 404; + } + # Block PHP execution in uploads + location ~ ^/public/uploads/.*\.php$ { + deny all; + } + + location ~ \.php$ { + fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location / { + try_files $uri $uri/ =404; + } +} +``` + +### Admin Settings Panel + +Visit **Admin → Settings** to configure: + +| Setting | Description | +|---|---| +| Site name & description | Header and `<title>` | +| Topics / Posts per page | Pagination sizes | +| Post captcha | Math captcha on replies (spam protection) | +| Topic captcha | Math captcha on new topics | +| Rate limiting | Seconds between posts | +| Max upload size | Image upload limit | +| Registration | Open or closed | + +--- + +## 📁 Project Structure + +``` +nexus-forum/ +│ +├── 📁 addons/ # Drop addon folders here +│ └── example-hello-world/ # Sample addon (see Addon docs) +│ +├── 📁 admin/ # Admin panel +│ ├── index.php # Dashboard +│ ├── users.php # User list +│ ├── user.php # Edit user + karma manager +│ ├── categories.php # Categories + role permissions +│ ├── topics.php # Topic moderation +│ ├── settings.php # Site settings +│ ├── themes.php # Theme switching +│ └── addons.php # Addon manager + developer docs +│ +├── 📁 api/ # JSON endpoints (POST) +│ ├── reply.php # Post a reply +│ ├── edit.php # Edit a post +│ ├── delete.php # Delete a post +│ ├── like.php # Like / unlike +│ ├── upload.php # Image upload +│ ├── notifications.php # Mark read +│ ├── friend.php # Friend requests +│ ├── karma.php # Admin karma adjust +│ ├── chat.php # Private message actions +│ ├── search.php # Live search (topics + posts) +│ ├── search_users.php # User autocomplete +│ └── topic_action.php # Pin / close / delete topic +│ +├── 📁 auth/ # login · register · logout +├── 📁 data/ # Created by installer (not web-accessible) +├── 📁 forum/ # category · topic · new-topic · search +│ +├── 📁 includes/ # Core library (not web-accessible) +│ ├── bootstrap.php # Loads everything, boots addons +│ ├── config.php # Path detection, security headers +│ ├── db.php # PDO multi-driver DB class +│ ├── functions.php # All helpers +│ ├── markdown.php # Markdown + embed renderer +│ └── addons.php # AddonManager class +│ +├── 📁 install/ # DELETE after setup +├── 📁 messages/ # inbox · compose · view +│ +├── 📁 public/ +│ ├── css/main.css # ~2400 lines — full design system +│ ├── js/app.js # ~1000 lines — all client JS +│ └── uploads/ # User images (PHP execution blocked) +│ +├── 📁 users/ # profile · edit · search +│ +├── 📁 views/partials/ +│ ├── layout.php # Header, sidebar, nav +│ ├── layout_end.php # Footer, Prism.js loader, app.js +│ ├── admin_layout.php # Admin sidebar +│ └── editor_toolbar.php # Reusable Markdown toolbar (SVG icons) +│ +└── index.php # Homepage +``` + +--- + +## 📺 Media Embeds + +Paste any of these URLs alone on a line in a post and it auto-embeds as a player: + +| Platform | Supported | +|---|---| +| YouTube | Videos, Shorts, YouTube Music | +| Vimeo | Videos | +| Twitch | Live streams, VODs | +| Dailymotion | Videos | +| Streamable | Clips | +| Rumble | Videos | +| Spotify | Tracks, albums, playlists, podcast episodes, artist pages | +| SoundCloud | Tracks | +| Loom | Screen recordings | +| CodePen | Pens | +| JSFiddle | Fiddles | +| Twitter / X | Tweets | +| TED Talks | Talks | +| Bandcamp | Tracks | + +--- + +## 🧩 Addon System + +### Installing + +1. Drop the addon folder into `addons/` +2. **Admin → Addons → ▶ Activate** + +### Creating an Addon + +**`nexus-addon.json`** — manifest (required) + +```json +{ + "name": "My Addon", + "description": "What this addon does.", + "version": "1.0.0", + "author": "Your Name", + "url": "https://yoursite.com", + "hooks": ["after_topic_created", "render_post_footer"], + "requires": { "nexus": ">=14" } +} +``` + +**`main.php`** — entry point (required) + +```php +<?php +// Runs on every request when the addon is active + +// Inject HTML below every post +addon_on('render_post_footer', function(array $post): string { + return '<div class="my-badge">✓ Verified</div>'; +}); + +// React to new topics +addon_on('after_topic_created', function(array $data): void { + // $data: topic_id, title, slug, category_id, user_id + // Call external webhook, send Slack message, etc. + // file_get_contents('https://hooks.example.com?title=' . urlencode($data['title'])); +}); + +// Filter post HTML before display +addon_on('render_post_content', function(string $html): string { + return str_replace(':-)', '😊', $html); +}); +``` + +**`install.php`** — runs on activation (optional) + +```php +<?php +DB::connect()->exec("CREATE TABLE IF NOT EXISTS my_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +)"); +cfg_set('my_addon_active', '1'); +``` + +**`uninstall.php`** — runs on deactivation (optional) + +```php +<?php +cfg_set('my_addon_active', '0'); +// DB::run("DROP TABLE IF EXISTS my_log"); // uncomment to clean up +``` + +### Hook Reference + +| Hook | Data passed | Return | Fires when | +|---|---|---|---| +| `after_topic_created` | `array` {topic_id, title, slug, category_id, user_id} | void | Topic saved | +| `after_reply_saved` | `array` {post_id, topic_id, user_id} | void | Reply posted | +| `after_user_registered` | `array` {user_id, username, email} | void | Registration | +| `render_post_content` | `string` HTML | `string` HTML | Before post output | +| `render_post_footer` | `array` post row | `string` HTML | Below post body | +| `render_topic_header` | `array` topic row | `string` HTML | Above topic | +| `user_karma_changed` | `array` {user_id, old, new, by} | void | Karma adjusted | +| `admin_nav_items` | `array` items | `array` | Admin sidebar | +| `before_page_head` | `string` HTML | `string` HTML | Inside `<head>` | + +### Addon PHP API + +```php +// Database +DB::rows("SELECT * FROM topics WHERE category_id=?", [$catId]); +DB::row("SELECT * FROM users WHERE id=?", [$uid]); +DB::insert("INSERT INTO my_log (message) VALUES (?)", [$msg]); +DB::run("UPDATE my_table SET col=? WHERE id=?", [$val, $id]); +DB::val("SELECT COUNT(*) FROM posts WHERE topic_id=?", [$tid]); + +// Current user +global $USER; // array or null + +// Settings +$val = cfg('site_name', 'My Forum'); +cfg_set('my_key', 'my_value'); + +// Notifications +add_notification($userId, 'my_type', ['key' => 'value']); + +// Karma +add_karma($userId, 10); // add 10 points + +// URL helpers +$url = u('forum/topic.php?slug=' . urlencode($slug)); +$assetUrl = asset('js/app.js'); +``` + +--- + +## 🎨 Theming + +All design tokens are CSS custom properties in `public/css/main.css`: + +```css +:root { + /* Brand colours */ + --blue: #3b82f6; + --blue-d: #2563eb; + --blue-l: #eff6ff; + --green: #22c55e; + --red: #ef4444; + --purple: #8b5cf6; + + /* Surfaces */ + --bg: #f1f5f9; /* page background */ + --surface: #ffffff; /* cards */ + --border: #e2e8f0; /* borders */ + --border-l: #f1f5f9; /* light borders */ + + /* Text */ + --text: #0f172a; + --muted: #64748b; + --faint: #94a3b8; + + /* Typography */ + --font: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', monospace; + + /* Sizing */ + --r: 6px; /* border radius */ + --r-lg: 10px; + --r-xl: 16px; + --header: 56px; + --sidebar: 220px; +} +``` + +Override any variable in a custom stylesheet, or inject one via the `before_page_head` addon hook. + +--- + +## 🔑 API Reference + +All endpoints accept `POST` (or `GET` for search) and expect a `csrf` parameter from the `NX.csrf` global. + +| Endpoint | Auth | Description | +|---|---|---| +| `POST /api/reply.php` | Member | Post a reply (`slug`, `content`) | +| `POST /api/edit.php` | Author/Admin | Edit post (`post_id`, `content`) | +| `POST /api/delete.php` | Author/Admin | Delete post (`post_id`) | +| `POST /api/like.php` | Member | Like/unlike (`post_id`) | +| `POST /api/upload.php` | Member | Upload image (`file`) → `{url}` | +| `POST /api/topic_action.php` | Mod/Admin | Pin/close/delete topic | +| `POST /api/friend.php` | Member | Friend actions (`action`, `other_id`) | +| `POST /api/karma.php` | Admin | Adjust karma (`user_id`, `amount`, `op`) | +| `POST /api/notifications.php` | Member | Mark notifications read | +| `GET /api/search.php?q=` | Public | Live search → `{topics, posts}` | +| `GET /api/search_users.php?q=` | Public | User autocomplete → `[{id, username, avatar}]` | +| `POST /api/chat.php` | Member | DM actions (`action`: send/poll/load/conversations) | + +**Quick example — posting a reply:** + +```javascript +const fd = new FormData(); +fd.append('slug', 'my-topic-slug'); +fd.append('content', 'My reply content here.'); +fd.append('csrf', NX.csrf); // NX is the global config object + +const res = await fetch(NX.base + '/api/reply.php', { method: 'POST', body: fd }); +const data = await res.json(); +// Success: { ok: true, post: { id, content, post_num, created_at, username, ... } } +// Error: { error: "message", ... } +``` + +--- + +## 🧰 Developer Reference + +### Helper Functions + +| Function | Returns | Description | +|---|---|---| +| `e($val)` | `string` | `htmlspecialchars()` — always use when outputting user data | +| `u($path)` | `string` | URL with BASE prefix | +| `asset($path)` | `string` | Public asset URL | +| `go($path)` | never | Redirect | +| `post($key, $default)` | `mixed` | `$_POST[$key] ?? $default` | +| `get($key, $default)` | `mixed` | `$_GET[$key] ?? $default` | +| `cfg($key, $default)` | `string` | Read a setting (cached) | +| `cfg_set($key, $value)` | void | Write a setting | +| `sanitise($input)` | `string` | Strip HTML/PHP/scripts from user input | +| `must_login()` | void | Redirect if not authenticated | +| `must_admin()` | void | Redirect if not admin | +| `is_admin()` | `bool` | Check admin role | +| `current_user()` | `?array` | Current user row or null | +| `csrf_input()` | `string` | `<input type="hidden" name="csrf" value="...">` | +| `csrf_ok()` | `bool` | Validate CSRF token | +| `render_post($raw)` | `string` | Render Markdown + embeds to HTML | +| `add_karma($uid, $pts)` | void | Add (or subtract) karma points | +| `karma_tier($karma)` | `array` | Tier name, icon, colour, progress | +| `add_notification($uid, $type, $data)` | void | Queue a notification | +| `can_read_category($cat)` | `bool` | Read permission check | +| `can_post_topic($cat)` | `bool` | Post permission check | +| `can_reply_topic($cat)` | `bool` | Reply permission check | +| `unique_slug($title, $table)` | `string` | Generate a unique URL slug | +| `rate_check($uid, $type)` | `array` | `{ok, wait}` | +| `rate_record($uid, $type)` | void | Record a rate-limited action | +| `addon_hook($hook, $data)` | `mixed` | Fire an addon hook | + +### Database Class + +```php +DB::rows($sql, $params) // array of rows +DB::row($sql, $params) // one row or null +DB::insert($sql, $params) // int lastInsertId +DB::run($sql, $params) // PDOStatement +DB::val($sql, $params) // scalar or null +DB::now() // cross-driver: NOW() or datetime('now') +DB::isMysql() // bool +DB::insertIgnore($table, $cols, $vals) // cross-driver INSERT IGNORE +DB::upsert($table, $keyCol, $valCol, $key, $val) // cross-driver upsert +``` + +### Adding a New Page + +```php +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); // or must_admin(), or omit for public pages + +$PAGE_TITLE = 'My Page'; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<h1>Hello, <?= e($USER['username']) ?></h1> +<p>Your karma: <?= (int)$USER['karma'] ?></p> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> +``` + +--- + +## 🛡️ Security Checklist + +After going live: + +- [ ] **Delete `install/`** — prevents re-installation +- [ ] **Verify `data/.htaccess`** — should deny all HTTP access (auto-created) +- [ ] **Verify `public/uploads/.htaccess`** — should block `.php` execution (auto-created) +- [ ] **Use HTTPS** — HSTS header is sent automatically when detected +- [ ] **MySQL users** — grant only `SELECT`, `INSERT`, `UPDATE`, `DELETE` (not `DROP`) +- [ ] **Enable captcha** — Admin → Settings → Post Captcha / Topic Captcha +- [ ] **Set rate limits** — Admin → Settings → Rate Limiting + +--- + +## ❓ FAQ + +<details> +<summary><strong>Can I run this on shared hosting without shell access?</strong></summary> + +Yes. Shared hosting is the primary target. Everything is configured through the web installer. No Composer, npm, or shell access required. +</details> + +<details> +<summary><strong>Do I need a separate database server?</strong></summary> + +No. SQLite works out of the box with zero configuration — the database is a single file in `data/`. You can switch to MySQL/MariaDB any time by re-running the installer. +</details> + +<details> +<summary><strong>How do I upgrade to a new version?</strong></summary> + +Replace all files except `data/`. The schema migration runs automatically on the first page load after the update, adding any new columns safely with `IF NOT EXISTS` / `information_schema` checks. +</details> + +<details> +<summary><strong>How do I reset a forgotten admin password?</strong></summary> + +Run from the command line in your forum directory: + +```bash +php -r " +require 'includes/bootstrap.php'; +\$hash = password_hash('new_password_here', PASSWORD_BCRYPT, ['cost' => 12]); +DB::run('UPDATE users SET password=? WHERE role=?', [\$hash, 'admin']); +echo 'Password reset successfully.'; +" +``` +</details> + +<details> +<summary><strong>Can I use this behind a reverse proxy / load balancer?</strong></summary> + +Yes. The `BASE` path is auto-detected from `DOCUMENT_ROOT` vs `SCRIPT_FILENAME`. No `.env` changes needed. For HTTPS detection behind a proxy, ensure the proxy sets `X-Forwarded-Proto: https`. +</details> + +<details> +<summary><strong>How do I back up the forum?</strong></summary> + +**SQLite:** Copy `data/forum.db` and `data/db_config.php`. + +**MySQL:** `mysqldump nexus_forum > backup.sql` + +Also back up `public/uploads/` for user images. +</details> + +<details> +<summary><strong>Why no Composer / npm?</strong></summary> + +The goal is maximum deployability. Any server running PHP 8 with PDO can run Nexus — no package manager, no build step, no Node.js. The only optional CDN dependency is Prism.js for syntax highlighting, which is lazy-loaded only when a code block is on the page. +</details> + +--- + +## 🤝 Contributing + +1. Fork the repository +2. Create a branch: `git checkout -b feature/my-feature` +3. Make your changes — test on **both SQLite and MySQL** +4. Syntax check: `find . -name "*.php" | xargs php -l` +5. Submit a pull request + +### Code Guidelines + +- **PHP 8.0+** — use `match`, arrow functions, named arguments freely +- **No raw SQL interpolation** — always use PDO prepared statements +- **Always `e()` user output** — never echo user data unescaped +- **Cross-driver SQL** — test on both SQLite and MySQL; use `DB::insertIgnore()`, `DB::upsert()`, `DB::now()` for portability +- **No external dependencies** — no Composer packages, no npm, no build step + +--- + +## 📄 License + +MIT License — free to use, modify, and distribute. + +--- + +<div align="center"> + +**Nexus Discussion** + +*Built with PHP 8 · PDO · Vanilla JS* + +*No frameworks · No build steps · No Docker required* + +**[⬆ Back to top](#nexus-discussion)** + +</div> diff --git a/addons/example-hello-world/install.php b/addons/example-hello-world/install.php new file mode 100644 index 0000000..f9f7811 --- /dev/null +++ b/addons/example-hello-world/install.php @@ -0,0 +1,4 @@ +<?php +// install.php — runs when admin activates the addon +if (!defined('NEXUS')) exit('Forbidden'); +cfg_set('hello_world_enabled', '1'); diff --git a/addons/example-hello-world/main.php b/addons/example-hello-world/main.php new file mode 100644 index 0000000..5e3433d --- /dev/null +++ b/addons/example-hello-world/main.php @@ -0,0 +1,18 @@ +<?php +/** + * Hello World — Example addon + * Appends a small note below every post footer. Demonstrates the hook system. + */ +if (!defined('NEXUS')) exit('Forbidden'); + +addon_on('render_post_footer', function (array $post): string { + // Return empty string to add nothing, or HTML to append below the post + return ''; // Disabled by default — remove the return to activate + return '<div style="font-size:11px;color:#94a3b8;margin-top:8px;padding-top:6px;' + . 'border-top:1px solid var(--border)">Hello World addon is active ✓</div>'; +}, 999); + +addon_on('after_topic_created', function (array $data): void { + // $data: topic_id, title, slug, category_id, user_id + // Example: error_log("New topic created: " . $data['title']); +}); diff --git a/addons/example-hello-world/nexus-addon.json b/addons/example-hello-world/nexus-addon.json new file mode 100644 index 0000000..6d1635b --- /dev/null +++ b/addons/example-hello-world/nexus-addon.json @@ -0,0 +1,9 @@ +{ + "name": "Hello World", + "description": "A sample addon that demonstrates the Nexus addon API. Adds a small footer note to every post.", + "version": "1.0.0", + "author": "Nexus Forum", + "url": "", + "hooks": ["render_post_footer", "after_topic_created"], + "requires": { "nexus": ">=14" } +} diff --git a/addons/example-hello-world/uninstall.php b/addons/example-hello-world/uninstall.php new file mode 100644 index 0000000..fbc02b4 --- /dev/null +++ b/addons/example-hello-world/uninstall.php @@ -0,0 +1,4 @@ +<?php +// uninstall.php — runs when admin deactivates the addon +if (!defined('NEXUS')) exit('Forbidden'); +cfg_set('hello_world_enabled', '0'); diff --git a/addons/welcome-guide/install.php b/addons/welcome-guide/install.php new file mode 100644 index 0000000..5fb35f4 --- /dev/null +++ b/addons/welcome-guide/install.php @@ -0,0 +1,3 @@ +<?php +if (!defined('NEXUS')) exit('Forbidden'); +cfg_set('welcome_guide_enabled', '1'); diff --git a/addons/welcome-guide/main.php b/addons/welcome-guide/main.php new file mode 100644 index 0000000..264cda5 --- /dev/null +++ b/addons/welcome-guide/main.php @@ -0,0 +1,90 @@ +<?php +/** + * Welcome & Formatting Guide Addon + * + * Fires on render_post_footer for post #1 of every topic. + * Shows a collapsible formatting cheatsheet under the first post. + * wgToggle() is defined in public/js/app.js — no inline script needed. + */ +if (!defined('NEXUS')) exit('Forbidden'); + +addon_on('render_post_footer', function (array $post): string { + + // Only show on the first post (the OP) in every topic + if ((int)($post['post_num'] ?? 0) !== 1) return ''; + + // Respect admin on/off toggle + if (cfg('welcome_guide_enabled', '1') !== '1') return ''; + + return ' +<div class="wg-guide" id="wgGuide"> + <button class="wg-toggle" onclick="wgToggle()" aria-expanded="false"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" + stroke-linecap="round" stroke-linejoin="round" width="13" height="13"> + <circle cx="12" cy="12" r="10"/> + <line x1="12" y1="8" x2="12" y2="12"/> + <line x1="12" y1="16" x2="12.01" y2="16"/> + </svg> + Formatting guide + <svg class="wg-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" + stroke-width="2.5" stroke-linecap="round" width="11" height="11"> + <polyline points="6 9 12 15 18 9"/> + </svg> + </button> + <div class="wg-body" style="display:none"> + <div class="wg-grid"> + + <div class="wg-section"> + <div class="wg-section-title">Text Formatting</div> + <div class="wg-row"><code class="wg-mono">**bold**</code> <span><strong>bold</strong></span></div> + <div class="wg-row"><code class="wg-mono">*italic*</code> <span><em>italic</em></span></div> + <div class="wg-row"><code class="wg-mono">~~strike~~</code> <span><del>strike</del></span></div> + <div class="wg-row"><code class="wg-mono">[text](url)</code><span><a href="#">link</a></span></div> + </div> + + <div class="wg-section"> + <div class="wg-section-title">Code</div> + <div class="wg-row"> + <code class="wg-mono">`inline`</code> + <span><code style="background:#f5f3ff;color:#7c3aed;padding:1px 5px;border-radius:4px;font-size:12px">inline</code></span> + </div> + <div class="wg-row wg-block-row"> + <div> + <code class="wg-mono">```php</code><br> + <code class="wg-mono">echo "hi";</code><br> + <code class="wg-mono">```</code> + </div> + <span>→ purple code block</span> + </div> + <div class="wg-hint">Languages: php, js, python, sql, html, css, bash…</div> + </div> + + <div class="wg-section"> + <div class="wg-section-title">Quote</div> + <div class="wg-row wg-block-row"> + <div> + <code class="wg-mono">> first line</code><br> + <code class="wg-mono">> second line</code> + </div> + <span>→ green quote block</span> + </div> + <div class="wg-section-title" style="margin-top:10px">Lists</div> + <div class="wg-row"><code class="wg-mono">- item</code> <span>bullet list</span></div> + <div class="wg-row"><code class="wg-mono">1. item</code> <span>numbered list</span></div> + </div> + + <div class="wg-section"> + <div class="wg-section-title">Other</div> + <div class="wg-row"><code class="wg-mono">## Heading</code> <span><strong>Heading</strong></span></div> + <div class="wg-row"><code class="wg-mono">@username</code> <span>mention user</span></div> + <div class="wg-row"><code class="wg-mono">---</code> <span>horizontal rule</span></div> + <div class="wg-hint" style="margin-top:8px"> + Paste a YouTube, Spotify, Twitter or Vimeo URL on its own line to auto-embed it. + </div> + </div> + + </div> + </div> +</div> +'; +}, 100); diff --git a/addons/welcome-guide/nexus-addon.json b/addons/welcome-guide/nexus-addon.json new file mode 100644 index 0000000..298460d --- /dev/null +++ b/addons/welcome-guide/nexus-addon.json @@ -0,0 +1,8 @@ +{ + "name": "Welcome & Formatting Guide", + "description": "Adds a collapsible formatting guide to the reply box showing users how to use Markdown, code blocks, quotes, and more.", + "version": "1.0.0", + "author": "Nexus Forum", + "hooks": ["render_post_footer"], + "requires": { "nexus": ">=19" } +} diff --git a/addons/welcome-guide/uninstall.php b/addons/welcome-guide/uninstall.php new file mode 100644 index 0000000..63748d8 --- /dev/null +++ b/addons/welcome-guide/uninstall.php @@ -0,0 +1,3 @@ +<?php +if (!defined('NEXUS')) exit('Forbidden'); +cfg_set('welcome_guide_enabled', '0'); diff --git a/admin/addons.php b/admin/addons.php new file mode 100644 index 0000000..a7e941b --- /dev/null +++ b/admin/addons.php @@ -0,0 +1,243 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$PAGE_TITLE = 'Addons'; +$ADMIN_PAGE = 'addons'; +$flash = null; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) { + $action = post('action'); + $slug = preg_replace('/[^a-zA-Z0-9_\-]/', '', post('slug')); + + if ($action === 'activate' && $slug) { + if (AddonManager::activate($slug)) { + $flash = ['ok', "Addon «{$slug}» activated."]; + } else { + $flash = ['err', "Failed to activate «{$slug}». Check error logs."]; + } + } elseif ($action === 'deactivate' && $slug) { + AddonManager::deactivate($slug); + $flash = ['ok', "Addon «{$slug}» deactivated."]; + } +} + +$addons = AddonManager::all(); +include __DIR__ . '/../views/partials/admin_layout.php'; +?> + +<?php if ($flash): ?> + <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div> +<?php endif; ?> + +<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px"> + <div> + <h1 style="font-size:1.3rem;font-weight:700;margin:0">🧩 Addons</h1> + <p style="color:var(--muted);font-size:14px;margin:4px 0 0">Extend your forum with addons. Place addon folders in <code>addons/</code>.</p> + </div> + <a href="#developer-docs" class="btn-ghost btn-sm">📖 Developer Docs</a> +</div> + +<?php if (empty($addons)): ?> + <div class="acard"> + <div class="acard-body" style="text-align:center;padding:40px"> + <div style="font-size:3rem;margin-bottom:12px">🧩</div> + <h2 style="font-size:1.1rem;margin-bottom:8px">No addons installed</h2> + <p style="color:var(--muted);font-size:14px;margin-bottom:16px"> + Place addon folders in <code><?= e(ROOT) ?>/addons/</code> and they will appear here. + </p> + <a href="#developer-docs" class="btn-primary">Create Your First Addon →</a> + </div> + </div> +<?php else: ?> + <div style="display:flex;flex-direction:column;gap:14px;margin-bottom:28px"> + <?php foreach ($addons as $slug => $addon): ?> + <div class="acard addon-card <?= $addon['active'] ? 'addon-active' : '' ?>"> + <div class="addon-card-inner"> + <div class="addon-info"> + <div class="addon-header"> + <span class="addon-name"><?= e($addon['name']) ?></span> + <span class="addon-version">v<?= e($addon['version']) ?></span> + <?php if ($addon['active']): ?> + <span class="addon-status-badge active">● Active</span> + <?php else: ?> + <span class="addon-status-badge inactive">○ Inactive</span> + <?php endif; ?> + </div> + <p class="addon-desc"><?= e($addon['description']) ?></p> + <div class="addon-meta"> + <span>By <strong><?= e($addon['author']) ?></strong></span> + <?php if ($addon['url']): ?> + <a href="<?= e($addon['url']) ?>" target="_blank" rel="noopener" style="font-size:12px">🔗 Website</a> + <?php endif; ?> + <span class="addon-slug-tag"><code><?= e($slug) ?></code></span> + <?php if (!empty($addon['hooks'])): ?> + <span style="font-size:12px;color:var(--muted)"> + Hooks: <?= implode(', ', array_map('htmlspecialchars', $addon['hooks'])) ?> + </span> + <?php endif; ?> + </div> + </div> + <div class="addon-actions"> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="slug" value="<?= e($slug) ?>"> + <?php if ($addon['active']): ?> + <input type="hidden" name="action" value="deactivate"> + <button class="btn-warn btn-sm" onclick="return confirm('Deactivate this addon?')"> + ⏸ Deactivate + </button> + <?php else: ?> + <input type="hidden" name="action" value="activate"> + <button class="btn-ok btn-sm">▶ Activate</button> + <?php endif; ?> + </form> + </div> + </div> + </div> + <?php endforeach; ?> + </div> +<?php endif; ?> + +<!-- ── Developer Documentation ─────────────────────── --> +<div class="acard" id="developer-docs"> + <div class="acard-head"> + <h2>📖 Developer Documentation — How to Create an Addon</h2> + </div> + <div class="acard-body addon-docs"> + + <h3>Overview</h3> + <p>Addons extend Nexus Forum without modifying core files. Each addon is a folder inside <code>addons/</code> containing at minimum a <code>nexus-addon.json</code> manifest and a <code>main.php</code> entry point.</p> + + <h3>Directory Structure</h3> + <pre><code>addons/ +└── your-addon-slug/ + ├── nexus-addon.json ← Required: manifest + ├── main.php ← Required: entry point, registers hooks + ├── install.php ← Optional: runs on first activation + ├── uninstall.php ← Optional: runs on deactivation + └── assets/ ← Optional: CSS, JS, images</code></pre> + + <h3>1. The Manifest (<code>nexus-addon.json</code>)</h3> + <pre><code>{ + "name": "My Addon", + "description": "A short description of what this addon does.", + "version": "1.0.0", + "author": "Your Name", + "url": "https://yoursite.com/addon", + "hooks": [ + "after_post_saved", + "render_post_footer" + ], + "requires": { + "nexus": ">=14" + } +}</code></pre> + + <h3>2. The Entry Point (<code>main.php</code>)</h3> + <p>Use <code>addon_on($hook, $callback)</code> to register listeners. Hooks fire in priority order (default 10). Lower number = runs first.</p> + <pre><code><?php +// main.php — loaded once per request if addon is active + +// Example: add a footer line after every post +addon_on('render_post_footer', function(array $post): string { + return '<div class="custom-footer">Addon says hi on post #' . $post['id'] . '</div>'; +}); + +// Example: run code when a new topic is created +addon_on('after_topic_created', function(array $data): void { + // $data has: topic_id, title, slug, category_id, user_id + // Do something: call external API, log it, etc. + error_log('New topic: ' . $data['title']); +}); + +// Example: filter post content before display +addon_on('render_post_content', function(string $html): string { + // Transform HTML before output + return str_replace(':-)', '😊', $html); +});</code></pre> + + <h3>3. Available Hooks</h3> + <table class="atable" style="margin-top:8px"> + <thead><tr><th>Hook</th><th>Receives</th><th>Returns</th><th>When</th></tr></thead> + <tbody> + <tr><td><code>after_topic_created</code></td><td><code>array</code> topic data</td><td><em>void</em></td><td>After a new topic is saved</td></tr> + <tr><td><code>after_reply_saved</code></td><td><code>array</code> post data</td><td><em>void</em></td><td>After a reply is posted</td></tr> + <tr><td><code>after_user_registered</code></td><td><code>array</code> user data</td><td><em>void</em></td><td>After a new user registers</td></tr> + <tr><td><code>render_post_content</code></td><td><code>string</code> HTML</td><td><code>string</code> HTML</td><td>Before post content is output (filter)</td></tr> + <tr><td><code>render_post_footer</code></td><td><code>array</code> post row</td><td><code>string</code> HTML</td><td>Below each post body</td></tr> + <tr><td><code>render_topic_header</code></td><td><code>array</code> topic row</td><td><code>string</code> HTML</td><td>Above topic content</td></tr> + <tr><td><code>admin_nav_items</code></td><td><code>array</code> nav items</td><td><code>array</code></td><td>Admin sidebar links</td></tr> + <tr><td><code>user_karma_changed</code></td><td><code>array</code> {user_id, old, new}</td><td><em>void</em></td><td>When karma is adjusted</td></tr> + <tr><td><code>before_page_head</code></td><td><code>string</code> HTML</td><td><code>string</code> HTML</td><td>Inside <head> on every page</td></tr> + </tbody> + </table> + + <h3>4. Install / Uninstall Scripts</h3> + <pre><code><?php +// install.php — runs once when admin activates the addon +// Use this to create tables, insert settings, etc. +DB::connect()->exec(" + CREATE TABLE IF NOT EXISTS my_addon_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) +"); +cfg_set('my_addon_enabled', '1');</code></pre> + + <pre><code><?php +// uninstall.php — runs when admin deactivates the addon +// Clean up tables and settings (optional — you may want to keep data) +// DB::run("DROP TABLE IF EXISTS my_addon_logs"); +cfg_set('my_addon_enabled', '0');</code></pre> + + <h3>5. Accessing Nexus APIs</h3> + <p>All Nexus helpers are available inside addon code:</p> + <pre><code>// Database +$rows = DB::rows("SELECT * FROM topics WHERE category_id=?", [1]); +DB::insert("INSERT INTO my_addon_logs (message) VALUES (?)", ['hello']); + +// Users +global $USER; // currently logged-in user (or null) + +// Settings +$siteName = cfg('site_name', 'My Forum'); +cfg_set('my_addon_setting', 'value'); + +// Notifications +add_notification($userId, 'my_addon', ['key' => 'value']); + +// Karma +add_karma($userId, 5); // award 5 karma points + +// URL helpers +$url = u('forum/topic.php?slug=hello'); // respects BASE path</code></pre> + + <h3>6. Example Addon</h3> + <p>A complete working example is included at <code>addons/example-hello-world/</code>.</p> + </div> +</div> + +<style> +.addon-card { transition:box-shadow .18s; } +.addon-card.addon-active { border-color:var(--green); } +.addon-card-inner { display:flex; align-items:flex-start; gap:16px; justify-content:space-between; flex-wrap:wrap; } +.addon-info { flex:1; min-width:0; } +.addon-header { display:flex; align-items:center; gap:10px; margin-bottom:6px; flex-wrap:wrap; } +.addon-name { font-size:15px; font-weight:700; } +.addon-version { font-size:11px; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:1px 7px; color:var(--muted); } +.addon-status-badge { font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; } +.addon-status-badge.active { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; } +.addon-status-badge.inactive { background:#f8fafc; color:#94a3b8; border:1px solid #e2e8f0; } +.addon-desc { font-size:14px; color:var(--muted); margin:0 0 8px; } +.addon-meta { display:flex; align-items:center; gap:14px; font-size:13px; color:var(--muted); flex-wrap:wrap; } +.addon-slug-tag code { background:var(--bg); padding:1px 6px; border-radius:4px; font-size:12px; border:1px solid var(--border); } +.addon-actions { flex-shrink:0; } +.addon-docs h3 { font-size:14px; font-weight:700; margin:20px 0 8px; color:var(--text); } +.addon-docs p { font-size:14px; color:var(--muted); margin-bottom:10px; line-height:1.7; } +.addon-docs pre { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:var(--r); overflow-x:auto; margin:8px 0 16px; font-size:13px; line-height:1.6; } +.addon-docs code { font-family:var(--mono); font-size:13px; } +.addon-docs p code { background:var(--bg); border:1px solid var(--border); padding:1px 6px; border-radius:4px; } +</style> + +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/categories.php b/admin/categories.php new file mode 100644 index 0000000..cc5c408 --- /dev/null +++ b/admin/categories.php @@ -0,0 +1,234 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$PAGE_TITLE = 'Categories'; +$ADMIN_PAGE = 'categories'; +$flash = null; + +// Role permission helper +function roleSelect(string $name, string $current, string $label): string { + $opts = [ + 'guest' => '🌐 Everyone (guests)', + 'member' => '👤 Members only', + 'moderator' => '🛡️ Moderators+', + 'admin' => '👑 Admins only', + ]; + $html = '<div class="fg" style="margin-bottom:8px">' + . '<label style="font-size:12px;color:var(--muted)">' . htmlspecialchars($label) . '</label>' + . '<select name="' . htmlspecialchars($name) . '" class="fi" style="padding:6px 8px;font-size:13px">'; + foreach ($opts as $val => $lbl) { + $html .= '<option value="' . $val . '"' . ($current === $val ? ' selected' : '') . '>' . $lbl . '</option>'; + } + return $html . '</select></div>'; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) { + $action = post('action'); + $validRoles = ['guest', 'member', 'moderator', 'admin']; + + if ($action === 'create') { + $name = post('name'); + if ($name) { + $readRole = in_array(post('read_role'), $validRoles) ? post('read_role') : 'guest'; + $postRole = in_array(post('post_role'), $validRoles) ? post('post_role') : 'member'; + $replyRole = in_array(post('reply_role'), $validRoles) ? post('reply_role') : 'member'; + $slug = unique_slug($name, 'categories'); + DB::insert( + 'INSERT INTO categories (name,slug,description,color,icon,position,parent_id,read_role,post_role,reply_role) + VALUES (?,?,?,?,?,?,?,?,?,?)', + [$name, $slug, post('desc'), post('color', '#3b82f6'), post('icon', '💬'), + (int)post('pos', 99), post('parent_id') ?: null, $readRole, $postRole, $replyRole] + ); + $flash = ['ok', 'Category created!']; + } + } elseif ($action === 'update') { + $readRole = in_array(post('read_role'), $validRoles) ? post('read_role') : 'guest'; + $postRole = in_array(post('post_role'), $validRoles) ? post('post_role') : 'member'; + $replyRole = in_array(post('reply_role'), $validRoles) ? post('reply_role') : 'member'; + DB::run( + 'UPDATE categories SET name=?,description=?,color=?,icon=?,position=?,read_role=?,post_role=?,reply_role=? WHERE id=?', + [post('name'), post('desc'), post('color', '#3b82f6'), post('icon', '💬'), + (int)post('pos'), $readRole, $postRole, $replyRole, (int)post('id')] + ); + $flash = ['ok', 'Saved!']; + } elseif ($action === 'delete') { + $cid = (int)post('id'); + $tc = (int)DB::val('SELECT COUNT(*) FROM topics WHERE category_id=?', [$cid]); + if ($tc > 0) { + $flash = ['err', 'Cannot delete a category that has topics.']; + } else { + DB::run('DELETE FROM categories WHERE id=?', [$cid]); + $flash = ['ok', 'Deleted.']; + } + } +} + +$cats = DB::rows('SELECT * FROM categories ORDER BY position, id'); +include __DIR__ . '/../views/partials/admin_layout.php'; +?> + +<?php if ($flash): ?> + <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div> +<?php endif; ?> + +<div class="admin-two-col"> + + <!-- ── Category list ──────────────────────────── --> + <div class="acard"> + <div class="acard-head"><h2>All Categories</h2></div> + <table class="atable"> + <thead> + <tr><th>Name</th><th>Permissions</th><th>Topics</th><th>Posts</th><th></th></tr> + </thead> + <tbody> + <?php foreach ($cats as $c): ?> + <tr id="cr-<?= $c['id'] ?>"> + <td> + <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:<?= e($c['color']) ?>;margin-right:6px;vertical-align:middle"></span> + <?= e($c['icon']) ?> <?= e($c['name']) ?> + </td> + <td style="font-size:11px;color:var(--muted)"> + Read: <strong><?= e($c['read_role'] ?? 'guest') ?></strong> · + Post: <strong><?= e($c['post_role'] ?? 'member') ?></strong> · + Reply: <strong><?= e($c['reply_role'] ?? 'member') ?></strong> + </td> + <td><?= $c['topic_count'] ?></td> + <td><?= $c['post_count'] ?></td> + <td style="white-space:nowrap"> + <button class="btn-ghost btn-sm" onclick="toggleEdit(<?= $c['id'] ?>)">Edit</button> + <form method="POST" style="display:inline"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="delete"> + <input type="hidden" name="id" value="<?= $c['id'] ?>"> + <button class="btn-danger btn-sm" onclick="return confirm('Delete this category?')">Delete</button> + </form> + </td> + </tr> + + <!-- Inline edit row --> + <tr id="ce-<?= $c['id'] ?>" style="display:none;background:#f8fafc"> + <td colspan="5" style="padding:16px 18px"> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="update"> + <input type="hidden" name="id" value="<?= $c['id'] ?>"> + + <div style="display:grid;grid-template-columns:1fr 60px 80px 70px;gap:10px;margin-bottom:10px"> + <div class="fg" style="margin:0"> + <label style="font-size:12px;color:var(--muted)">Name</label> + <input type="text" name="name" value="<?= e($c['name']) ?>" class="fi" required> + </div> + <div class="fg" style="margin:0"> + <label style="font-size:12px;color:var(--muted)">Icon</label> + <input type="text" name="icon" value="<?= e($c['icon']) ?>" class="fi" style="font-size:1.3rem;text-align:center"> + </div> + <div class="fg" style="margin:0"> + <label style="font-size:12px;color:var(--muted)">Color</label> + <input type="color" name="color" value="<?= e($c['color']) ?>" class="fi" style="height:40px;padding:2px"> + </div> + <div class="fg" style="margin:0"> + <label style="font-size:12px;color:var(--muted)">Order</label> + <input type="number" name="pos" value="<?= $c['position'] ?>" class="fi" min="0"> + </div> + </div> + + <div style="margin-bottom:12px"> + <label style="font-size:12px;color:var(--muted)">Description</label> + <input type="text" name="desc" value="<?= e($c['description']) ?>" class="fi" placeholder="Short description"> + </div> + + <div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px"> + <div style="font-size:12px;font-weight:600;color:#64748b;margin-bottom:10px">🔐 Role Permissions</div> + <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px"> + <?= roleSelect('read_role', $c['read_role'] ?? 'guest', 'Who can read') ?> + <?= roleSelect('post_role', $c['post_role'] ?? 'member', 'Who can post topics') ?> + <?= roleSelect('reply_role', $c['reply_role'] ?? 'member', 'Who can reply') ?> + </div> + </div> + + <div style="display:flex;gap:8px;justify-content:flex-end"> + <button type="button" class="btn-ghost btn-sm" onclick="toggleEdit(<?= $c['id'] ?>)">Cancel</button> + <button type="submit" class="btn-primary btn-sm">Save Changes</button> + </div> + </form> + </td> + </tr> + <?php endforeach; ?> + + <?php if (empty($cats)): ?> + <tr><td colspan="5" style="text-align:center;padding:24px;color:var(--muted)">No categories yet.</td></tr> + <?php endif; ?> + </tbody> + </table> + </div> + + <!-- ── Create category ────────────────────────── --> + <div class="acard"> + <div class="acard-head"><h2>Create Category</h2></div> + <div class="acard-body"> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="create"> + + <div class="fg"> + <label>Name <span class="req">*</span></label> + <input type="text" name="name" class="fi" required placeholder="Category name"> + </div> + + <div class="fg"> + <label>Description</label> + <input type="text" name="desc" class="fi" placeholder="Short description (optional)"> + </div> + + <div style="display:grid;grid-template-columns:1fr 70px 90px 80px;gap:10px;margin-bottom:16px"> + <div class="fg" style="margin:0"> + <label>Icon</label> + <input type="text" name="icon" class="fi" value="💬" style="font-size:1.3rem;text-align:center"> + </div> + <div class="fg" style="margin:0"> + <label>Color</label> + <input type="color" name="color" class="fi" value="#3b82f6" style="height:40px;padding:2px"> + </div> + <div class="fg" style="margin:0"> + <label>Position</label> + <input type="number" name="pos" class="fi" value="99" min="0"> + </div> + </div> + + <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:16px"> + <div style="font-size:13px;font-weight:600;margin-bottom:10px">🔐 Role Permissions</div> + <div style="display:grid;grid-template-columns:1fr;gap:8px"> + <?= roleSelect('read_role', 'guest', 'Who can read this category') ?> + <?= roleSelect('post_role', 'member', 'Who can create topics') ?> + <?= roleSelect('reply_role', 'member', 'Who can reply to topics') ?> + </div> + </div> + + <div class="fg"> + <label>Parent Category <small>(optional)</small></label> + <select name="parent_id" class="fi"> + <option value="">None (top-level)</option> + <?php foreach (array_filter($cats, fn($c) => !$c['parent_id']) as $c): ?> + <option value="<?= $c['id'] ?>"><?= e($c['icon']) ?> <?= e($c['name']) ?></option> + <?php endforeach; ?> + </select> + </div> + + <button type="submit" class="btn-primary" style="width:100%">Create Category</button> + </form> + </div> + </div> + +</div> + +<script> +function toggleEdit(id) { + var row = document.getElementById('cr-' + id); + var edit = document.getElementById('ce-' + id); + var show = edit.style.display === 'none'; + edit.style.display = show ? '' : 'none'; + row.style.opacity = show ? '0.4' : '1'; +} +</script> + +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/index.php b/admin/index.php new file mode 100644 index 0000000..3e1a6be --- /dev/null +++ b/admin/index.php @@ -0,0 +1,93 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$PAGE_TITLE = 'Dashboard'; +$ADMIN_PAGE = 'dashboard'; + +$stats = [ + 'users' => (int)DB::val('SELECT COUNT(*) FROM users'), + 'topics' => (int)DB::val('SELECT COUNT(*) FROM topics'), + 'posts' => (int)DB::val("SELECT COUNT(*) FROM posts WHERE deleted=0"), + 'cats' => (int)DB::val('SELECT COUNT(*) FROM categories'), + 'u_today'=> (int)DB::val("SELECT COUNT(*) FROM users WHERE DATE(joined_at)=DATE('now')"), + 't_today'=> (int)DB::val("SELECT COUNT(*) FROM topics WHERE DATE(created_at)=DATE('now')"), + 'p_today'=> (int)DB::val("SELECT COUNT(*) FROM posts WHERE DATE(created_at)=DATE('now') AND deleted=0"), +]; +$recent_users = DB::rows('SELECT * FROM users ORDER BY joined_at DESC LIMIT 6'); +$recent_topics = DB::rows("SELECT t.*,u.username,c.name AS cat FROM topics t JOIN users u ON u.id=t.user_id JOIN categories c ON c.id=t.category_id ORDER BY t.created_at DESC LIMIT 6"); +$activity = DB::rows("SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM posts WHERE created_at>=DATE('now','-7 days') AND deleted=0 GROUP BY DATE(created_at) ORDER BY day"); + +include __DIR__ . '/../views/partials/admin_layout.php'; +?> + +<div class="stat-grid"> + <?php foreach ([ + ['👥','Total Users',$stats['users'],'+'. $stats['u_today'].' today','#3b82f6'], + ['💬','Topics', $stats['topics'],'+'. $stats['t_today'].' today','#10b981'], + ['📝','Posts', $stats['posts'],'+'. $stats['p_today'].' today','#8b5cf6'], + ['📂','Categories', $stats['cats'],'','#f59e0b'], + ] as [$icon,$label,$num,$sub,$col]): ?> + <div class="stat-card"> + <div class="stat-icon" style="background:<?= $col ?>22;color:<?= $col ?>"><?= $icon ?></div> + <div> + <div class="stat-num"><?= number_format($num) ?></div> + <div class="stat-label"><?= $label ?></div> + <?php if ($sub): ?><div class="stat-sub"><?= $sub ?></div><?php endif; ?> + </div> + </div> + <?php endforeach; ?> +</div> + +<div class="acard"> + <div class="acard-head"><h2>Post Activity — Last 7 Days</h2></div> + <div class="acard-body"><canvas id="actChart" height="70"></canvas></div> +</div> + +<div class="admin-two-col"> + <div class="acard"> + <div class="acard-head"><h2>Recent Users</h2><a href="<?= u('admin/users.php') ?>" class="btn-ghost btn-sm">All</a></div> + <table class="atable"> + <thead><tr><th>User</th><th>Role</th><th>Joined</th></tr></thead> + <tbody> + <?php foreach ($recent_users as $u): ?> + <tr> + <td><a href="<?= u('admin/user.php?id='.$u['id']) ?>">@<?= e($u['username']) ?></a></td> + <td><span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></td> + <td><span class="ago" data-ts="<?= e($u['joined_at']) ?>"></span></td> + </tr> + <?php endforeach; ?> + </tbody> + </table> + </div> + <div class="acard"> + <div class="acard-head"><h2>Recent Topics</h2><a href="<?= u('admin/topics.php') ?>" class="btn-ghost btn-sm">All</a></div> + <table class="atable"> + <thead><tr><th>Title</th><th>By</th></tr></thead> + <tbody> + <?php foreach ($recent_topics as $t): ?> + <tr> + <td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,40)) ?></a></td> + <td>@<?= e($t['username']) ?></td> + </tr> + <?php endforeach; ?> + </tbody> + </table> + </div> +</div> + +<script> +var actData = <?= json_encode($activity) ?>; +(function(){ + var s=document.createElement('script'); + s.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js'; + s.onload=function(){ + new Chart(document.getElementById('actChart'),{ + type:'bar', + data:{labels:actData.map(function(d){return d.day;}),datasets:[{label:'Posts',data:actData.map(function(d){return d.cnt;}),backgroundColor:'rgba(59,130,246,.6)',borderColor:'#3b82f6',borderWidth:1,borderRadius:4}]}, + options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}}}} + }); + }; + document.head.appendChild(s); +})(); +</script> +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/settings.php b/admin/settings.php new file mode 100644 index 0000000..a35e3ef --- /dev/null +++ b/admin/settings.php @@ -0,0 +1,159 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin_only(); +$PAGE_TITLE = 'Settings'; +$ADMIN_PAGE = 'settings'; +$saved = false; + +if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) { + // Checkboxes (default 0 if unchecked) + $checkboxes = ['allow_reg','rate_limit_enabled','post_captcha_enabled','topic_captcha_enabled','post_layout_horizontal']; + $textfields = ['site_name','site_desc','logo_url','topics_per_page','posts_per_page', + 'footer_text','custom_head','rate_limit_count','rate_limit_window', + 'max_upload_mb']; + foreach ($checkboxes as $k) cfg_set($k, isset($_POST[$k]) ? '1' : '0'); + foreach ($textfields as $k) cfg_set($k, post($k)); + $saved = true; +} + +$s = []; +foreach (DB::rows('SELECT key,value FROM settings') as $r) $s[$r['key']] = $r['value']; +include __DIR__ . '/../views/partials/admin_layout.php'; +?> +<?php if ($saved): ?><div class="alert ok">✅ Settings saved!</div><?php endif; ?> + +<form method="POST"> + <?= csrf_input() ?> + + <!-- Site Identity --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>🏠 Site Identity</h2></div> + <div class="acard-body"> + <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> + <div class="fg"><label>Site Name</label> + <input type="text" name="site_name" class="fi" value="<?= e($s['site_name']??'Nexus Forum') ?>"></div> + <div class="fg"><label>Logo URL <small>(blank = text logo)</small></label> + <input type="text" name="logo_url" class="fi" value="<?= e($s['logo_url']??'') ?>" placeholder="/public/uploads/logo.png"></div> + <div class="fg" style="grid-column:1/-1"><label>Description</label> + <textarea name="site_desc" class="fi" rows="2"><?= e($s['site_desc']??'') ?></textarea></div> + <div class="fg" style="grid-column:1/-1"><label>Footer Text <small>(HTML allowed)</small></label> + <input type="text" name="footer_text" class="fi" value="<?= e($s['footer_text']??'') ?>"></div> + </div> + </div> + </div> + + <!-- Registration --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>👥 Registration</h2></div> + <div class="acard-body"> + <label class="setting-toggle"> + <div><strong>Allow Registration</strong><p class="hint">Let new users create accounts</p></div> + <label class="toggle-sw"><input type="checkbox" name="allow_reg" <?= ($s['allow_reg']??'1')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label> + </label> + </div> + </div> + + <!-- Content --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>📄 Pagination</h2></div> + <div class="acard-body"> + <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> + <div class="fg"><label>Topics Per Page</label> + <input type="number" name="topics_per_page" class="fi" value="<?= e($s['topics_per_page']??'30') ?>" min="5" max="100"></div> + <div class="fg"><label>Posts Per Page</label> + <input type="number" name="posts_per_page" class="fi" value="<?= e($s['posts_per_page']??'20') ?>" min="5" max="100"></div> + + <div class="arow"> + <div> + <label class="form-label">Max Image Upload Size (MB)</label> + <small style="color:var(--muted);display:block">Maximum file size per uploaded image. Range: 1–50 MB.</small> + </div> + <input type="number" name="max_upload_mb" class="fi" value="<?= e($s['max_upload_mb']??'5') ?>" min="1" max="50" style="width:80px"></div> + </div> + </div> + </div> + + <!-- Rate Limiting --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>⏱️ Post Rate Limiting</h2></div> + <div class="acard-body"> + <label class="setting-toggle" style="margin-bottom:16px"> + <div><strong>Enable Rate Limiting</strong><p class="hint">Prevent users from posting too fast. Admins & moderators are exempt.</p></div> + <label class="toggle-sw"><input type="checkbox" name="rate_limit_enabled" id="rlToggle" + <?= ($s['rate_limit_enabled']??'0')==='1'?'checked':'' ?> + onchange="document.getElementById('rlSettings').style.display=this.checked?'':'none'"> + <span class="toggle-knob"></span></label> + </label> + <div id="rlSettings" style="<?= ($s['rate_limit_enabled']??'0')==='1'?'':'display:none' ?>;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:16px"> + <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> + <div class="fg"> + <label>Max Posts Per Window</label> + <input type="number" name="rate_limit_count" class="fi" value="<?= e($s['rate_limit_count']??'3') ?>" min="1" max="100"> + <span class="hint">Number of posts allowed in the time window</span> + </div> + <div class="fg"> + <label>Time Window (seconds)</label> + <input type="number" name="rate_limit_window" class="fi" value="<?= e($s['rate_limit_window']??'60') ?>" min="5" max="86400"> + <span class="hint">E.g. 60 = 1 minute, 3600 = 1 hour</span> + </div> + </div> + <div class="rate-presets"> + <span style="font-size:12px;color:#94a3b8;margin-right:6px">Presets:</span> + <button type="button" class="btn-ghost btn-sm" onclick="setRL(3,60)">Relaxed (3/min)</button> + <button type="button" class="btn-ghost btn-sm" onclick="setRL(1,30)">Moderate (1/30s)</button> + <button type="button" class="btn-ghost btn-sm" onclick="setRL(1,120)">Strict (1/2min)</button> + <button type="button" class="btn-ghost btn-sm" onclick="setRL(5,300)">5 per 5 mins</button> + </div> + </div> + </div> + </div> + + <!-- Post/Reply Captcha --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>🔒 Post/Reply Security Captcha</h2></div> + <div class="acard-body"> + <label class="setting-toggle" style="margin-bottom:12px"> + <div> + <strong>Require Math Captcha to Reply</strong> + <p class="hint">Users must solve a simple math question before posting a reply to any topic</p> + </div> + <label class="toggle-sw"><input type="checkbox" name="post_captcha_enabled" + <?= ($s['post_captcha_enabled']??'0')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label> + </label> + <label class="setting-toggle"> + <div> + <strong>Require Math Captcha to Create Topic</strong> + <p class="hint">Users must solve a math question when creating a new topic</p> + </div> + <label class="toggle-sw"><input type="checkbox" name="topic_captcha_enabled" + <?= ($s['topic_captcha_enabled']??'0')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label> + </label> + </div> + </div> + + <!-- Advanced --> + <div class="acard" style="margin-bottom:18px"> + <div class="acard-head"><h2>🔧 Advanced</h2></div> + <div class="acard-body"> + <div class="fg"> + <label>Custom <head> HTML <small>(analytics, meta tags)</small></label> + <textarea name="custom_head" class="fi" rows="4" style="font-family:var(--mono);font-size:13px" + placeholder="<!-- Google Analytics, custom meta, etc. -->"><?= e($s['custom_head']??'') ?></textarea> + </div> + </div> + </div> + + <button type="submit" class="btn-primary btn-lg">💾 Save Settings</button> +</form> + +<style> +.setting-toggle { display:flex; align-items:center; justify-content:space-between; padding:6px 0; } +.rate-presets { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; align-items:center; } +</style> +<script> +function setRL(count,window){ + document.querySelector('[name=rate_limit_count]').value = count; + document.querySelector('[name=rate_limit_window]').value = window; +} +</script> +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/themes.php b/admin/themes.php new file mode 100644 index 0000000..5952872 --- /dev/null +++ b/admin/themes.php @@ -0,0 +1,276 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin_only(); +$PAGE_TITLE = 'Themes & Templates'; +$ADMIN_PAGE = 'themes'; +$flash = null; + +if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) { + $action = post('action'); + + if ($action === 'create' || $action === 'update') { + $name = post('name'); + $css = post('css'); + if ($name) { + if ($action === 'create') { + $slug = unique_slug($name, 'themes'); + DB::insert('INSERT INTO themes (name,slug,css) VALUES (?,?,?)', [$name,$slug,$css]); + $flash = ['ok','Theme created!']; + } else { + $tid = (int)post('id'); + DB::run('UPDATE themes SET name=?,css=? WHERE id=?', [$name,$css,$tid]); + $flash = ['ok','Theme updated!']; + } + } + } elseif ($action === 'activate') { + $tid = (int)post('id'); + DB::run('UPDATE themes SET is_active=0'); + DB::run('UPDATE themes SET is_active=1 WHERE id=?', [$tid]); + $flash = ['ok','Theme activated!']; + } elseif ($action === 'deactivate') { + DB::run('UPDATE themes SET is_active=0'); + $flash = ['ok','Default theme restored.']; + } elseif ($action === 'delete') { + $tid = (int)post('id'); + DB::run('DELETE FROM themes WHERE id=?', [$tid]); + $flash = ['ok','Theme deleted.']; + } +} + +$themes = DB::rows('SELECT * FROM themes ORDER BY id'); +$editId = (int)get('edit'); +$editTheme = $editId ? DB::row('SELECT * FROM themes WHERE id=?', [$editId]) : null; + +include __DIR__ . '/../views/partials/admin_layout.php'; +?> + +<?php if ($flash): ?> + <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div> +<?php endif; ?> + +<div class="admin-two-col"> + <!-- Theme List --> + <div class="acard"> + <div class="acard-head"> + <h2>🎨 Themes</h2> + <span style="font-size:12px;color:#94a3b8">One theme active at a time</span> + </div> + <div class="acard-body" style="padding:0"> + <?php if (empty($themes)): ?> + <p style="padding:20px;color:#94a3b8;text-align:center">No themes yet. Create one below.</p> + <?php else: ?> + <?php foreach ($themes as $th): ?> + <div class="theme-row <?= $th['is_active'] ? 'theme-active' : '' ?>"> + <div class="theme-info"> + <?php if ($th['is_active']): ?> + <span class="stag active" style="margin-right:8px">● Active</span> + <?php endif; ?> + <strong><?= e($th['name']) ?></strong> + <span style="font-size:12px;color:#94a3b8;margin-left:8px"><?= strlen($th['css']) ?> chars CSS</span> + </div> + <div class="theme-btns"> + <a href="?edit=<?= $th['id'] ?>" class="btn-ghost btn-sm">Edit</a> + <?php if (!$th['is_active']): ?> + <form method="POST" style="display:inline"><?= csrf_input() ?> + <input type="hidden" name="action" value="activate"> + <input type="hidden" name="id" value="<?= $th['id'] ?>"> + <button class="btn-primary btn-sm">Activate</button> + </form> + <?php else: ?> + <form method="POST" style="display:inline"><?= csrf_input() ?> + <input type="hidden" name="action" value="deactivate"> + <button class="btn-ghost btn-sm">Deactivate</button> + </form> + <?php endif; ?> + <form method="POST" style="display:inline"><?= csrf_input() ?> + <input type="hidden" name="action" value="delete"> + <input type="hidden" name="id" value="<?= $th['id'] ?>"> + <button class="btn-danger btn-sm" onclick="return confirm('Delete theme?')">Delete</button> + </form> + </div> + </div> + <?php endforeach; ?> + <?php endif; ?> + </div> + </div> + + <!-- Create / Edit --> + <div class="acard"> + <div class="acard-head"> + <h2><?= $editTheme ? '✏️ Edit: '.e($editTheme['name']) : '+ New Theme' ?></h2> + <?php if ($editTheme): ?><a href="<?= u('admin/themes.php') ?>" class="btn-ghost btn-sm">Cancel</a><?php endif; ?> + </div> + <div class="acard-body"> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="<?= $editTheme ? 'update' : 'create' ?>"> + <?php if ($editTheme): ?> + <input type="hidden" name="id" value="<?= $editTheme['id'] ?>"> + <?php endif; ?> + + <div class="fg"> + <label>Theme Name</label> + <input type="text" name="name" class="fi" required placeholder="My Custom Theme" + value="<?= e($editTheme['name'] ?? '') ?>"> + </div> + + <div class="fg"> + <label>Custom CSS</label> + <div class="css-editor-wrap"> + <div class="css-presets"> + <span style="font-size:12px;color:#94a3b8;margin-right:6px">Presets:</span> + <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('dark')">🌙 Dark</button> + <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('warm')">🍂 Warm</button> + <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('green')">🌿 Green</button> + <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('purple')">💜 Purple</button> + </div> + <textarea name="css" id="cssEditor" class="fi css-ta" rows="18" spellcheck="false" + placeholder="/* Write your custom CSS here */ +/* Override any CSS variables or rules */ + +/* Example: Change primary color */ +:root { + --blue: #e74c3c; + --blue-d: #c0392b; + --blue-l: #fdf2f2; +} + +/* Example: Custom background */ +body { + background: #1a1a2e; + color: #e0e0e0; +}"><?= e($editTheme['css'] ?? '') ?></textarea> + </div> + </div> + + <div style="display:flex;gap:10px;align-items:center"> + <button type="submit" class="btn-primary">💾 Save Theme</button> + <button type="button" class="btn-ghost" onclick="previewTheme()">👁️ Preview</button> + <span style="font-size:12px;color:#94a3b8">Preview opens forum in new tab</span> + </div> + </form> + </div> + </div> +</div> + +<!-- CSS Variable Reference --> +<div class="acard"> + <div class="acard-head"><h2>📖 CSS Variables Reference</h2></div> + <div class="acard-body"> + <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;font-size:13px"> + <?php + $vars = [ + ['--blue','Primary color (links, buttons)','#3b82f6'], + ['--blue-d','Primary hover color','#2563eb'], + ['--blue-l','Primary light background','#eff6ff'], + ['--bg','Page background','#f1f5f9'], + ['--surface','Card/panel background','#ffffff'], + ['--border','Border color','#e2e8f0'], + ['--text','Main text color','#0f172a'], + ['--muted','Secondary text','#64748b'], + ['--green','Success/accent color','#22c55e'], + ['--red','Danger color','#ef4444'], + ['--amber','Warning color','#f59e0b'], + ['--header','Header height','56px'], + ['--sidebar','Sidebar width','210px'], + ['--font','Font family','Inter'], + ['--mono','Monospace font','JetBrains Mono'], + ['--r','Border radius','8px'], + ]; + foreach ($vars as [$var,$desc,$val]): ?> + <div style="background:#f8fafc;padding:8px 10px;border-radius:6px;border:1px solid #e2e8f0"> + <code style="color:#6366f1;font-size:12px"><?= e($var) ?></code> + <div style="font-size:11px;color:#94a3b8;margin-top:2px"><?= e($desc) ?></div> + <div style="font-size:11px;color:#64748b">Default: <em><?= e($val) ?></em></div> + </div> + <?php endforeach; ?> + </div> + </div> +</div> + +<style> +.theme-row{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid #f1f5f9;transition:background .18s} +.theme-row:hover{background:#f8fafc} +.theme-row:last-child{border-bottom:none} +.theme-active{background:#eff6ff} +.theme-info{display:flex;align-items:center;flex:1} +.theme-btns{display:flex;gap:6px;align-items:center} +.css-editor-wrap{background:#1e293b;border-radius:8px;overflow:hidden} +.css-presets{display:flex;align-items:center;gap:4px;padding:8px 12px;background:#0f172a} +.css-ta{background:#1e293b!important;color:#e2e8f0!important;font-family:var(--mono)!important;font-size:13px!important;border-radius:0!important;border:none!important;padding:14px 16px!important;min-height:300px;line-height:1.6} +.css-ta:focus{box-shadow:none!important} +</style> + +<script> +var presets = { + dark: `:root { + --blue: #6366f1; + --blue-d: #4f46e5; + --blue-l: #1e1b4b; + --bg: #0f172a; + --surface: #1e293b; + --border: #334155; + --border-l: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --faint: #64748b; +} +.site-header { background: #1e293b; border-color: #334155; } +.sidebar { background: #1e293b; border-color: #334155; } +.site-footer { background: #1e293b; border-color: #334155; } +.cat-card, .topic-row, .post { background: #1e293b; border-color: #334155; } +.reply-box { background: #1e293b; border-color: #334155; } +.tl-hdr { background: #0f172a; } +.topic-list { background: #1e293b; border-color: #334155; }`, + + warm: `:root { + --blue: #ea580c; + --blue-d: #c2410c; + --blue-l: #fff7ed; + --bg: #fdf4ec; + --surface: #fffaf5; + --border: #fed7aa; + --text: #431407; + --muted: #9a3412; +}`, + + green: `:root { + --blue: #16a34a; + --blue-d: #15803d; + --blue-l: #f0fdf4; + --bg: #f0fdf4; + --surface: #ffffff; + --border: #bbf7d0; + --text: #14532d; + --muted: #166534; +}`, + + purple: `:root { + --blue: #9333ea; + --blue-d: #7e22ce; + --blue-l: #faf5ff; + --bg: #faf5ff; + --surface: #ffffff; + --border: #e9d5ff; + --text: #3b0764; + --muted: #6b21a8; +}` +}; + +function applyPreset(name) { + document.getElementById('cssEditor').value = presets[name] || ''; +} + +function previewTheme() { + var css = document.getElementById('cssEditor').value; + var key = 'nx_preview_css'; + localStorage.setItem(key, css); + var w = window.open('<?= u('/') ?>', '_blank'); + w.addEventListener('load', function() { + w.postMessage({ type: 'nx_preview', css: css }, '*'); + }); + alert('Preview tip: the theme preview uses postMessage.\nActivate the theme to see it properly in all browsers.'); +} +</script> + +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/topics.php b/admin/topics.php new file mode 100644 index 0000000..623f97f --- /dev/null +++ b/admin/topics.php @@ -0,0 +1,56 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$PAGE_TITLE = 'Topics'; +$ADMIN_PAGE = 'topics'; +$q = get('q'); +$page = max(1,(int)get('page','1')); +$pp = 30; $off = ($page-1)*$pp; +$like = '%'.$q.'%'; +$where = $q ? "WHERE t.title LIKE ?" : ""; +$params = $q ? [$like] : []; +$topics = DB::rows("SELECT t.*,u.username,c.name AS cat FROM topics t JOIN users u ON u.id=t.user_id JOIN categories c ON c.id=t.category_id $where ORDER BY t.created_at DESC LIMIT $pp OFFSET $off", $params); +$total = (int)DB::val("SELECT COUNT(*) FROM topics t $where", $params); +$pages = max(1,(int)ceil($total/$pp)); +include __DIR__ . '/../views/partials/admin_layout.php'; +?> +<div class="admin-toolbar"> + <form method="GET" class="at-form"> + <input type="text" name="q" value="<?= e($q) ?>" placeholder="Search topics…" class="fi at-fi"> + <button type="submit" class="btn-primary">Search</button> + <?php if ($q): ?><a href="<?= u('admin/topics.php') ?>" class="btn-ghost">Clear</a><?php endif; ?> + </form> +</div> +<div class="acard"> + <table class="atable"> + <thead><tr><th>Title</th><th>Category</th><th>By</th><th>Status</th><th>Actions</th></tr></thead> + <tbody> + <?php foreach ($topics as $t): ?> + <tr> + <td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,50)) ?></a></td> + <td><?= e($t['cat']) ?></td> + <td>@<?= e($t['username']) ?></td> + <td><?= $t['pinned']?'📌':'' ?><?= $t['closed']?'🔒':'' ?><?= $t['archived']?'📦':'' ?><?= (!$t['pinned']&&!$t['closed']&&!$t['archived'])?'Active':'' ?></td> + <td> + <?php foreach ([['pin','unpin','📌'],['close','open','🔒'],['archive','unarchive','📦']] as [$a,$b,$icon]): ?> + <form method="POST" action="<?= u('api/topic_action.php') ?>" style="display:inline"> + <?= csrf_input() ?><input type="hidden" name="id" value="<?= $t['id'] ?>"> + <input type="hidden" name="action" value="<?= $t[$a==='pin'?'pinned':($a==='close'?'closed':'archived')] ? $b : $a ?>"> + <button class="btn-ghost btn-sm" title="<?= $a ?>"><?= $icon ?></button> + </form> + <?php endforeach; ?> + </td> + </tr> + <?php endforeach; ?> + <?php if (!$topics): ?><tr><td colspan="5" style="text-align:center;color:#6b7280;padding:2rem">No topics.</td></tr><?php endif; ?> + </tbody> + </table> +</div> +<?php if ($pages>1): ?> + <nav class="pager"> + <?php for($i=1;$i<=$pages;$i++): ?> + <a href="?page=<?= $i ?>&q=<?= urlencode($q) ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a> + <?php endfor; ?> + </nav> +<?php endif; ?> +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/user.php b/admin/user.php new file mode 100644 index 0000000..e03ddb6 --- /dev/null +++ b/admin/user.php @@ -0,0 +1,327 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$id = (int)get('id'); +$user = DB::row('SELECT * FROM users WHERE id=?',[$id]); +if (!$user) render_404(); +$flash = null; $newpw = null; + +// All available permissions +$ALL_PERMS = [ + 'pin_topics' => 'Pin/Unpin Topics', + 'close_topics' => 'Close/Open Topics', + 'delete_posts' => 'Delete Any Post', + 'edit_posts' => 'Edit Any Post', + 'manage_users' => 'View User List', + 'upload_images' => 'Upload Images', + 'bypass_silence' => 'Bypass Silence', +]; + +if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) { + $action = post('action'); + + if ($action === 'adjust_karma') { + $amount = (int)post('karma_amount'); + $op = post('karma_op'); // add | subtract | set + $reason = sanitise(post('karma_reason')); + if (!in_array($op, ['add','subtract','set'])) $op = 'add'; + if ($amount < 0) $amount = 0; + $oldKarma = (int)$user['karma']; + if ($op === 'set') { + $newKarma = $amount; + DB::run('UPDATE users SET karma=? WHERE id=?', [$newKarma, $id]); + } elseif ($op === 'add') { + $newKarma = $oldKarma + $amount; + DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$amount, $id]); + } else { + $newKarma = max(0, $oldKarma - $amount); + DB::run('UPDATE users SET karma=? WHERE id=?', [$newKarma, $id]); + } + if ($reason) { + add_notification($id, 'karma_admin', [ + 'from' => $USER['username'], + 'change' => $newKarma - $oldKarma, + 'new' => $newKarma, + 'reason' => $reason, + ]); + } + // Fire addon hook + addon_hook('user_karma_changed', ['user_id'=>$id,'old'=>$oldKarma,'new'=>$newKarma,'by'=>$USER['id']]); + $flash = ['ok', "Karma updated: {$oldKarma} → {$newKarma}" . ($reason ? " ({$reason})" : '')]; + $user = DB::row('SELECT * FROM users WHERE id=?', [$id]); // refresh + } + + if ($action === 'edit_profile') { + // Admin editing another user's profile details + $newUsername = trim(post('username')); + $newEmail = trim(post('email')); + $newBio = trim(post('bio')); + $newRole = post('role'); + + $errs = []; + if (strlen($newUsername)<3) $errs[]='Username too short.'; + if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) $errs[]='Invalid email.'; + if (!in_array($newRole,['member','moderator','admin'])) $newRole='member'; + + // Check uniqueness (excluding current user) + if (!$errs) { + $dup = DB::row('SELECT id FROM users WHERE (username=? OR email=?) AND id!=?',[$newUsername,$newEmail,$id]); + if ($dup) $errs[]='Username or email already taken.'; + } + + if (!$errs) { + // Build permissions array for moderators + $permsArr = []; + if ($newRole === 'moderator' || $newRole === 'admin') { + foreach (array_keys($ALL_PERMS) as $pkey) { + if (!empty($_POST['perm_'.$pkey])) $permsArr[$pkey] = true; + } + } + DB::run('UPDATE users SET username=?,email=?,bio=?,role=?,permissions=? WHERE id=?', + [$newUsername,$newEmail,$newBio,$newRole,json_encode($permsArr),$id]); + + // Handle new password + $np = post('new_password'); + if ($np) { + if (strlen($np)<8) { $flash='Password too short (8+ chars).'; } + else { + DB::run('UPDATE users SET password=? WHERE id=?',[password_hash($np,PASSWORD_BCRYPT,['cost'=>12]),$id]); + } + } + if (!$flash) { $flash='User profile updated!'; $user=DB::row('SELECT * FROM users WHERE id=?',[$id]); } + } else { + $flash = implode(' ', $errs); + } + } + elseif ($id !== $USER['id']) { + switch ($action) { + case 'suspend': DB::run('UPDATE users SET suspended=1 WHERE id=?',[$id]); $flash='User suspended.'; break; + case 'unsuspend': DB::run('UPDATE users SET suspended=0 WHERE id=?',[$id]); $flash='User unsuspended.'; break; + case 'silence': DB::run('UPDATE users SET silenced=1 WHERE id=?',[$id]); $flash='User silenced.'; break; + case 'unsilence': DB::run('UPDATE users SET silenced=0 WHERE id=?',[$id]); $flash='User unsilenced.'; break; + case 'resetpw': + $newpw = bin2hex(random_bytes(5)); + DB::run('UPDATE users SET password=? WHERE id=?',[password_hash($newpw,PASSWORD_BCRYPT,['cost'=>12]),$id]); + break; + } + $user = DB::row('SELECT * FROM users WHERE id=?',[$id]); + } +} + +$userPerms = json_decode($user['permissions'] ?? '{}', true) ?: []; +$utopics = DB::rows("SELECT t.*,c.name AS cat FROM topics t JOIN categories c ON c.id=t.category_id WHERE t.user_id=? ORDER BY t.created_at DESC LIMIT 10",[$id]); +$uposts = DB::rows("SELECT p.*,t.title AS tt,t.slug AS ts FROM posts p JOIN topics t ON t.id=p.topic_id WHERE p.user_id=? AND p.deleted=0 ORDER BY p.created_at DESC LIMIT 10",[$id]); + +$PAGE_TITLE = 'User: @'.$user['username']; +$ADMIN_PAGE = 'users'; +include __DIR__ . '/../views/partials/admin_layout.php'; +?> +<p style="margin-bottom:16px"><a href="<?= u('admin/users.php') ?>">← Back to Users</a></p> + +<?php if ($flash): ?> + <div class="alert <?= strpos($flash,'!') ? 'ok' : 'err' ?>"><?= e($flash) ?></div> +<?php endif; ?> +<?php if ($newpw): ?> + <div class="alert warn" style="font-size:14px">🔑 Password reset! New password: <strong style="font-family:monospace"><?= e($newpw) ?></strong> — share this securely with the user.</div> +<?php endif; ?> + +<div class="admin-two-col" style="margin-bottom:0"> + <!-- Edit Profile Card --> + <div class="acard"> + <div class="acard-head"><h2>✏️ Edit Profile</h2></div> + <div class="acard-body"> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="edit_profile"> + + <div class="fg"> + <label>Username</label> + <input type="text" name="username" class="fi" required value="<?= e($user['username']) ?>" minlength="3" maxlength="30"> + </div> + <div class="fg"> + <label>Email</label> + <input type="email" name="email" class="fi" required value="<?= e($user['email']) ?>"> + </div> + <div class="fg"> + <label>Bio</label> + <textarea name="bio" class="fi" rows="3" maxlength="500"><?= e($user['bio']??'') ?></textarea> + </div> + <div class="fg"> + <label>Role</label> + <select name="role" class="fi" id="roleSelect" onchange="togglePerms(this.value)" <?= $id===$USER['id']?'disabled':'' ?>> + <option value="member" <?= $user['role']==='member' ?'selected':'' ?>>Member</option> + <option value="moderator" <?= $user['role']==='moderator' ?'selected':'' ?>>Moderator</option> + <option value="admin" <?= $user['role']==='admin' ?'selected':'' ?>>Admin</option> + </select> + </div> + + <!-- Permissions (shown for moderator) --> + <div id="permsBox" style="<?= in_array($user['role'],['moderator','admin'])?'':'display:none' ?>"> + <div class="fg"> + <label>Moderator Permissions</label> + <div class="perms-grid"> + <?php foreach ($ALL_PERMS as $pk => $plabel): ?> + <label class="perm-chk"> + <input type="checkbox" name="perm_<?= $pk ?>" value="1" + <?= !empty($userPerms[$pk]) ? 'checked' : '' ?>> + <?= e($plabel) ?> + </label> + <?php endforeach; ?> + </div> + </div> + </div> + + <div class="fg"> + <label>Set New Password <small>(leave blank to keep)</small></label> + <input type="text" name="new_password" class="fi" placeholder="New password (8+ chars)" autocomplete="new-password"> + </div> + + <button type="submit" class="btn-primary">Save Changes</button> + </form> + </div> + </div> + + <!-- Quick Actions Card --> + <div> + <div class="acard" style="margin-bottom:16px"> + <div class="acard-head"><h2>👤 User Info</h2></div> + <div class="acard-body"> + <div style="display:flex;align-items:center;gap:14px;margin-bottom:16px"> + <?php if ($user['avatar']): ?> + <img src="<?= e($user['avatar']) ?>" class="av-xl" alt=""> + <?php else: ?> + <span class="av-xl av-init"><?= strtoupper($user['username'][0]) ?></span> + <?php endif; ?> + <div> + <h3 style="margin-bottom:4px">@<?= e($user['username']) ?></h3> + <p style="font-size:13px;color:#64748b;margin-bottom:6px"><?= e($user['email']) ?></p> + <span class="role-tag role-<?= e($user['role']) ?>"><?= e($user['role']) ?></span> + <?php if ($user['suspended']): ?> <span class="stag suspended">Suspended</span><?php endif; ?> + </div> + </div> + <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px"> + <div>📝 Posts: <strong><?= $user['post_count'] ?></strong></div> + <div>💬 Topics: <strong><?= $user['topic_count'] ?></strong></div> + <div>⭐ Karma: <strong><?= $user['karma'] ?></strong></div> + <div>📅 Joined: <span class="ago" data-ts="<?= e($user['joined_at']) ?>"></span></div> + </div> + <a href="<?= u('users/profile.php?u='.urlencode($user['username'])) ?>" target="_blank" class="btn-ghost btn-sm" style="margin-top:12px">View Profile →</a> + </div> + </div> + + <?php if ($id !== $USER['id']): ?> + <div class="acard" style="margin-bottom:16px"> + <div class="acard-head"><h2>⭐ Karma Management</h2> + <?php $kTier = karma_tier((int)$user['karma']); ?> + <span style="font-size:13px;color:<?= e($kTier['color']) ?>"><?= $kTier['icon'] ?> <?= e($kTier['name']) ?> — <?= number_format((int)$user['karma']) ?> pts</span> + </div> + <div class="acard-body"> + <!-- Progress bar --> + <div style="margin-bottom:14px"> + <div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px"> + <span><?= e($kTier['name']) ?> (<?= $kTier['min'] ?>)</span> + <?php if ($kTier['next']): ?> + <span><?= e(karma_tier($kTier['next'])['name']) ?> (<?= $kTier['next'] ?>)</span> + <?php else: ?> + <span>Max tier 🏆</span> + <?php endif; ?> + </div> + <div style="height:8px;background:var(--border);border-radius:4px;overflow:hidden"> + <div style="height:100%;width:<?= $kTier['progress'] ?>%;background:<?= e($kTier['color']) ?>;border-radius:4px;transition:width .4s"></div> + </div> + <div style="font-size:11px;color:var(--faint);margin-top:3px;text-align:right"><?= $kTier['progress'] ?>% to next tier</div> + </div> + + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="adjust_karma"> + <div style="display:grid;grid-template-columns:120px 1fr;gap:10px;margin-bottom:10px"> + <div class="fg" style="margin:0"> + <label style="font-size:12px">Operation</label> + <select name="karma_op" class="fi" style="padding:7px 10px"> + <option value="add">➕ Add points</option> + <option value="subtract">➖ Subtract points</option> + <option value="set">🎯 Set exact value</option> + </select> + </div> + <div class="fg" style="margin:0"> + <label style="font-size:12px">Amount</label> + <input type="number" name="karma_amount" class="fi" value="10" min="0" max="999999" + style="font-family:var(--mono);font-size:16px;text-align:center"> + </div> + </div> + <div class="fg" style="margin-bottom:10px"> + <label style="font-size:12px">Reason <small style="color:var(--faint)">(optional — notifies user)</small></label> + <input type="text" name="karma_reason" class="fi" placeholder="e.g. Helpful answer, code of conduct violation…"> + </div> + <button type="submit" class="btn-primary btn-sm">Update Karma</button> + </form> + </div> + </div> + + <div class="acard"> + <div class="acard-head"><h2>⚡ Quick Actions</h2></div> + <div class="acard-body"> + <div style="display:flex;flex-wrap:wrap;gap:8px"> + <?php if (!$user['suspended']): ?> + <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="suspend"> + <button class="btn-warn btn-sm" onclick="return confirm('Suspend this user?')">🚫 Suspend</button></form> + <?php else: ?> + <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="unsuspend"> + <button class="btn-ok btn-sm">✅ Unsuspend</button></form> + <?php endif; ?> + <?php if (!$user['silenced']): ?> + <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="silence"> + <button class="btn-warn btn-sm" onclick="return confirm('Silence this user?')">🔇 Silence</button></form> + <?php else: ?> + <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="unsilence"> + <button class="btn-ok btn-sm">🔊 Unsilence</button></form> + <?php endif; ?> + <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="resetpw"> + <button class="btn-ghost btn-sm" onclick="return confirm('Generate new random password?')">🔑 Reset PW</button></form> + </div> + </div> + </div> + <?php else: ?> + <div class="acard"><div class="acard-body"><p style="color:#64748b;font-size:13px">You cannot moderate your own account.</p></div></div> + <?php endif; ?> + </div> +</div> + +<!-- Recent topics & posts --> +<div class="admin-two-col" style="margin-top:16px"> + <div class="acard"> + <div class="acard-head"><h2>Recent Topics</h2></div> + <table class="atable"> + <thead><tr><th>Title</th><th>Category</th><th>Date</th></tr></thead> + <tbody> + <?php foreach ($utopics as $t): ?> + <tr><td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,50)) ?></a></td> + <td><?= e($t['cat']) ?></td><td><span class="ago" data-ts="<?= e($t['created_at']) ?>"></span></td></tr> + <?php endforeach; ?> + <?php if (!$utopics): ?><tr><td colspan="3" style="text-align:center;color:#94a3b8;padding:1.5rem">No topics.</td></tr><?php endif; ?> + </tbody> + </table> + </div> + <div class="acard"> + <div class="acard-head"><h2>Recent Posts</h2></div> + <table class="atable"> + <thead><tr><th>In Topic</th><th>Preview</th><th>Date</th></tr></thead> + <tbody> + <?php foreach ($uposts as $p): ?> + <tr><td><a href="<?= u('forum/topic.php?slug='.urlencode($p['ts']).'#post-'.$p['id']) ?>" target="_blank"><?= e(mb_substr($p['tt'],0,30)) ?></a></td> + <td><?= e(mb_substr($p['content'],0,60)) ?>…</td><td><span class="ago" data-ts="<?= e($p['created_at']) ?>"></span></td></tr> + <?php endforeach; ?> + <?php if (!$uposts): ?><tr><td colspan="3" style="text-align:center;color:#94a3b8;padding:1.5rem">No posts.</td></tr><?php endif; ?> + </tbody> + </table> + </div> +</div> + +<script> +function togglePerms(role) { + document.getElementById('permsBox').style.display = (role==='moderator'||role==='admin') ? '' : 'none'; +} +</script> + +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/admin/users.php b/admin/users.php new file mode 100644 index 0000000..2eb2ceb --- /dev/null +++ b/admin/users.php @@ -0,0 +1,57 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +$PAGE_TITLE = 'Users'; +$ADMIN_PAGE = 'users'; + +$q = get('q'); +$page = max(1,(int)get('page','1')); +$pp = 30; +$off = ($page-1)*$pp; +$like = '%'.$q.'%'; +$where = $q ? "WHERE username LIKE ? OR email LIKE ?" : ""; +$params = $q ? [$like,$like] : []; +$users = DB::rows("SELECT * FROM users $where ORDER BY joined_at DESC LIMIT $pp OFFSET $off", $params); +$total = (int)DB::val("SELECT COUNT(*) FROM users $where", $params); +$pages = max(1,(int)ceil($total/$pp)); + +include __DIR__ . '/../views/partials/admin_layout.php'; +?> +<div class="admin-toolbar"> + <form method="GET" class="at-form"> + <input type="text" name="q" value="<?= e($q) ?>" placeholder="Search users…" class="fi at-fi"> + <button type="submit" class="btn-primary">Search</button> + <?php if ($q): ?><a href="<?= u('admin/users.php') ?>" class="btn-ghost">Clear</a><?php endif; ?> + </form> +</div> +<div class="acard"> + <table class="atable"> + <thead><tr><th>User</th><th>Email</th><th>Role</th><th>Posts</th><th>Status</th><th>Joined</th><th></th></tr></thead> + <tbody> + <?php foreach ($users as $u): ?> + <tr> + <td>@<?= e($u['username']) ?></td> + <td><?= e($u['email']) ?></td> + <td><span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></td> + <td><?= $u['post_count'] ?></td> + <td> + <?php if ($u['suspended']): ?><span class="stag suspended">Suspended</span> + <?php elseif ($u['silenced']): ?><span class="stag silenced">Silenced</span> + <?php else: ?><span class="stag active">Active</span><?php endif; ?> + </td> + <td><span class="ago" data-ts="<?= e($u['joined_at']) ?>"></span></td> + <td><a href="<?= u('admin/user.php?id='.$u['id']) ?>" class="btn-ghost btn-sm">Manage</a></td> + </tr> + <?php endforeach; ?> + <?php if (!$users): ?><tr><td colspan="7" style="text-align:center;padding:2rem;color:#6b7280">No users found.</td></tr><?php endif; ?> + </tbody> + </table> +</div> +<?php if ($pages>1): ?> + <nav class="pager"> + <?php for($i=1;$i<=$pages;$i++): ?> + <a href="?page=<?= $i ?>&q=<?= urlencode($q) ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a> + <?php endfor; ?> + </nav> +<?php endif; ?> +<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?> diff --git a/api/chat.php b/api/chat.php new file mode 100644 index 0000000..e438efe --- /dev/null +++ b/api/chat.php @@ -0,0 +1,155 @@ +<?php +/** + * Live chat API for private messages. + * Actions: send, poll, delete, conversations + */ +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); +if (!csrf_ok()) json_out(['error' => 'CSRF'], 403); + +$action = post('action'); +$uid = (int)$USER['id']; + +/* ── Helper: build conversation_id ───────────────────── */ +function conv_id(int $a, int $b): string { + return min($a,$b) . '-' . max($a,$b); +} + +/* ── Send a message ──────────────────────────────────── */ +if ($action === 'send') { + $toId = (int)post('to_id'); + $body = sanitise(post('body')); + + if (!$toId || $toId === $uid) json_out(['error' => 'Invalid recipient'], 400); + if (!$body) json_out(['error' => 'Message is empty'], 400); + if (mb_strlen($body) > 2000) json_out(['error' => 'Message too long (max 2000)'], 400); + + $other = DB::row('SELECT id,username FROM users WHERE id=? AND suspended=0', [$toId]); + if (!$other) json_out(['error' => 'User not found'], 404); + + $convId = conv_id($uid, $toId); + + $msgId = DB::insert( + 'INSERT INTO messages (sender_id,receiver_id,subject,body,conversation_id) VALUES (?,?,?,?,?)', + [$uid, $toId, '', $body, $convId] + ); + + // Notify recipient + add_notification($toId, 'message', [ + 'from' => $USER['username'], + 'from_id' => $uid, + 'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''), + ]); + + // Return the new message row for live append + $msg = DB::row( + 'SELECT m.*, u.username AS sender_name, u.avatar AS sender_av + FROM messages m JOIN users u ON u.id=m.sender_id + WHERE m.id=?', [$msgId] + ); + json_out(['ok' => true, 'message' => $msg]); +} + +/* ── Poll: fetch new messages since a given id ───────── */ +if ($action === 'poll') { + $otherId = (int)post('other_id'); + $sinceId = (int)post('since_id'); + if (!$otherId) json_out(['error' => 'Missing other_id'], 400); + + $convId = conv_id($uid, $otherId); + $msgs = DB::rows( + 'SELECT m.*, u.username AS sender_name, u.avatar AS sender_av + FROM messages m JOIN users u ON u.id=m.sender_id + WHERE m.conversation_id=? AND m.id>? + ORDER BY m.id ASC LIMIT 50', + [$convId, $sinceId] + ); + + // Mark messages from other user as read + if ($msgs) { + $now = DB::now(); + DB::run( + "UPDATE messages SET is_read=1 + WHERE conversation_id=? AND receiver_id=? AND `is_read`=0", + [$convId, $uid] + ); + } + + json_out(['ok' => true, 'messages' => $msgs]); +} + +/* ── Load full conversation ──────────────────────────── */ +if ($action === 'load') { + $otherId = (int)post('other_id'); + $before = (int)post('before_id'); // for pagination + if (!$otherId) json_out(['error' => 'Missing other_id'], 400); + + $convId = conv_id($uid, $otherId); + $params = [$convId]; + $where = ''; + if ($before) { + $where = ' AND m.id < ?'; + $params[] = $before; + } + + $msgs = DB::rows( + "SELECT m.*, u.username AS sender_name, u.avatar AS sender_av + FROM messages m JOIN users u ON u.id=m.sender_id + WHERE m.conversation_id=? $where + ORDER BY m.id DESC LIMIT 40", + $params + ); + $msgs = array_reverse($msgs); // oldest first + + // Mark as read + DB::run( + "UPDATE messages SET `is_read`=1 + WHERE conversation_id=? AND receiver_id=? AND `is_read`=0", + [$convId, $uid] + ); + + $other = DB::row('SELECT id,username,avatar,role,karma,last_seen FROM users WHERE id=?', [$otherId]); + json_out(['ok' => true, 'messages' => $msgs, 'other' => $other]); +} + +/* ── List conversations ──────────────────────────────── */ +if ($action === 'conversations') { + $convs = DB::rows( + "SELECT m.*, + u.username AS other_name, u.avatar AS other_av, + (SELECT COUNT(*) FROM messages m2 + WHERE m2.conversation_id=m.conversation_id + AND m2.receiver_id=? AND m2.is_read=0) AS unread_count + FROM messages m + JOIN users u ON u.id = CASE WHEN m.sender_id=? THEN m.receiver_id ELSE m.sender_id END + WHERE m.conversation_id IN ( + SELECT conversation_id FROM messages + WHERE sender_id=? OR receiver_id=? + ) + AND m.id IN ( + SELECT MAX(id) FROM messages + WHERE sender_id=? OR receiver_id=? + GROUP BY conversation_id + ) + ORDER BY m.created_at DESC", + [$uid, $uid, $uid, $uid, $uid, $uid] + ); + json_out(['ok' => true, 'conversations' => $convs]); +} + +/* ── Delete a message ────────────────────────────────── */ +if ($action === 'delete') { + $msgId = (int)post('msg_id'); + $msg = DB::row('SELECT * FROM messages WHERE id=?', [$msgId]); + if (!$msg) json_out(['error' => 'Not found'], 404); + if ($msg['sender_id'] !== $uid && $msg['receiver_id'] !== $uid) json_out(['error' => 'Forbidden'], 403); + + if ($msg['sender_id'] === $uid) + DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?', [$msgId]); + else + DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$msgId]); + + json_out(['ok' => true]); +} + +json_out(['error' => 'Unknown action'], 400); diff --git a/api/delete.php b/api/delete.php new file mode 100644 index 0000000..1f3646b --- /dev/null +++ b/api/delete.php @@ -0,0 +1,10 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out(['error'=>'Not logged in'],401); +if (!csrf_ok()) json_out(['error'=>'CSRF'],403); +$pid = (int)post('post_id'); +$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]); +if (!$post) json_out(['error'=>'Not found'],404); +if ($post['user_id']!==$USER['id'] && !is_admin()) json_out(['error'=>'Forbidden'],403); +DB::run('UPDATE posts SET deleted=1 WHERE id=?',[$pid]); +json_out(['ok'=>true]); diff --git a/api/edit.php b/api/edit.php new file mode 100644 index 0000000..40bebfd --- /dev/null +++ b/api/edit.php @@ -0,0 +1,21 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out(['error'=>'Not logged in'],401); +if (!csrf_ok()) json_out(['error'=>'CSRF'],403); + +$pid = (int)post('post_id'); +$content = sanitise(post('content')); +$reason = sanitise(post('reason')); + +if (!$pid || !$content) json_out(['error'=>'Missing fields'],400); +if (strlen($content) > 20000) json_out(['error'=>'Post too long'],400); + +$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]); +if (!$post) json_out(['error'=>'Not found'],404); +if ($post['user_id']!==$USER['id'] && !is_admin()) json_out(['error'=>'Forbidden'],403); + +$now = DB::now(); +DB::run("UPDATE posts SET content=?,edited=1,edit_reason=?,updated_at=$now WHERE id=?", + [$content, $reason ?: null, $pid]); + +json_out(['ok'=>true,'content'=>$content]); diff --git a/api/friend.php b/api/friend.php new file mode 100644 index 0000000..493894e --- /dev/null +++ b/api/friend.php @@ -0,0 +1,60 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out(['error'=>'Not logged in'],401); +if (!csrf_ok()) json_out(['error'=>'CSRF'],403); + +$action = sanitise(post('action')); +$targetId = (int)post('target_id'); +if (!$targetId || $targetId === $USER['id']) json_out(['error'=>'Invalid target'],400); + +$target = DB::row('SELECT id,username FROM users WHERE id=?',[$targetId]); +if (!$target) json_out(['error'=>'User not found'],404); + +$uid = (int)$USER['id']; + +switch ($action) { + case 'send': + // Send friend request + $existing = DB::row('SELECT * FROM friends WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)', + [$uid,$targetId,$targetId,$uid]); + if ($existing) json_out(['error'=>'Request already exists'],400); + DB::insert('INSERT INTO friends (user_id,friend_id,status) VALUES (?,?,\'pending\')',[$uid,$targetId]); + add_notification($targetId,'friend_request',[ + 'from'=>$USER['username'],'from_id'=>$uid + ]); + json_out(['ok'=>true,'msg'=>'Friend request sent!']); + break; + + case 'cancel': + // Cancel outgoing request + DB::run('DELETE FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$uid,$targetId]); + json_out(['ok'=>true,'msg'=>'Request cancelled.']); + break; + + case 'accept': + // Accept incoming request + $req = DB::row('SELECT * FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$targetId,$uid]); + if (!$req) json_out(['error'=>'No pending request found'],404); + DB::run("UPDATE friends SET status='accepted' WHERE id=?",[$req['id']]); + add_notification($targetId,'friend_accepted',[ + 'from'=>$USER['username'],'from_id'=>$uid + ]); + json_out(['ok'=>true,'msg'=>'Friend request accepted!']); + break; + + case 'decline': + // Decline incoming request + DB::run('DELETE FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$targetId,$uid]); + json_out(['ok'=>true,'msg'=>'Request declined.']); + break; + + case 'remove': + // Remove existing friendship + DB::run('DELETE FROM friends WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)', + [$uid,$targetId,$targetId,$uid]); + json_out(['ok'=>true,'msg'=>'Friend removed.']); + break; + + default: + json_out(['error'=>'Unknown action'],400); +} diff --git a/api/karma.php b/api/karma.php new file mode 100644 index 0000000..79c540c --- /dev/null +++ b/api/karma.php @@ -0,0 +1,46 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +if (!csrf_ok()) json_out(['error' => 'CSRF'], 403); + +$uid = (int)post('user_id'); +$action = post('action'); // set | add | subtract +$amount = (int)post('amount'); +$reason = sanitise(post('reason')); + +if (!$uid) json_out(['error' => 'Invalid user'], 400); +if (!in_array($action, ['set','add','subtract'])) json_out(['error' => 'Invalid action'], 400); +if ($amount < 0) json_out(['error' => 'Amount must be >= 0'], 400); +if ($amount > 999999) json_out(['error' => 'Amount too large'], 400); + +$user = DB::row('SELECT id, username, karma FROM users WHERE id=?', [$uid]); +if (!$user) json_out(['error' => 'User not found'], 404); + +$old = (int)$user['karma']; + +switch ($action) { + case 'set': + $new = $amount; + DB::run('UPDATE users SET karma=? WHERE id=?', [$new, $uid]); + break; + case 'add': + DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$amount, $uid]); + $new = $old + $amount; + break; + case 'subtract': + $new = max(0, $old - $amount); + DB::run('UPDATE users SET karma=? WHERE id=?', [$new, $uid]); + break; +} + +// Add an audit notification to the user +if ($reason) { + add_notification($uid, 'karma_admin', [ + 'from' => $USER['username'], + 'change' => ($new - $old), + 'new' => $new, + 'reason' => $reason, + ]); +} + +json_out(['ok' => true, 'old' => $old, 'new' => $new, 'username' => $user['username']]); diff --git a/api/like.php b/api/like.php new file mode 100644 index 0000000..f3e0a78 --- /dev/null +++ b/api/like.php @@ -0,0 +1,25 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out(['error'=>'Not logged in'],401); +if (!csrf_ok()) json_out(['error'=>'CSRF'],403); +$pid = (int)post('post_id'); +if (!$pid) json_out(['error'=>'Missing post_id'],400); +$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]); +if (!$post) json_out(['error'=>'Not found'],404); + +$exists = DB::row('SELECT 1 FROM likes WHERE post_id=? AND user_id=?',[$pid,$USER['id']]); +if ($exists) { + DB::run('DELETE FROM likes WHERE post_id=? AND user_id=?',[$pid,$USER['id']]); + DB::run('UPDATE posts SET likes=MAX(0,likes-1) WHERE id=?',[$pid]); + // Remove karma from post author + add_karma((int)$post['user_id'], -1); + $liked = false; +} else { + DB::insertIgnore('likes', ['post_id','user_id'], [$pid,$USER['id']]); + DB::run('UPDATE posts SET likes=likes+1 WHERE id=?',[$pid]); + // Award karma to post author + add_karma((int)$post['user_id'], 1); + $liked = true; +} +$count = (int)DB::val('SELECT COUNT(*) FROM likes WHERE post_id=?',[$pid]); +json_out(['ok'=>true,'liked'=>$liked,'count'=>$count]); diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..aa6879a --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,20 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out([],401); + +// Mark-read requires POST + valid CSRF +if ($_SERVER['REQUEST_METHOD']==='POST' && post('action')==='read') { + if (!csrf_ok()) json_out(['error'=>'Invalid CSRF'],403); + DB::run('UPDATE notifications SET `read`=1 WHERE user_id=?',[$USER['id']]); + json_out(['ok'=>true]); +} +// Allow GET for polling +if (isset($_GET['action']) && $_GET['action']==='read') { + // Legacy GET — accept but deprecated + DB::run('UPDATE notifications SET `read`=1 WHERE user_id=?',[$USER['id']]); + json_out(['ok'=>true]); +} + +$rows = DB::rows('SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 25',[$USER['id']]); +foreach ($rows as &$r) $r['payload'] = json_decode($r['payload'],true) ?: []; +json_out($rows); diff --git a/api/reply.php b/api/reply.php new file mode 100644 index 0000000..8fe0fba --- /dev/null +++ b/api/reply.php @@ -0,0 +1,69 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if (!$USER) json_out(['error'=>'Not logged in'],401); +if ($_SERVER['REQUEST_METHOD']!=='POST') json_out(['error'=>'Method not allowed'],405); +if (!csrf_ok()) json_out(['error'=>'Invalid CSRF token'],403); +if ($USER['silenced']) json_out(['error'=>'You are silenced and cannot post'],403); + +// ── Rate limit check ───────────────────────────────────────── +$rl = rate_check((int)$USER['id'], 'post'); +if (!$rl['ok']) { + $wait = (int)$rl['wait']; + json_out(['error'=>"You're posting too fast. Please wait {$wait} second".($wait!==1?'s':'').' before posting again.','rate_limited'=>true,'wait'=>$wait], 429); +} + +// ── Post captcha check ─────────────────────────────────────── +if (post_captcha_enabled()) { + $cap = post('post_captcha'); + if (!post_captcha_verify($cap)) { + json_out(['error'=>'Incorrect security answer.','captcha_failed'=>true], 403); + } +} + +$slug = sanitise(post('slug')); +$content = sanitise(post('content')); +$replyTo = (int)post('reply_to'); + +if (!$slug || !$content) json_out(['error'=>'Missing required fields'],400); +if (strlen($content) < 1) json_out(['error'=>'Reply cannot be empty'],400); +if (strlen($content) > 20000) json_out(['error'=>'Post too long (max 20,000 chars)'],400); + +$topic = DB::row('SELECT * FROM topics WHERE slug=?', [$slug]); +if (!$topic) json_out(['error'=>'Topic not found'],404); +if ($topic['closed']) json_out(['error'=>'This topic is closed'],403); +$replyCat = DB::row('SELECT * FROM categories WHERE id=?',[$topic['category_id']]); +if (!can_reply_topic($replyCat ?? [])) json_out(['error'=>'No permission to reply in this category'],403); + +$last = DB::row('SELECT post_num FROM posts WHERE topic_id=? ORDER BY post_num DESC LIMIT 1',[$topic['id']]); +$num = ($last ? (int)$last['post_num'] : 0) + 1; + +$pid = DB::insert( + 'INSERT INTO posts (topic_id,user_id,content,post_num,reply_to) VALUES (?,?,?,?,?)', + [$topic['id'], $USER['id'], $content, $num, $replyTo ?: null] +); + +// Record rate limit event AFTER successful post +rate_record((int)$USER['id'], 'post'); + +$now = DB::now(); +DB::run("UPDATE topics SET last_post_at=$now,reply_count=reply_count+1 WHERE id=?",[$topic['id']]); +DB::run('UPDATE users SET post_count=post_count+1 WHERE id=?',[$USER['id']]); +DB::run('UPDATE categories SET post_count=post_count+1 WHERE id=?',[$topic['category_id']]); + +if ($topic['user_id'] !== $USER['id']) { + add_notification((int)$topic['user_id'],'reply',[ + 'slug'=>$topic['slug'],'title'=>$topic['title'],'from'=>$USER['username'] + ]); +} +process_mentions($content, $pid, $topic['slug'], (int)$USER['id'], $USER['username']); + +$post = DB::row(" + SELECT p.*,u.username,u.avatar,u.role,u.post_count,0 AS likes + FROM posts p JOIN users u ON u.id=p.user_id WHERE p.id=? +",[$pid]); + +// Send new captcha if post captcha enabled +$newCaptcha = post_captcha_enabled() ? post_captcha_generate() : null; + +addon_hook('after_reply_saved', ['post_id'=>$pid,'topic_id'=>$topic['id'],'user_id'=>(int)$USER['id']]); +json_out(['ok'=>true,'post'=>$post,'new_captcha'=>$newCaptcha]); diff --git a/api/search.php b/api/search.php new file mode 100644 index 0000000..4eeb1c2 --- /dev/null +++ b/api/search.php @@ -0,0 +1,34 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +$q = get('q'); +if (mb_strlen($q) < 2) json_out(['topics' => [], 'posts' => []]); + +$like = '%' . $q . '%'; + +// Topic title matches +$topics = DB::rows( + "SELECT t.id, t.title, t.slug, c.name AS cat, c.color AS cat_color + FROM topics t + JOIN categories c ON c.id = t.category_id + WHERE t.title LIKE ? + ORDER BY t.last_post_at DESC + LIMIT 6", + [$like] +); + +// Post content matches — include post_id for goto link +$posts = DB::rows( + "SELECT p.id AS post_id, p.content, p.post_num, + t.title AS topic_title, t.slug AS topic_slug, + u.username + FROM posts p + JOIN topics t ON t.id = p.topic_id + JOIN users u ON u.id = p.user_id + WHERE p.content LIKE ? AND p.deleted = 0 + ORDER BY p.created_at DESC + LIMIT 5", + [$like] +); + +json_out(['topics' => $topics, 'posts' => $posts]); diff --git a/api/search_users.php b/api/search_users.php new file mode 100644 index 0000000..6deecbf --- /dev/null +++ b/api/search_users.php @@ -0,0 +1,9 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +$q = sanitise(get('q')); +if (mb_strlen($q) < 2) json_out([]); +$rows = DB::rows( + "SELECT id, username, avatar FROM users WHERE username LIKE ? AND suspended=0 ORDER BY username LIMIT 8", + [$q.'%'] +); +json_out($rows); diff --git a/api/topic_action.php b/api/topic_action.php new file mode 100644 index 0000000..a61f544 --- /dev/null +++ b/api/topic_action.php @@ -0,0 +1,21 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_admin(); +if (!csrf_ok()) go('/'); +$id = (int)post('id'); +$action = post('action'); +// Validate referer to prevent open redirect +$rawRef = $_SERVER['HTTP_REFERER'] ?? ''; +$baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; +$ref = (strpos($rawRef, $baseUrl) === 0) ? $rawRef : u('/'); +$map = [ + 'pin' => 'UPDATE topics SET pinned=1 WHERE id=?', + 'unpin' => 'UPDATE topics SET pinned=0 WHERE id=?', + 'close' => 'UPDATE topics SET closed=1 WHERE id=?', + 'open' => 'UPDATE topics SET closed=0 WHERE id=?', + 'archive' => 'UPDATE topics SET archived=1 WHERE id=?', + 'unarchive' => 'UPDATE topics SET archived=0 WHERE id=?', +]; +if ($id && isset($map[$action])) DB::run($map[$action], [$id]); +header('Location: ' . $ref); +exit; diff --git a/api/upload.php b/api/upload.php new file mode 100644 index 0000000..5058b1e --- /dev/null +++ b/api/upload.php @@ -0,0 +1,105 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +// ── Auth & method ──────────────────────────────────────────────── +if (!$USER) + json_out(['error' => 'Not logged in'], 401); +if ($_SERVER['REQUEST_METHOD'] !== 'POST') + json_out(['error' => 'Method not allowed'], 405); +if (!csrf_ok()) + json_out(['error' => 'Invalid CSRF token'], 403); + +// ── File present? ──────────────────────────────────────────────── +// $_FILES is empty when PHP silently drops the upload because it exceeds +// php.ini upload_max_filesize or post_max_size. +if (empty($_FILES['file']) || !isset($_FILES['file']['tmp_name'])) { + $phpLimit = ini_get('upload_max_filesize') ?: '?'; + json_out(['error' => 'No file received. Check PHP upload_max_filesize (currently: ' . $phpLimit . ')'], 400); +} + +$f = $_FILES['file']; +$code = $f['error'] ?? UPLOAD_ERR_OK; + +// ── PHP upload error codes ──────────────────────────────────────── +if ($code !== UPLOAD_ERR_OK) { + $msgs = [ + UPLOAD_ERR_INI_SIZE => 'File exceeds server upload limit (upload_max_filesize)', + UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit', + UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'Upload blocked by a PHP extension', + ]; + json_out(['error' => $msgs[$code] ?? 'Upload error code ' . $code], 400); +} + +// ── Detect real MIME type from file content (not browser header) ── +// finfo is reliable; fall back to getimagesize if finfo not available. +$realMime = null; +if (function_exists('finfo_open')) { + $fi = finfo_open(FILEINFO_MIME_TYPE); + $realMime = finfo_file($fi, $f['tmp_name']); + finfo_close($fi); +} elseif (function_exists('mime_content_type')) { + $realMime = mime_content_type($f['tmp_name']); +} + +// Validate the real MIME type +$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +if ($realMime && !in_array($realMime, $allowed)) { + json_out(['error' => 'Only JPEG, PNG, GIF and WebP images are allowed (detected: ' . $realMime . ')'], 400); +} + +// ── Size limit ──────────────────────────────────────────────────── +$maxMb = max(1, min(50, (int) cfg('max_upload_mb', '5'))); +$maxBytes = $maxMb * 1024 * 1024; +if ($f['size'] > $maxBytes) { + json_out(['error' => 'Image too large — maximum is ' . $maxMb . ' MB'], 400); +} + +// ── Validate it is a real image (catches non-images finfo might miss) ── +$info = @getimagesize($f['tmp_name']); +if (!$info) { + json_out(['error' => 'File does not appear to be a valid image'], 400); +} + +// ── Safe extension from detected MIME ──────────────────────────── +$mimeToExt = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', +]; +$detectedMime = $realMime ?: $info['mime']; +$ext = $mimeToExt[$detectedMime] ?? ($mimeToExt[$info['mime']] ?? 'jpg'); +$name = 'img_' . uniqid('', true) . '.' . $ext; + +// ── Ensure upload directory exists and is writable ──────────────── +$dir = UPLOADS . '/'; +if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + json_out(['error' => 'Upload directory could not be created'], 500); + } +} +if (!is_writable($dir)) { + json_out(['error' => 'Upload directory is not writable'], 500); +} + +// ── Move file ───────────────────────────────────────────────────── +if (!move_uploaded_file($f['tmp_name'], $dir . $name)) { + json_out(['error' => 'Could not save uploaded file'], 500); +} + +// ── Add security .htaccess to uploads dir (prevent PHP execution) ─ +$ht = $dir . '.htaccess'; +if (!file_exists($ht)) { + file_put_contents($ht, + "# Deny PHP execution in uploads\n" . + "<FilesMatch \"\\.php\$\">\n" . + " Require all denied\n" . + "</FilesMatch>\n" + ); +} + +json_out(['ok' => true, 'url' => BASE . '/public/uploads/' . $name]); diff --git a/auth/login.php b/auth/login.php new file mode 100644 index 0000000..b083ab8 --- /dev/null +++ b/auth/login.php @@ -0,0 +1,65 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if ($USER) go('/'); + +$err = ''; +$next = get('next', u('/')); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!csrf_ok()) { $err = 'Invalid request. Please try again.'; } + else { + $login = post('login'); + $pass = post('password'); + $row = DB::row('SELECT * FROM users WHERE username=? OR email=?', [$login, $login]); + if (!$row || !password_verify($pass, $row['password'])) { + $err = 'Incorrect username or password.'; + } elseif ($row['suspended']) { + $err = 'This account has been suspended.'; + } else { + login_user((int)$row['id']); + go(ltrim(str_replace(BASE, '', $next), '/') ?: '/'); + } + } +} +?> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> + <title>Log In — <?= e(cfg('site_name','Nexus Forum')) ?></title> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="<?= asset('css/main.css') ?>"> +</head> +<body class="auth-body"> +<div class="auth-page"> + <div class="auth-card"> + <a href="<?= u('/') ?>" class="auth-logo"> + <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div> + <span><?= e(cfg('site_name','Nexus Forum')) ?></span> + </a> + <h1>Welcome back</h1> + <p class="auth-sub">Sign in to your account</p> + <?php if ($err): ?><div class="alert err"><?= e($err) ?></div><?php endif; ?> + <form method="POST"> + <?= csrf_input() ?> + <input type="hidden" name="next" value="<?= e($next) ?>"> + <div class="fg"> + <label for="login">Username or Email</label> + <input type="text" id="login" name="login" class="fi" required autofocus + value="<?= e($_POST['login'] ?? '') ?>" placeholder="your_username"> + </div> + <div class="fg"> + <label for="password">Password</label> + <div class="pw-row"> + <input type="password" id="password" name="password" class="fi" required placeholder="••••••••"> + <button type="button" class="pw-eye" onclick="togglePwd('password')">👁</button> + </div> + </div> + <button type="submit" class="btn-primary btn-block">Sign In</button> + </form> + <p class="auth-foot">No account? <a href="<?= u('auth/register.php') ?>">Sign up →</a></p> + </div> + <p class="auth-back"><a href="<?= u('/') ?>">← Back to forum</a></p> +</div> +<script>function togglePwd(id){var e=document.getElementById(id);e.type=e.type==='password'?'text':'password';}</script> +</body></html> diff --git a/auth/logout.php b/auth/logout.php new file mode 100644 index 0000000..7a1cad8 --- /dev/null +++ b/auth/logout.php @@ -0,0 +1,4 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +logout_user(); +go('/'); diff --git a/auth/register.php b/auth/register.php new file mode 100644 index 0000000..1dff045 --- /dev/null +++ b/auth/register.php @@ -0,0 +1,168 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +if ($USER) go('/'); +if (cfg('allow_reg','1') !== '1') $disabled = true; + +$errs = []; + +// IMPORTANT: Only generate a NEW captcha on GET requests (page load). +// On POST requests, verify FIRST against the stored session answer, +// then generate a new one only if needed for re-display. +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $captcha = captcha_generate(); +} else { + // On POST: we don't overwrite the session answer yet. + // captcha_verify() will read and unset it. + $captcha = ['q' => '']; // placeholder, overwritten below if needed +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && !isset($disabled)) { + if (!csrf_ok()) { + $errs[] = 'Invalid request. Please refresh and try again.'; + $captcha = captcha_generate(); + } else { + $name = sanitise(post('username')); + $email = sanitise(post('email')); + $pass = post('password'); + $pass2 = post('password2'); + $cap = post('captcha'); + + // Verify captcha FIRST before anything else + if (!captcha_verify($cap)) { + $errs[] = 'Incorrect answer to the security question. Please try again.'; + $captcha = captcha_generate(); // generate fresh question for retry + } + + // Only run other validation if captcha passed + if (!$errs) { + if (strlen($name) < 3 || strlen($name) > 30) $errs[] = 'Username must be 3–30 characters.'; + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) $errs[] = 'Username: letters, numbers, _ and - only.'; + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) $errs[] = 'Invalid email address.'; + if (strlen($pass) < 8) $errs[] = 'Password must be at least 8 characters.'; + if ($pass !== $pass2) $errs[] = 'Passwords do not match.'; + } + + if (!$errs) { + if (DB::row('SELECT id FROM users WHERE username=? OR email=?', [$name, $email])) { + $errs[] = 'Username or email is already taken.'; + $captcha = captcha_generate(); // fresh question after failed attempt + } else { + $id = DB::insert( + 'INSERT INTO users (username, email, password) VALUES (?, ?, ?)', + [$name, $email, password_hash($pass, PASSWORD_BCRYPT, ['cost' => 12])] + ); + addon_hook('after_user_registered', ['user_id'=>$id,'username'=>$name,'email'=>$email]); + login_user($id); + go('/'); + } + } + + // If we have errors but captcha already passed (errs from other fields), + // generate a fresh captcha for the re-shown form + if ($errs && empty(array_filter($errs, fn($e) => str_contains($e, 'security')))) { + if (!isset($captcha['q']) || $captcha['q'] === '') { + $captcha = captcha_generate(); + } + } + } +} +?><!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> + <title>Sign Up — <?= e(cfg('site_name','Nexus Forum')) ?></title> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="<?= asset('css/main.css') ?>"> +</head> +<body class="auth-body"> +<div class="auth-page"> + <div class="auth-card"> + <a href="<?= u('/') ?>" class="auth-logo"> + <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div> + <span><?= e(cfg('site_name','Nexus Forum')) ?></span> + </a> + <h1>Create an account</h1> + <p class="auth-sub">Join the community</p> + + <?php if (!empty($errs)): ?> + <div class="alert err"><?= implode('<br>', array_map('e', $errs)) ?></div> + <?php endif; ?> + + <?php if (isset($disabled)): ?> + <div class="alert warn">Registration is currently disabled.</div> + <?php else: ?> + <form method="POST" autocomplete="off"> + <?= csrf_input() ?> + <div class="fg"> + <label>Username</label> + <input type="text" name="username" class="fi" required autofocus + value="<?= e($_POST['username'] ?? '') ?>" + placeholder="your_username" minlength="3" maxlength="30"> + <span class="hint">Letters, numbers, _ and - only</span> + </div> + <div class="fg"> + <label>Email</label> + <input type="email" name="email" class="fi" required + value="<?= e($_POST['email'] ?? '') ?>" placeholder="you@example.com"> + </div> + <div class="fg"> + <label>Password</label> + <div class="pw-row"> + <input type="password" id="pw1" name="password" class="fi" required + placeholder="Min. 8 characters" minlength="8"> + <button type="button" class="pw-eye" onclick="togglePwd('pw1')">👁</button> + </div> + <div class="pw-bar"><div class="pw-fill" id="pwFill"></div></div> + <span class="hint" id="pwHint"></span> + </div> + <div class="fg"> + <label>Confirm Password</label> + <div class="pw-row"> + <input type="password" id="pw2" name="password2" class="fi" required + placeholder="Repeat password"> + <button type="button" class="pw-eye" onclick="togglePwd('pw2')">👁</button> + </div> + </div> + + <!-- Math Captcha --> + <div class="captcha-box"> + <div class="captcha-label"> + 🔒 Security check — What is + <strong class="captcha-q"><?= e($captcha['q']) ?></strong> + </div> + <input type="number" name="captcha" class="fi captcha-input" + required placeholder="Your answer" autocomplete="off"> + <span class="hint">Solve this simple math problem to continue</span> + </div> + + <button type="submit" class="btn-primary btn-block" style="margin-top:16px"> + Create Account + </button> + </form> + <?php endif; ?> + <p class="auth-foot">Have an account? <a href="<?= u('auth/login.php') ?>">Sign in →</a></p> + </div> + <p class="auth-back"><a href="<?= u('/') ?>">← Back to forum</a></p> +</div> +<script> +function togglePwd(id) { + var e = document.getElementById(id); + e.type = e.type === 'password' ? 'text' : 'password'; +} +var pw = document.getElementById('pw1'); +if (pw) pw.addEventListener('input', function() { + var p=this.value, f=document.getElementById('pwFill'), h=document.getElementById('pwHint'), s=0; + if (p.length >= 8) s++; + if (p.length >= 12) s++; + if (/[A-Z]/.test(p)) s++; + if (/[0-9]/.test(p)) s++; + if (/[^A-Za-z0-9]/.test(p)) s++; + f.style.width = (s / 5 * 100) + '%'; + var cols = ['','#ef4444','#f59e0b','#f59e0b','#22c55e','#22c55e']; + f.style.background = cols[s] || '#22c55e'; + h.textContent = ['','Weak','Fair','Good','Strong','Very strong'][s] || ''; + h.style.color = f.style.background; +}); +</script> +</body> +</html> diff --git a/forum/category.php b/forum/category.php new file mode 100644 index 0000000..a82c496 --- /dev/null +++ b/forum/category.php @@ -0,0 +1,102 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +$slug = get('slug'); +$cat = DB::row('SELECT * FROM categories WHERE slug=?', [$slug]); +if (!$cat) render_404(); +if (!can_read_category($cat)) render_403(); + +$page = max(1, (int)get('page','1')); +$pp = (int)cfg('topics_per_page','30'); +$off = ($page - 1) * $pp; + +$topics = DB::rows(" + SELECT t.*, u.username, u.avatar, + (SELECT u2.username FROM posts p2 JOIN users u2 ON u2.id=p2.user_id + WHERE p2.topic_id=t.id ORDER BY p2.created_at DESC LIMIT 1) AS last_poster, + (SELECT COUNT(*)-1 FROM posts p WHERE p.topic_id=t.id AND p.deleted=0) AS replies + FROM topics t JOIN users u ON u.id=t.user_id + WHERE t.category_id=? AND t.archived=0 + ORDER BY t.pinned DESC, t.last_post_at DESC + LIMIT ? OFFSET ? +", [$cat['id'], $pp, $off]); + +$total = (int) DB::val('SELECT COUNT(*) FROM topics WHERE category_id=? AND archived=0', [$cat['id']]); +$pages = max(1, (int)ceil($total / $pp)); +$subs = DB::rows('SELECT * FROM categories WHERE parent_id=? ORDER BY position', [$cat['id']]); + +$PAGE_TITLE = $cat['name']; +include __DIR__ . '/../views/partials/layout.php'; +?> +<nav class="bc"><a href="<?= u('/') ?>">Home</a> › <span><?= e($cat['name']) ?></span></nav> + +<div class="cat-hdr" style="border-left:4px solid <?= e($cat['color']) ?>"> + <span class="cat-hdr-icon"><?= e($cat['icon']) ?></span> + <div> + <h1><?= e($cat['name']) ?></h1> + <p><?= e($cat['description']) ?></p> + <div class="cat-hdr-meta"><?= $total ?> topics · <?= $cat['post_count'] ?> posts</div> + </div> + <?php if ($USER): ?> + <a href="<?= u('forum/new-topic.php?cat=' . $cat['id']) ?>" class="btn-primary">+ New Topic</a> + <?php endif; ?> +</div> + +<?php if ($subs): ?> + <div class="subcats"> + <?php foreach ($subs as $s): ?> + <a href="<?= u('forum/category.php?slug=' . urlencode($s['slug'])) ?>" class="subcat" style="border-color:<?= e($s['color']) ?>"><?= e($s['icon']) ?> <?= e($s['name']) ?></a> + <?php endforeach; ?> + </div> +<?php endif; ?> + +<div class="topic-list"> + <div class="tl-hdr"><span>Topic</span><span class="tl-r">Replies</span><span class="tl-r">Views</span><span class="tl-r">Activity</span></div> + <?php foreach ($topics as $t): ?> + <div class="tl-row <?= $t['pinned'] ? 'is-pinned' : '' ?>"> + <div class="tl-main"> + <?php if ($t['avatar']): ?> + <img src="<?= e($t['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($t['username'][0]) ?></span> + <?php endif; ?> + <div> + <div class="tl-title"> + <?php if ($t['pinned']): ?><span title="Pinned">📌</span><?php endif; ?> + <?php if ($t['closed']): ?><span title="Closed">🔒</span><?php endif; ?> + <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>"><?= e($t['title']) ?></a> + </div> + <div class="tl-meta"> + by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a> + <?php if ($t['last_poster'] && $t['last_poster'] !== $t['username']): ?> + · last by @<?= e($t['last_poster']) ?> + <?php endif; ?> + </div> + </div> + </div> + <div class="tl-r"><?= max(0,(int)$t['replies']) ?></div> + <div class="tl-r"><?= $t['views'] ?></div> + <div class="tl-r"><span class="ago" data-ts="<?= e($t['last_post_at']) ?>"></span></div> + </div> + <?php endforeach; ?> + <?php if (empty($topics)): ?> + <div class="empty-state"> + <p>No topics yet.</p> + <?php if ($USER): ?> + <a href="<?= u('forum/new-topic.php?cat=' . $cat['id']) ?>" class="btn-primary">Start the first one!</a> + <?php endif; ?> + </div> + <?php endif; ?> +</div> + +<?php if ($pages > 1): ?> + <nav class="pager"> + <?php if ($page > 1): ?><a href="?slug=<?= urlencode($slug) ?>&page=<?= $page-1 ?>" class="pg-btn">← Prev</a><?php endif; ?> + <?php for ($i=max(1,$page-2); $i<=min($pages,$page+2); $i++): ?> + <a href="?slug=<?= urlencode($slug) ?>&page=<?= $i ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a> + <?php endfor; ?> + <?php if ($page < $pages): ?><a href="?slug=<?= urlencode($slug) ?>&page=<?= $page+1 ?>" class="pg-btn">Next →</a><?php endif; ?> + </nav> +<?php endif; ?> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/forum/new-topic.php b/forum/new-topic.php new file mode 100644 index 0000000..e057594 --- /dev/null +++ b/forum/new-topic.php @@ -0,0 +1,147 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); + +$allCats = DB::rows('SELECT * FROM categories ORDER BY position, id'); +$cats = array_filter($allCats, fn($c) => can_post_topic($c)); +$selCat = (int)get('cat'); +$errs = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!csrf_ok()) { $errs[] = 'Invalid request.'; } + else { + $title = sanitise(post('title')); + $content = sanitise(post('content')); + $catId = (int)post('cat_id'); + $tagStr = sanitise(post('tags')); + + if (!$title) $errs[] = 'Title is required.'; + if (!$content) $errs[] = 'Post content is required.'; + if (!$catId) $errs[] = 'Please select a category.'; + if (strlen($title) > 200) $errs[] = 'Title too long (max 200 chars).'; + if (strlen($content) > 50000) $errs[] = 'Content too long.'; + + if (!$errs && !DB::row('SELECT id FROM categories WHERE id=?', [$catId])) $errs[] = 'Invalid category.'; + if (!$errs) { + $postCat = DB::row('SELECT * FROM categories WHERE id=?', [$catId]); + if (!$postCat || !can_post_topic($postCat)) $errs[] = 'You do not have permission to post in this category.'; + } + + if (!$errs && cfg('topic_captcha_enabled', '0') === '1') { + if (!post_captcha_verify(post('topic_captcha'))) $errs[] = 'Incorrect security answer.'; + } + + if (!$errs) { + $rl = rate_check((int)$USER['id'], 'topic'); + if (!$rl['ok']) { + $wait = (int)$rl['wait']; + $errs[] = "Slow down! Please wait {$wait} second" . ($wait !== 1 ? 's' : '') . '.'; + } + } + + if (!$errs) { + $slug = unique_slug($title, 'topics'); + $tid = DB::insert('INSERT INTO topics (title,slug,category_id,user_id) VALUES (?,?,?,?)', + [$title, $slug, $catId, $USER['id']]); + $pid = DB::insert('INSERT INTO posts (topic_id,user_id,content,post_num) VALUES (?,?,?,1)', + [$tid, $USER['id'], $content]); + + if ($tagStr) { + $names = array_slice(array_filter(array_map('trim', explode(',', mb_strtolower($tagStr)))), 0, 5); + foreach ($names as $n) { + if (!$n) continue; + $tg = DB::row('SELECT id FROM tags WHERE name=?', [$n]); + $tgId = $tg ? $tg['id'] : DB::insert('INSERT INTO tags (name) VALUES (?)', [$n]); + DB::insertIgnore('topic_tags', ['topic_id', 'tag_id'], [$tid, $tgId]); + DB::run('UPDATE tags SET topic_count=topic_count+1 WHERE id=?', [$tgId]); + } + } + DB::run('UPDATE users SET topic_count=topic_count+1,post_count=post_count+1 WHERE id=?', [$USER['id']]); + DB::run('UPDATE categories SET topic_count=topic_count+1,post_count=post_count+1 WHERE id=?', [$catId]); + process_mentions($content, $pid, $slug, (int)$USER['id'], $USER['username']); + rate_record((int)$USER['id'], 'topic'); + addon_hook('after_topic_created', ['topic_id'=>$tid,'title'=>$title,'slug'=>$slug,'category_id'=>$catId,'user_id'=>(int)$USER['id']]); + go('forum/topic.php?slug=' . urlencode($slug)); + } + } +} + +$PAGE_TITLE = 'New Topic'; +include __DIR__ . '/../views/partials/layout.php'; +?> +<nav class="bc"><a href="<?=u('/')?>">Home</a> › <span>New Topic</span></nav> +<div class="form-card"> + <h1>Create a New Topic</h1> + <?php if($errs):?><div class="alert err"><?=implode('<br>',array_map('e',$errs))?></div><?php endif;?> + <form method="POST" id="ntForm"> + <?=csrf_input()?> + <div class="fg"> + <label>Category <span class="req">*</span></label> + <select name="cat_id" class="fi" required> + <option value="">— Select a category —</option> + <?php foreach($cats as $c):?> + <option value="<?=$c['id']?>" <?=($selCat==$c['id']||((int)($_POST['cat_id']??0))==$c['id'])?'selected':''?>> + <?=e($c['icon'])?> <?=e($c['name'])?> + </option> + <?php endforeach;?> + </select> + </div> + <div class="fg"> + <label>Title <span class="req">*</span></label> + <input type="text" name="title" class="fi" required maxlength="200" + value="<?=e($_POST['title']??'')?>" placeholder="What is this about?"> + </div> + <div class="fg"> + <label>Tags <small>(optional, comma-separated)</small></label> + <input type="text" name="tags" id="tagsIn" class="fi" value="<?=e($_POST['tags']??'')?>" placeholder="help, tutorial"> + <div id="tagPreview" class="tag-preview"></div> + </div> + <div class="fg"> + <label>Content <span class="req">*</span></label> + <div class="ed-toolbar"> + <button type="button" onclick="fmt('bold','replyTa')"><b>B</b></button> + <button type="button" onclick="fmt('italic','replyTa')"><i>I</i></button> + <button type="button" onclick="fmt('link','replyTa')">🔗</button> + <button type="button" onclick="fmt('quote','replyTa')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button> + <button type="button" onclick="fmt('codeblock','replyTa')">📄</button> + <button type="button" onclick="fmt('heading','replyTa')">H</button> + <button type="button" onclick="fmt('ul','replyTa')">≡</button> + <div class="ed-sep"></div> + <button type="button" onclick="pickImg('ntImg','replyTa')">🖼</button> + <input type="file" id="ntImg" accept="image/*" style="display:none" onchange="uploadImg(this,'replyTa')"> + <div class="ed-sep"></div> + <button type="button" id="prevBtn" onclick="togglePreview()">👁 Preview</button> + </div> + <div class="ed-panes"> + <textarea id="replyTa" name="content" class="reply-ta" rows="14" required + placeholder="Write your post… Markdown supported. Tip: use @username to mention someone!"><?=e($_POST['content']??'')?></textarea> + <div id="replyPreview" class="reply-preview hidden"></div> + </div> + </div> + <?php if (cfg('topic_captcha_enabled','0') === '1'): ?> + <?php $tc = post_captcha_generate(); ?> + <div class="fg captcha-box"> + <label class="captcha-label">🔒 Security — What is <strong class="captcha-q"><?= e($tc['q']) ?></strong></label> + <input type="number" name="topic_captcha" class="fi captcha-input" required placeholder="Answer" autocomplete="off" min="-99" max="99"> + <span class="hint">Solve this math problem to post</span> + </div> + <?php endif; ?> + + <div class="form-actions"> + <a href="<?=u('/')?>" class="btn-ghost">Cancel</a> + <button type="submit" class="btn-primary btn-lg">Post Topic</button> + </div> + </form> +</div> +<script> +document.getElementById('tagsIn').addEventListener('input',function(){ + var tags=this.value.split(',').map(function(t){return t.trim();}).filter(Boolean); + document.getElementById('tagPreview').innerHTML=tags.map(function(t){return '<span class="tag">'+escH(t)+'</span>';}).join(''); +}); +function escH(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} +document.getElementById('replyTa').addEventListener('paste',function(e){ + var items=(e.clipboardData||e.originalEvent.clipboardData).items; + for(var i=0;i<items.length;i++){if(items[i].type.indexOf('image')!==-1){e.preventDefault();uploadFileToEditor(items[i].getAsFile(),'replyTa');}} +}); +</script> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/forum/search.php b/forum/search.php new file mode 100644 index 0000000..372cd5f --- /dev/null +++ b/forum/search.php @@ -0,0 +1,192 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +$q = get('q'); +$type = get('type', 'topics'); // topics | users | posts +if (!in_array($type, ['topics','users','posts'])) $type = 'topics'; + +$topicResults = []; +$postResults = []; +$userResults = []; + +if (mb_strlen($q) >= 2) { + $like = '%' . $q . '%'; + + if ($type === 'topics' || $type === 'posts') { + // Topics matching title + $topicResults = DB::rows(" + SELECT t.*, u.username, c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color, + NULL AS match_post_id + FROM topics t + JOIN users u ON u.id = t.user_id + JOIN categories c ON c.id = t.category_id + WHERE t.title LIKE ? + ORDER BY t.last_post_at DESC LIMIT 20 + ", [$like]); + } + + if ($type === 'posts') { + // Posts matching content — include post ID so we can anchor to it + $postResults = DB::rows(" + SELECT p.id AS post_id, p.content, p.post_num, p.created_at AS post_date, + t.id AS topic_id, t.title, t.slug, + u.username, u.avatar, + c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color + FROM posts p + JOIN topics t ON t.id = p.topic_id + JOIN users u ON u.id = p.user_id + JOIN categories c ON c.id = t.category_id + WHERE p.content LIKE ? AND p.deleted = 0 + ORDER BY p.created_at DESC LIMIT 30 + ", [$like]); + } + + if ($type === 'users') { + $userResults = DB::rows(" + SELECT id, username, avatar, role, karma, post_count, bio, joined_at + FROM users + WHERE (username LIKE ? OR bio LIKE ?) AND suspended = 0 + ORDER BY post_count DESC LIMIT 30 + ", [$like, $like]); + } +} + +$allCount = count($topicResults) + count($postResults) + count($userResults); +$PAGE_TITLE = 'Search'; +include __DIR__ . '/../views/partials/layout.php'; +?> +<div style="max-width:800px"> + + <!-- Search form with type tabs --> + <form method="GET" id="searchForm"> + <div style="display:flex;gap:10px;margin-bottom:16px"> + <div style="position:relative;flex:1;max-width:560px"> + <svg style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--faint);pointer-events:none" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> + <input type="text" name="q" value="<?= e($q) ?>" class="fi" + style="padding-left:34px" placeholder="Search topics, posts, users…" + autofocus autocomplete="off" id="mainSearchInput"> + </div> + <button type="submit" class="btn-primary">Search</button> + </div> + + <!-- Type tabs --> + <div style="display:flex;gap:0;margin-bottom:22px;border-bottom:2px solid var(--border)"> + <?php foreach (['topics'=>'📋 Topics','posts'=>'💬 Posts','users'=>'👥 Users'] as $t => $lbl): ?> + <button type="submit" name="type" value="<?= $t ?>" + class="srch-tab <?= $type===$t?'active':'' ?>"><?= $lbl ?></button> + <?php endforeach; ?> + </div> + </form> + + <?php if (mb_strlen($q) >= 2): ?> + <p class="search-info"> + <?= count($type==='posts'?$postResults:($type==='users'?$userResults:$topicResults)) ?> + result<?= count($type==='posts'?$postResults:($type==='users'?$userResults:$topicResults))!==1?'s':'' ?> + for "<strong><?= e($q) ?></strong>" + </p> + + <?php if ($type === 'topics'): ?> + <?php if (empty($topicResults)): ?> + <div class="empty-state"><p>No topics found matching "<?= e($q) ?>".</p></div> + <?php else: ?> + <?php foreach ($topicResults as $t): ?> + <div class="topic-row"> + <div class="tr-body"> + <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>" class="tr-title"><?= e($t['title']) ?></a> + <div class="tr-meta"> + <a href="<?= u('forum/category.php?slug=' . urlencode($t['cat_slug'])) ?>" + class="cat-tag" style="--cc:<?= e($t['cat_color']) ?>"><?= e($t['cat_name']) ?></a> + by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a> + <span class="ago" data-ts="<?= e($t['created_at']) ?>"></span> + </div> + </div> + <div class="tr-counts"><span>💬 <?= $t['reply_count'] ?></span><span>👁 <?= $t['views'] ?></span></div> + </div> + <?php endforeach; ?> + <?php endif; ?> + + <?php elseif ($type === 'posts'): ?> + <?php if (empty($postResults)): ?> + <div class="empty-state"><p>No posts found matching "<?= e($q) ?>".</p></div> + <?php else: ?> + <?php foreach ($postResults as $p): ?> + <?php + // Build URL with post anchor so user lands on the exact post + $postUrl = u('forum/topic.php?slug=' . urlencode($p['slug']) . '&goto=' . $p['post_id']) . '#post-' . $p['post_id']; + // Highlight the search term in content snippet + $snippet = mb_substr($p['content'], 0, 200); + $pos = mb_stripos($p['content'], $q); + if ($pos !== false) { + $start = max(0, $pos - 60); + $snippet = ($start > 0 ? '…' : '') . mb_substr($p['content'], $start, 200) . (mb_strlen($p['content']) > $start+200 ? '…' : ''); + } + ?> + <div class="post-search-card"> + <div class="psc-head"> + <div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0"> + <?php if ($p['avatar']): ?> + <img src="<?= e($p['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($p['username'][0]) ?></span> + <?php endif; ?> + <div style="min-width:0"> + <a href="<?= u('users/profile.php?u='.urlencode($p['username'])) ?>" style="font-weight:600;font-size:13px">@<?= e($p['username']) ?></a> + <span style="color:var(--faint);font-size:12px;margin-left:6px"> + in <a href="<?= u('forum/topic.php?slug='.urlencode($p['slug'])) ?>" style="color:var(--muted)"><?= e(mb_substr($p['title'],0,60)) ?><?= mb_strlen($p['title'])>60?'…':'' ?></a> + </span> + </div> + </div> + <div style="display:flex;align-items:center;gap:8px;flex-shrink:0"> + <span class="ago" data-ts="<?= e($p['post_date']) ?>" style="font-size:12px;color:var(--faint)"></span> + <a href="<?= e($postUrl) ?>" class="btn-ghost btn-sm">View Post →</a> + </div> + </div> + <div class="psc-body"><?= e($snippet) ?></div> + </div> + <?php endforeach; ?> + <?php endif; ?> + + <?php elseif ($type === 'users'): ?> + <?php if (empty($userResults)): ?> + <div class="empty-state"><p>No users found matching "<?= e($q) ?>".</p></div> + <?php else: ?> + <div class="user-search-grid"> + <?php foreach ($userResults as $u): + $kt = karma_tier((int)$u['karma']); ?> + <a href="<?= u('users/profile.php?u='.urlencode($u['username'])) ?>" class="user-search-card"> + <div class="usc-av"> + <?php if ($u['avatar']): ?> + <img src="<?= e($u['avatar']) ?>" class="av-lg" alt=""> + <?php else: ?> + <span class="av-lg av-init"><?= strtoupper($u['username'][0]) ?></span> + <?php endif; ?> + </div> + <div class="usc-body"> + <div class="usc-name">@<?= e($u['username']) ?> <span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></div> + <?php if ($u['bio']): ?><div class="usc-bio"><?= e(mb_substr($u['bio'],0,80)) ?></div><?php endif; ?> + <div class="usc-stats"> + <span><?= number_format($u['post_count']) ?> posts</span> + <span>·</span> + <span style="color:<?= e($kt['color']) ?>"><?= $kt['icon'] ?> <?= number_format((int)$u['karma']) ?> karma</span> + </div> + </div> + </a> + <?php endforeach; ?> + </div> + <?php endif; ?> + <?php endif; ?> + + <?php elseif ($q): ?> + <div class="alert warn">Please enter at least 2 characters to search.</div> + <?php endif; ?> +</div> + +<script> +// Auto-submit on tab change keeps query +document.querySelectorAll('.srch-tab').forEach(function(btn){ + btn.addEventListener('click',function(){ + document.getElementById('mainSearchInput').form.submit(); + }); +}); +</script> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/forum/topic.php b/forum/topic.php new file mode 100644 index 0000000..a9aecec --- /dev/null +++ b/forum/topic.php @@ -0,0 +1,326 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +$slug = get('slug'); +$topic = DB::row(" + SELECT t.*, u.username, u.avatar, + c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color + FROM topics t + JOIN users u ON u.id = t.user_id + JOIN categories c ON c.id = t.category_id + WHERE t.slug = ? +", [$slug]); +if (!$topic) render_404(); + +// Fetch full category for permission checks +$topicCat = DB::row('SELECT * FROM categories WHERE id=?', [$topic['category_id']]); +if (!can_read_category($topicCat ?? [])) render_403(); + +DB::run('UPDATE topics SET views = views + 1 WHERE id = ?', [$topic['id']]); + +$page = max(1, (int)get('page','1')); +$pp = max(1, (int)cfg('posts_per_page','20')); + +// ?goto=POST_ID — jump directly to a specific post, calculating its page +$gotoId = (int)get('goto'); +if ($gotoId) { + // Find what position this post is in the thread (0-indexed) + $postPos = (int)DB::val( + 'SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0 AND id<=?', + [$topic['id'], $gotoId] + ); + if ($postPos > 0) { + $targetPage = (int)ceil($postPos / $pp); + if ($targetPage !== $page) { + // Redirect to the correct page with the anchor + $redirectUrl = u('forum/topic.php?slug='.urlencode($slug).'&page='.$targetPage).'#post-'.$gotoId; + header('Location: ' . $redirectUrl); + exit; + } + // Already on the right page — anchor will handle scrolling + $page = $targetPage; + } +} + +$off = ($page - 1) * $pp; + +$posts = DB::rows(" + SELECT p.*, u.username, u.avatar, u.role, u.post_count, u.karma, u.location, + (SELECT COUNT(*) FROM likes l WHERE l.post_id = p.id) AS likes + FROM posts p + JOIN users u ON u.id = p.user_id + WHERE p.topic_id = ? AND p.deleted = 0 + ORDER BY p.post_num + LIMIT ? OFFSET ? +", [$topic['id'], $pp, $off]); + +$total = (int)DB::val('SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0', [$topic['id']]); +$pages = max(1, (int)ceil($total / $pp)); + +$tags = DB::rows(" + SELECT tg.* FROM tags tg + JOIN topic_tags tt ON tg.id = tt.tag_id + WHERE tt.topic_id = ? +", [$topic['id']]); + +$liked = []; +if ($USER) { + foreach (DB::rows('SELECT post_id FROM likes WHERE user_id=?', [$USER['id']]) as $l) + $liked[$l['post_id']] = true; +} + +// Post captcha for reply box +$post_cap = post_captcha_enabled() ? post_captcha_generate() : null; + +$PAGE_TITLE = $topic['title']; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<nav class="bc"> + <a href="<?= u('/') ?>">Home</a> › + <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>"><?= e($topic['cat_name']) ?></a> › + <span><?= e(mb_substr($topic['title'],0,60)) ?></span> +</nav> + +<!-- Topic header --> +<div class="topic-hdr"> + <div class="topic-hdr-main"> + <div class="topic-badges"> + <?php if ($topic['pinned']): ?><span class="tbadge pin">📌 Pinned</span><?php endif; ?> + <?php if ($topic['closed']): ?><span class="tbadge closed">🔒 Closed</span><?php endif; ?> + </div> + <h1 class="topic-title"><?= e($topic['title']) ?></h1> + <div class="topic-meta"> + <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>" + class="cat-tag" style="--cc:<?= e($topic['cat_color']) ?>"><?= e($topic['cat_name']) ?></a> + <?php foreach ($tags as $tg): ?> + <span class="tag"><?= e($tg['name']) ?></span> + <?php endforeach; ?> + <span>·</span> + <span><?= max(0, $total - 1) ?> <?= $total === 2 ? 'reply' : 'replies' ?></span> + <span>·</span> + <span><?= number_format($topic['views']) ?> views</span> + </div> + </div> + <?php if ($USER && is_admin()): ?> + <div class="topic-mod"> + <?php foreach ([ + [$topic['pinned'], 'unpin', 'pin', '📌', $topic['pinned'] ? 'Unpin' : 'Pin'], + [$topic['closed'], 'open', 'close', '🔒', $topic['closed'] ? 'Open' : 'Close'], + [$topic['archived'], 'unarchive','archive', '📦', $topic['archived'] ? 'Unarchive': 'Archive'], + ] as [$state, $onAct, $offAct, $icon, $label]): ?> + <form method="POST" action="<?= u('api/topic_action.php') ?>" style="display:inline"> + <?= csrf_input() ?> + <input type="hidden" name="id" value="<?= $topic['id'] ?>"> + <input type="hidden" name="action" value="<?= $state ? $onAct : $offAct ?>"> + <button class="btn-sm btn-ghost"><?= $icon ?> <?= $label ?></button> + </form> + <?php endforeach; ?> + </div> + <?php endif; ?> +</div> + +<?php +/* ── render_topic_header addon hook (collector) ── */ +echo addon_collect('render_topic_header', $topic); +?> + +<!-- Posts list --> +<?php $postLayoutH = cfg('post_layout_horizontal','0') === '1'; ?> +<div id="postsList" class="<?= $postLayoutH ? 'posts-horizontal' : '' ?>"> +<?php foreach ($posts as $p): ?> + <div class="post <?= $p['post_num'] == 1 ? 'post-op' : '' ?><?= $postLayoutH ? ' post-h' : '' ?>" id="post-<?= $p['id'] ?>"> + + <!-- Author sidebar --> + <div class="post-side"> + <?php if ($p['avatar']): ?> + <img src="<?= e($p['avatar']) ?>" class="av-lg" alt=""> + <?php else: ?> + <span class="av-lg av-init"><?= strtoupper($p['username'][0]) ?></span> + <?php endif; ?> + <a href="<?= u('users/profile.php?u='.urlencode($p['username'])) ?>" class="post-name"> + @<?= e($p['username']) ?> + </a> + <?php if ($p['role'] === 'admin'): ?><span class="role-flair admin">Admin</span><?php endif; ?> + <?php if ($p['role'] === 'moderator'): ?><span class="role-flair mod">Mod</span><?php endif; ?> + <span class="post-pcnt"><?= $p['post_count'] ?> posts</span> + <?php if (!empty($p['location'])): ?> + <span class="post-location" title="Location"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="9" height="9"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> + <?= e(mb_substr($p['location'],0,20)) ?> + </span> + <?php endif; ?> + <div class="post-karma-badge"> + <span class="pkb-icon"> + <svg viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="10" height="10"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> + </span> + <span class="pkb-val"><?= number_format((int)$p['karma']) ?></span> + </div> + </div> + + <!-- Post body --> + <div class="post-body"> + <div class="post-meta-bar"> + <a class="pnum" href="#post-<?= $p['id'] ?>" title="Permalink to this post">#<?= $p['post_num'] ?></a> + <button class="post-link-btn" title="Copy link to this post" + onclick="copyPostLink('<?= $p['id'] ?>',this)" + data-url="<?= e(rtrim(u('forum/topic.php?slug='.urlencode($slug)), '/')) ?>&goto=<?= $p['id'] ?>"> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> + </svg> + </button> + <time class="ago" data-ts="<?= e($p['created_at']) ?>"></time> + <?php if ($p['edited']): ?> + <em class="edit-lbl" title="<?= e($p['edit_reason'] ?? '') ?>">(edited)</em> + <?php endif; ?> + <div class="post-acts"> + <?php if ($USER): ?> + <button class="pa-btn like-btn <?= isset($liked[$p['id']]) ? 'liked' : '' ?>" + onclick="doLike(<?= $p['id'] ?>,this)"> + ♥ <span class="lc"><?= $p['likes'] ?></span> + </button> + <?php if (!$topic['closed']): ?> + <button class="pa-btn" onclick="doQuote(<?= $p['id'] ?>,'<?= e($p['username']) ?>')">↩ Quote</button> + <?php endif; ?> + <?php if ($USER['id'] == $p['user_id'] || is_admin()): ?> + <button class="pa-btn" onclick="doEdit(<?= $p['id'] ?>)">✏ Edit</button> + <button class="pa-btn del" onclick="doDelete(<?= $p['id'] ?>)">🗑 Delete</button> + <?php endif; ?> + <?php endif; ?> + </div> + </div> + + <?php if ($p['reply_to']): ?> + <div class="reply-ref"> + ↩ In reply to <a href="#post-<?= $p['reply_to'] ?>">#<?= $p['reply_to'] ?></a> + </div> + <?php endif; ?> + + <!-- Rendered content — server-side markdown + embeds --> + <div class="post-content rendered-post" id="pc-<?= $p['id'] ?>" + data-raw="<?= e(base64_encode($p['content'])) ?>"> + <?= addon_hook('render_post_content', render_post($p['content'])) ?> + </div> + + <?php /* ── render_post_footer addon hook (collector) ── */ + echo addon_collect('render_post_footer', $p); + ?> + + <!-- Edit box — hidden by default, toggled by doEdit() --> + <div class="edit-box" id="eb-<?= $p['id'] ?>"> + <div class="ed-toolbar"> + <button type="button" onclick="fmt('bold','et-<?= $p['id'] ?>')"><b>B</b></button> + <button type="button" onclick="fmt('italic','et-<?= $p['id'] ?>')"><i>I</i></button> + <button type="button" onclick="fmt('quote','et-<?= $p['id'] ?>')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button> + <button type="button" onclick="pickImg('pi-<?= $p['id'] ?>','et-<?= $p['id'] ?>')">🖼</button> + <input type="file" id="pi-<?= $p['id'] ?>" accept="image/*" style="display:none" + onchange="uploadImg(this,'et-<?= $p['id'] ?>')"> + </div> + <textarea id="et-<?= $p['id'] ?>" class="edit-ta fi" rows="7" + placeholder="Edit your post…"></textarea> + <input type="text" id="er-<?= $p['id'] ?>" class="fi edit-reason-inp" + placeholder="Edit reason (optional)" style="margin-top:6px"> + <div class="edit-btns"> + <button class="btn-ghost btn-sm" onclick="cancelEdit(<?= $p['id'] ?>)">Cancel</button> + <button class="btn-primary btn-sm" onclick="saveEdit(<?= $p['id'] ?>)">Save Changes</button> + </div> + </div> + + </div> + </div> +<?php endforeach; ?> +</div> + +<!-- Pagination --> +<?php if ($pages > 1): ?> + <nav class="pager"> + <?php if ($page > 1): ?> + <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page-1 ?>" class="pg-btn">← Prev</a> + <?php endif; ?> + <?php for ($i = max(1,$page-2); $i <= min($pages,$page+2); $i++): ?> + <a href="?slug=<?= urlencode($slug) ?>&page=<?= $i ?>" + class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a> + <?php endfor; ?> + <?php if ($page < $pages): ?> + <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page+1 ?>" class="pg-btn">Next →</a> + <?php endif; ?> + </nav> +<?php endif; ?> + +<!-- Reply box --> +<?php if ($USER && !$topic['closed']): ?> + <div class="reply-box" id="replyBox"> + <div class="reply-hdr"> + <?php if ($USER['avatar']): ?> + <img src="<?= e($USER['avatar']) ?>" class="av-md" alt=""> + <?php else: ?> + <span class="av-md av-init"><?= strtoupper($USER['username'][0]) ?></span> + <?php endif; ?> + <span>Reply as <strong>@<?= e($USER['username']) ?></strong></span> + </div> + + <div class="ed-toolbar"> + <button type="button" onclick="fmt('bold')"><b>B</b></button> + <button type="button" onclick="fmt('italic')"><i>I</i></button> + <button type="button" onclick="fmt('link')">🔗</button> + <button type="button" onclick="fmt('quote')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button> + <button type="button" onclick="fmt('codeblock')">📄</button> + <button type="button" onclick="fmt('heading')">H</button> + <button type="button" onclick="fmt('ul')">≡</button> + <div class="ed-sep"></div> + <button type="button" onclick="pickImg('mainImg','replyTa')">🖼</button> + <input type="file" id="mainImg" accept="image/*" style="display:none" + onchange="uploadImg(this,'replyTa')"> + <div class="ed-sep"></div> + <button type="button" id="prevBtn" onclick="togglePreview()">👁 Preview</button> + </div> + + <div class="ed-panes"> + <textarea id="replyTa" class="reply-ta" + placeholder="Write your reply… Markdown supported. Paste or drag images to embed. Type @username to mention someone." + rows="8"></textarea> + <div id="replyPreview" class="reply-preview hidden"></div> + </div> + + <div class="reply-footer"> + <span class="hint">Markdown · Images · @mentions · Auto-embeds YouTube & more</span> + <div class="reply-footer-right"> + <span id="charCnt" class="cnt">0</span> + <button class="btn-primary" id="replyBtn" + onclick="sendReply('<?= e($topic['slug']) ?>')">Post Reply</button> + </div> + </div> + + <?php if ($post_cap): ?> + <div class="post-captcha-row" id="postCaptchaRow"> + <span class="post-captcha-label"> + 🔒 Security — What is <strong class="captcha-q"><?= e($post_cap['q']) ?></strong> + </span> + <input type="number" id="postCaptchaInput" class="fi post-captcha-inp" + placeholder="?" autocomplete="off" min="-99" max="99"> + <span class="hint">Solve to post</span> + </div> + <?php endif; ?> + + <div class="rate-limit-info" id="rateLimitInfo" style="display:none"> + <div class="rate-limit-bar"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> + <span id="rateLimitMsg">Please wait before posting again:</span> + <div class="rate-countdown" id="rateCountdown"></div> + </div> + </div> + </div> + +<?php elseif (!$USER): ?> + <div class="reply-cta"> + <p>Join the discussion!</p> + <a href="<?= u('auth/login.php') ?>" class="btn-ghost">Log In</a> + <a href="<?= u('auth/register.php') ?>" class="btn-primary">Sign Up to Reply</a> + </div> + +<?php else: ?> + <div class="closed-notice">🔒 This topic is closed to new replies.</div> +<?php endif; ?> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/includes/addons.php b/includes/addons.php new file mode 100644 index 0000000..d88fa89 --- /dev/null +++ b/includes/addons.php @@ -0,0 +1,176 @@ +<?php +/** + * Nexus Forum — Addon System + * + * Two hook patterns: + * + * AddonManager::fire($hook, $data) + * FILTER pattern — each callback receives the return value of the previous. + * Use for transforming a single value (e.g. render_post_content: HTML string in/out). + * + * AddonManager::collect($hook, $context) + * COLLECTOR pattern — every callback receives the same $context array. + * Non-empty string returns are accumulated and joined. + * Use for output hooks where multiple addons each contribute HTML + * (e.g. render_post_footer, render_topic_header, before_page_head). + */ +if (!defined('NEXUS')) exit('Forbidden'); + +class AddonManager { + private static array $hooks = []; + private static array $loaded = []; + private static ?array $active = null; + + /* ── Boot all active addons ─────────────────────────────── */ + public static function boot(): void { + foreach (self::activeList() as $slug) { + $main = ROOT . '/addons/' . $slug . '/main.php'; + if (file_exists($main) && !isset(self::$loaded[$slug])) { + try { + require_once $main; + self::$loaded[$slug] = true; + } catch (Throwable $e) { + error_log("Addon [$slug] boot error: " . $e->getMessage()); + } + } + } + } + + /* ── Register a hook callback ────────────────────────────── */ + public static function on(string $hook, callable $cb, int $priority = 10): void { + self::$hooks[$hook][$priority][] = $cb; + } + + /* ── FILTER: chain callbacks, each receives previous return ─ */ + public static function fire(string $hook, mixed $data = null): mixed { + if (empty(self::$hooks[$hook])) return $data; + $buckets = self::$hooks[$hook]; + ksort($buckets); + foreach ($buckets as $callbacks) { + foreach ($callbacks as $cb) { + try { + $result = $cb($data); + if ($result !== null) $data = $result; + } catch (Throwable $e) { + error_log("Addon hook [$hook] filter error: " . $e->getMessage()); + } + } + } + return $data; + } + + /* ── COLLECTOR: all callbacks receive same $context ──────── */ + /* Each callback should return '' or an HTML string to append. */ + public static function collect(string $hook, mixed $context = null): string { + if (empty(self::$hooks[$hook])) return ''; + $buckets = self::$hooks[$hook]; + ksort($buckets); + $output = ''; + foreach ($buckets as $callbacks) { + foreach ($callbacks as $cb) { + try { + $result = $cb($context); + if (is_string($result) && $result !== '') { + $output .= $result; + } + } catch (Throwable $e) { + error_log("Addon hook [$hook] collect error: " . $e->getMessage()); + } + } + } + return $output; + } + + public static function hasHook(string $hook): bool { + return !empty(self::$hooks[$hook]); + } + + /* ── Active addon list ──────────────────────────────────── */ + public static function activeList(): array { + if (self::$active !== null) return self::$active; + try { + $val = DB::val("SELECT value FROM settings WHERE `key`='active_addons'"); + self::$active = $val ? (json_decode($val, true) ?: []) : []; + } catch (Throwable $e) { + self::$active = []; + } + return self::$active; + } + + /* ── Scan all installed addons ──────────────────────────── */ + public static function all(): array { + $dir = ROOT . '/addons'; + $active = self::activeList(); + $addons = []; + if (!is_dir($dir)) return []; + foreach (scandir($dir) as $slug) { + if ($slug[0] === '.') continue; + $manifest = $dir . '/' . $slug . '/nexus-addon.json'; + if (!file_exists($manifest)) continue; + $info = json_decode(file_get_contents($manifest), true); + if (!$info || empty($info['name'])) continue; + $addons[$slug] = array_merge([ + 'slug' => $slug, 'name' => $slug, 'description' => '', + 'version' => '1.0.0', 'author' => 'Unknown', 'url' => '', + 'hooks' => [], 'requires' => [], + ], $info, [ + 'slug' => $slug, + 'active' => in_array($slug, $active), + ]); + } + return $addons; + } + + /* ── Activate / deactivate ──────────────────────────────── */ + public static function activate(string $slug): bool { + $active = self::activeList(); + if (in_array($slug, $active)) return true; + $install = ROOT . '/addons/' . $slug . '/install.php'; + if (file_exists($install)) { + try { require $install; } catch (Throwable $e) { + error_log("Addon [$slug] install error: " . $e->getMessage()); + return false; + } + } + $active[] = $slug; + self::saveActive($active); + self::$active = $active; + return true; + } + + public static function deactivate(string $slug): bool { + $active = self::activeList(); + if (!in_array($slug, $active)) return true; + $uninstall = ROOT . '/addons/' . $slug . '/uninstall.php'; + if (file_exists($uninstall)) { + try { require $uninstall; } catch (Throwable $e) { + error_log("Addon [$slug] uninstall error: " . $e->getMessage()); + } + } + $active = array_values(array_filter($active, fn($s) => $s !== $slug)); + self::saveActive($active); + self::$active = $active; + return true; + } + + private static function saveActive(array $active): void { + DB::upsert('settings', 'key', 'value', 'active_addons', json_encode(array_values($active))); + } +} + +/* ── Global convenience helpers ─────────────────────────────── */ + +/** FILTER hook — transforms a value through all callbacks */ +function addon_hook(string $hook, mixed $data = null): mixed { + return AddonManager::fire($hook, $data); +} + +/** COLLECTOR hook — every callback gets same $context, returns accumulated HTML */ +function addon_collect(string $hook, mixed $context = null): string { + return AddonManager::collect($hook, $context); +} + +/** Register a callback on a hook */ +function addon_on(string $hook, callable $cb, int $priority = 10): void { + AddonManager::on($hook, $cb, $priority); +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php new file mode 100644 index 0000000..917bc8f --- /dev/null +++ b/includes/bootstrap.php @@ -0,0 +1,40 @@ +<?php +define('NEXUS', true); + +require_once __DIR__ . '/config.php'; +require_once __DIR__ . '/db.php'; +require_once __DIR__ . '/functions.php'; + +start_session(); + +// Redirect to installer if not installed +if (!file_exists(DATA . '/installed.lock')) { + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (strpos($uri, '/install') === false) { + header('Location: ' . BASE . '/install/'); + exit; + } +} + +// Auto-migrate: ensure all tables exist for upgraded installs. +// CREATE TABLE IF NOT EXISTS is idempotent — safe to run on every request. +// This fixes "table not found" errors when upgrading from older versions. +static $_dbInited = false; +if (!$_dbInited) { + try { + DB::init(); + $_dbInited = true; + } catch (Throwable $e) { + // Log but don't crash — if DB is unreachable the next query will fail with a clearer error + error_log('Nexus DB::init() failed: ' . $e->getMessage()); + } +} + +require_once __DIR__ . '/markdown.php'; +require_once __DIR__ . '/addons.php'; + +// Set global current user +$USER = current_user(); + +// Boot addons after user is set +AddonManager::boot(); diff --git a/includes/config.php b/includes/config.php new file mode 100644 index 0000000..3a4c4da --- /dev/null +++ b/includes/config.php @@ -0,0 +1,69 @@ +<?php +/** + * Nexus Forum — Core Configuration + * Auto-detects base path. Loads db_config.php if present. + */ +if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); } + +/* ── Paths ──────────────────────────────────────────────────── */ +define('ROOT', dirname(__DIR__)); +define('DATA', ROOT . '/data'); +define('UPLOADS', ROOT . '/public/uploads'); + +/* ── Base URL detection ─────────────────────────────────────── */ +if (!defined('BASE')) { + $docRoot = rtrim(str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT'] ?? ''), '/'); + $rootPath = str_replace('\\', '/', ROOT); + $base = str_replace($docRoot, '', $rootPath); + $base = '/' . trim($base, '/'); + define('BASE', $base === '/' ? '' : $base); +} + +/* ── Database config (written by installer) ─────────────────── */ +$dbCfg = ROOT . '/includes/db_config.php'; +if (file_exists($dbCfg)) { + require_once $dbCfg; +} else { + // Default: SQLite (installer not yet run, or config missing) + if (!defined('DB_DRIVER')) define('DB_DRIVER', 'sqlite'); +} + +/* ── Security headers (applied on every page load) ──────────── */ +if (!headers_sent()) { + header('X-Content-Type-Options: nosniff'); + header('X-Frame-Options: SAMEORIGIN'); + header('X-XSS-Protection: 1; mode=block'); + header('Referrer-Policy: strict-origin-when-cross-origin'); + header('Permissions-Policy: camera=(), microphone=(), geolocation=()'); + + // Content-Security-Policy — blocks injected scripts even if XSS were possible. + // script-src 'self' allows our own JS; cdnjs for Prism.js; 'unsafe-inline' for + // onclick= attributes used throughout the forum (toolbar buttons, embeds etc.). + // frame-src allows trusted embed domains only. + $csp = implode('; ', [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://platform.twitter.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: https:", + "media-src 'self' https:", + "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://soundcloud.com https://bandcamp.com https://codepen.io https://jsfiddle.net https://www.loom.com https://rumble.com https://embed.ted.com https://www.dailymotion.com https://streamable.com https://platform.twitter.com https://syndication.twitter.com", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ]); + header('Content-Security-Policy: ' . $csp); + // Only set HSTS if running HTTPS + if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') { + header('Strict-Transport-Security: max-age=31536000; includeSubDomains'); + } +} + +/* ── Error handling ──────────────────────────────────────────── */ +// Production: hide errors from users, log them instead +if (!(defined('NEXUS_DEBUG') && NEXUS_DEBUG)) { + ini_set('display_errors', '0'); + ini_set('log_errors', '1'); + error_reporting(E_ALL); +} diff --git a/includes/db.php b/includes/db.php new file mode 100644 index 0000000..0724fcc --- /dev/null +++ b/includes/db.php @@ -0,0 +1,582 @@ +<?php +if (!defined('NEXUS')) exit('Forbidden'); + +/** + * Database abstraction — supports SQLite3, MySQL, and MariaDB. + * Driver is selected from DB_DRIVER constant (set by installer or config.php). + * + * SQLite: DB_DRIVER='sqlite' (default, no credentials needed) + * MySQL: DB_DRIVER='mysql' + DB_HOST, DB_NAME, DB_USER, DB_PASS, DB_PORT + * MariaDB: DB_DRIVER='mysql' (same driver as MySQL via PDO) + */ +class DB { + private static ?PDO $pdo = null; + + /* ── Connect ──────────────────────────────────────────────── */ + public static function connect(): PDO { + if (self::$pdo !== null) return self::$pdo; + + $driver = defined('DB_DRIVER') ? DB_DRIVER : 'sqlite'; + + if ($driver === 'mysql') { + $host = defined('DB_HOST') ? DB_HOST : '127.0.0.1'; + $port = defined('DB_PORT') ? DB_PORT : '3306'; + $name = defined('DB_NAME') ? DB_NAME : 'nexus'; + $user = defined('DB_USER') ? DB_USER : 'root'; + $pass = defined('DB_PASS') ? DB_PASS : ''; + $charset = defined('DB_CHARSET') ? DB_CHARSET : 'utf8mb4'; + + $dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}"; + self::$pdo = new PDO($dsn, $user, $pass, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_FOUND_ROWS => true, + ]); + self::$pdo->exec("SET SESSION sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO'"); + } else { + // SQLite + $path = DATA . '/forum.db'; + if (!is_dir(DATA)) mkdir(DATA, 0750, true); + self::$pdo = new PDO('sqlite:' . $path, null, null, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + self::$pdo->exec('PRAGMA foreign_keys = ON'); + self::$pdo->exec('PRAGMA journal_mode = WAL'); + self::$pdo->exec('PRAGMA synchronous = NORMAL'); + self::$pdo->exec('PRAGMA temp_store = MEMORY'); + self::$pdo->exec('PRAGMA mmap_size = 268435456'); + } + + return self::$pdo; + } + + public static function driver(): string { + return defined('DB_DRIVER') ? DB_DRIVER : 'sqlite'; + } + + public static function isMysql(): bool { + return self::driver() === 'mysql'; + } + + /* ── Query helpers ────────────────────────────────────────── */ + public static function run(string $sql, array $p = []): PDOStatement { + $s = self::connect()->prepare($sql); + $s->execute($p); + return $s; + } + + public static function row(string $sql, array $p = []): ?array { + $r = self::run($sql, $p)->fetch(); + return $r ?: null; + } + + public static function rows(string $sql, array $p = []): array { + return self::run($sql, $p)->fetchAll(); + } + + public static function insert(string $sql, array $p = []): int { + self::run($sql, $p); + return (int) self::connect()->lastInsertId(); + } + + public static function val(string $sql, array $p = []): mixed { + $row = self::run($sql, $p)->fetch(PDO::FETCH_NUM); + return $row ? $row[0] : null; + } + + /** + * Cross-driver INSERT IGNORE. + * Silently skips if the row already exists (duplicate key). + */ + public static function insertIgnore(string $table, array $cols, array $vals): void { + $placeholders = implode(',', array_fill(0, count($vals), '?')); + $colList = implode(',', array_map(fn($c) => "`$c`", $cols)); + if (self::isMysql()) { + self::run("INSERT IGNORE INTO `{$table}` ({$colList}) VALUES ({$placeholders})", $vals); + } else { + self::run("INSERT OR IGNORE INTO {$table} (" . implode(',', $cols) . ") VALUES ({$placeholders})", $vals); + } + } + + /** + * Cross-driver upsert (INSERT ... ON DUPLICATE KEY UPDATE for MySQL, + * INSERT OR REPLACE for SQLite). + * Only works for simple single-column key tables like settings(key,value). + */ + public static function upsert(string $table, string $keyCol, string $valCol, string $key, string $val): void { + if (self::isMysql()) { + self::run( + "INSERT INTO `{$table}` (`{$keyCol}`,`{$valCol}`) VALUES (?,?) ON DUPLICATE KEY UPDATE `{$valCol}`=VALUES(`{$valCol}`)", + [$key, $val] + ); + } else { + self::run( + "INSERT OR REPLACE INTO {$table} ({$keyCol},{$valCol}) VALUES (?,?)", + [$key, $val] + ); + } + } + + /* ── Schema: cross-driver table creation ─────────────────── */ + public static function init(): void { + $mysql = self::isMysql(); + + /* Helper: datetime default compatible with both drivers */ + $now = $mysql ? "DEFAULT CURRENT_TIMESTAMP" : "DEFAULT (datetime('now'))"; + $auto = $mysql ? "INT AUTO_INCREMENT" : "INTEGER PRIMARY KEY AUTOINCREMENT"; + $pk = $mysql ? "PRIMARY KEY" : ""; + $eng = $mysql ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" : ""; + + $tables = []; + + /* settings */ + $tables[] = "CREATE TABLE IF NOT EXISTS settings ( + `key` VARCHAR(100) PRIMARY KEY, + `value` TEXT NOT NULL + ) $eng"; + + /* users */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(30) NOT NULL, + email VARCHAR(254) NOT NULL, + password VARCHAR(255) NOT NULL, + avatar TEXT, + bio TEXT, + role VARCHAR(20) NOT NULL DEFAULT 'member', + permissions TEXT NOT NULL DEFAULT '{}', + post_count INT NOT NULL DEFAULT 0, + topic_count INT NOT NULL DEFAULT 0, + karma INT NOT NULL DEFAULT 0, + suspended TINYINT NOT NULL DEFAULT 0, + silenced TINYINT NOT NULL DEFAULT 0, + location VARCHAR(100) NOT NULL DEFAULT '', + friends_hidden TINYINT NOT NULL DEFAULT 0, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_username (username), + UNIQUE KEY uq_email (email) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar TEXT, + bio TEXT, + role TEXT NOT NULL DEFAULT 'member', + permissions TEXT NOT NULL DEFAULT '{}', + post_count INTEGER NOT NULL DEFAULT 0, + topic_count INTEGER NOT NULL DEFAULT 0, + karma INTEGER NOT NULL DEFAULT 0, + suspended INTEGER NOT NULL DEFAULT 0, + silenced INTEGER NOT NULL DEFAULT 0, + location TEXT NOT NULL DEFAULT '', + friends_hidden INTEGER NOT NULL DEFAULT 0, + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* categories */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + color VARCHAR(20) NOT NULL DEFAULT '#3b82f6', + icon VARCHAR(10) NOT NULL DEFAULT '💬', + position INT NOT NULL DEFAULT 0, + parent_id INT, + topic_count INT NOT NULL DEFAULT 0, + post_count INT NOT NULL DEFAULT 0, + read_role VARCHAR(20) NOT NULL DEFAULT 'guest', + post_role VARCHAR(20) NOT NULL DEFAULT 'member', + reply_role VARCHAR(20) NOT NULL DEFAULT 'member', + UNIQUE KEY uq_slug (slug) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#3b82f6', + icon TEXT NOT NULL DEFAULT '💬', + position INTEGER NOT NULL DEFAULT 0, + parent_id INTEGER, + topic_count INTEGER NOT NULL DEFAULT 0, + post_count INTEGER NOT NULL DEFAULT 0, + read_role TEXT NOT NULL DEFAULT 'guest', + post_role TEXT NOT NULL DEFAULT 'member', + reply_role TEXT NOT NULL DEFAULT 'member' + )"; + } + + /* topics */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS topics ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + slug VARCHAR(220) NOT NULL, + category_id INT NOT NULL, + user_id INT NOT NULL, + views INT NOT NULL DEFAULT 0, + reply_count INT NOT NULL DEFAULT 0, + pinned TINYINT NOT NULL DEFAULT 0, + closed TINYINT NOT NULL DEFAULT 0, + archived TINYINT NOT NULL DEFAULT 0, + last_post_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_slug (slug), + KEY ix_cat (category_id), + KEY ix_user (user_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS topics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + category_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + reply_count INTEGER NOT NULL DEFAULT 0, + pinned INTEGER NOT NULL DEFAULT 0, + closed INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + last_post_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* posts */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + topic_id INT NOT NULL, + user_id INT NOT NULL, + content TEXT NOT NULL, + post_num INT NOT NULL, + reply_to INT, + likes INT NOT NULL DEFAULT 0, + edited TINYINT NOT NULL DEFAULT 0, + edit_reason VARCHAR(200), + deleted TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_topic (topic_id), + KEY ix_user (user_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + topic_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + content TEXT NOT NULL, + post_num INTEGER NOT NULL, + reply_to INTEGER, + likes INTEGER NOT NULL DEFAULT 0, + edited INTEGER NOT NULL DEFAULT 0, + edit_reason TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* likes */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS likes ( + post_id INT NOT NULL, + user_id INT NOT NULL, + PRIMARY KEY (post_id, user_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS likes ( + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + PRIMARY KEY (post_id, user_id) + )"; + } + + /* tags + topic_tags */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS tags ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + topic_count INT NOT NULL DEFAULT 0, + UNIQUE KEY uq_name (name) + ) $eng"; + $tables[] = "CREATE TABLE IF NOT EXISTS topic_tags ( + topic_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (topic_id, tag_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + topic_count INTEGER NOT NULL DEFAULT 0 + )"; + $tables[] = "CREATE TABLE IF NOT EXISTS topic_tags ( + topic_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (topic_id, tag_id) + )"; + } + + /* notifications */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS notifications ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type VARCHAR(50) NOT NULL, + payload TEXT NOT NULL, + `read` TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_user (user_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* friends */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS friends ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + friend_id INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_pair (user_id, friend_id), + KEY ix_uid (user_id), + KEY ix_fid (friend_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS friends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + friend_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, friend_id) + )"; + } + + /* messages */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + sender_id INT NOT NULL, + receiver_id INT NOT NULL, + subject VARCHAR(150) NOT NULL DEFAULT '', + body TEXT NOT NULL, + is_read TINYINT NOT NULL DEFAULT 0, + deleted_by_sender TINYINT NOT NULL DEFAULT 0, + deleted_by_receiver TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_recv (receiver_id), + KEY ix_send (sender_id) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, + receiver_id INTEGER NOT NULL, + subject TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + deleted_by_sender INTEGER NOT NULL DEFAULT 0, + deleted_by_receiver INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* rate_events */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS rate_events ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + event_type VARCHAR(20) NOT NULL DEFAULT 'post', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY ix_rate (user_id, event_type, created_at) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS rate_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + event_type TEXT NOT NULL DEFAULT 'post', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + /* themes */ + if ($mysql) { + $tables[] = "CREATE TABLE IF NOT EXISTS themes ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + css LONGTEXT NOT NULL DEFAULT '', + is_active TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_slug (slug) + ) $eng"; + } else { + $tables[] = "CREATE TABLE IF NOT EXISTS themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + css TEXT NOT NULL DEFAULT '', + is_active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )"; + } + + $pdo = self::connect(); + foreach ($tables as $sql) { + $pdo->exec($sql); + } + + /* SQLite-only indexes */ + if (!$mysql) { + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_topics_cat ON topics(category_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_posts_topic ON posts(topic_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_notif_user ON notifications(user_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_rate_user ON rate_events(user_id, event_type, created_at)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_friends_u ON friends(user_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS ix_msg_recv ON messages(receiver_id)"); + } + + /* ── Column migrations (safe for both SQLite + MySQL upgrades) ── */ + // Add columns that were introduced in later versions. + // Uses IF NOT EXISTS logic compatible with both drivers. + if ($mysql) { + // MySQL: check information_schema then ALTER TABLE if column missing + $dbName = defined('DB_NAME') ? DB_NAME : ''; + $existingCols = array_column(self::rows( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'users'", + [$dbName] + ), 'COLUMN_NAME'); + $mysqlAdd = [ + 'karma' => "ALTER TABLE users ADD COLUMN karma INT NOT NULL DEFAULT 0", + 'permissions' => "ALTER TABLE users ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'", + 'friends_hidden' => "ALTER TABLE users ADD COLUMN friends_hidden TINYINT NOT NULL DEFAULT 0", + 'suspended' => "ALTER TABLE users ADD COLUMN suspended TINYINT NOT NULL DEFAULT 0", + 'silenced' => "ALTER TABLE users ADD COLUMN silenced TINYINT NOT NULL DEFAULT 0", + 'topic_count' => "ALTER TABLE users ADD COLUMN topic_count INT NOT NULL DEFAULT 0", + ]; + foreach ($mysqlAdd as $col => $sql) { + if (!in_array($col, $existingCols)) { + try { self::run($sql); } catch (\Throwable $e) { /* already exists */ } + } + } + } else { + // SQLite: use PRAGMA table_info + $cols = array_column(self::rows("PRAGMA table_info(users)"), 'name'); + $sqliteAdd = [ + 'karma' => "ALTER TABLE users ADD COLUMN karma INTEGER NOT NULL DEFAULT 0", + 'permissions' => "ALTER TABLE users ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'", + 'friends_hidden' => "ALTER TABLE users ADD COLUMN friends_hidden INTEGER NOT NULL DEFAULT 0", + 'suspended' => "ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0", + 'silenced' => "ALTER TABLE users ADD COLUMN silenced INTEGER NOT NULL DEFAULT 0", + 'topic_count' => "ALTER TABLE users ADD COLUMN topic_count INTEGER NOT NULL DEFAULT 0", + ]; + foreach ($sqliteAdd as $col => $sql) { + if (!in_array($col, $cols)) { + try { self::run($sql); } catch (\Throwable $e) { /* already exists */ } + } + } + } + + /* ── Categories: role permission columns ── */ + if ($mysql) { + $dbName = defined('DB_NAME') ? DB_NAME : ''; + $catCols = array_column(self::rows( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME='categories'", + [$dbName] + ), 'COLUMN_NAME'); + foreach (['read_role'=>"VARCHAR(20) NOT NULL DEFAULT 'guest'",'post_role'=>"VARCHAR(20) NOT NULL DEFAULT 'member'",'reply_role'=>"VARCHAR(20) NOT NULL DEFAULT 'member'"] as $col=>$def) { + if (!in_array($col,$catCols)) { try{self::run("ALTER TABLE categories ADD COLUMN $col $def");}catch(\Throwable $e){} } + } + } else { + $catCols = array_column(self::rows("PRAGMA table_info(categories)"),'name'); + foreach (['read_role'=>"TEXT NOT NULL DEFAULT 'guest'",'post_role'=>"TEXT NOT NULL DEFAULT 'member'",'reply_role'=>"TEXT NOT NULL DEFAULT 'member'"] as $col=>$def) { + if (!in_array($col,$catCols)) { try{self::run("ALTER TABLE categories ADD COLUMN $col $def");}catch(\Throwable $e){} } + } + } + + /* ── Users: add location column ── */ + if ($mysql) { + $dbName = defined('DB_NAME') ? DB_NAME : ''; + $uCols = array_column(self::rows( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME='users'", + [$dbName] + ), 'COLUMN_NAME'); + if (!in_array('location', $uCols)) { + try { self::run("ALTER TABLE users ADD COLUMN location VARCHAR(100) NOT NULL DEFAULT ''"); } + catch (\Throwable $e) {} + } + } else { + $uCols2 = array_column(self::rows("PRAGMA table_info(users)"), 'name'); + if (!in_array('location', $uCols2)) { + try { self::run("ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT ''"); } + catch (\Throwable $e) {} + } + } + + /* ── Messages: add conversation_id for chat threading ── */ + // conversation_id = LEAST(sender_id, receiver_id) * 1000000 + GREATEST(...) + // We store it as a VARCHAR key for easy lookup + if ($mysql) { + $dbName = defined('DB_NAME') ? DB_NAME : ''; + $msgCols = array_column(self::rows( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'messages'", [$dbName] + ), 'COLUMN_NAME'); + if (!in_array('conversation_id', $msgCols)) { + try { + self::run("ALTER TABLE messages ADD COLUMN conversation_id VARCHAR(30) NOT NULL DEFAULT ''"); + self::run("ALTER TABLE messages ADD INDEX ix_conv (conversation_id)"); + // Back-fill existing rows + self::run("UPDATE messages SET conversation_id = CONCAT(LEAST(sender_id,receiver_id),'-',GREATEST(sender_id,receiver_id))"); + } catch (\Throwable $e) { /* ignore */ } + } + } else { + $msgCols = array_column(self::rows("PRAGMA table_info(messages)"), 'name'); + if (!in_array('conversation_id', $msgCols)) { + try { + self::run("ALTER TABLE messages ADD COLUMN conversation_id TEXT NOT NULL DEFAULT ''"); + self::run("UPDATE messages SET conversation_id = MIN(sender_id,receiver_id)||'-'||MAX(sender_id,receiver_id)"); + } catch (\Throwable $e) { /* ignore */ } + } + } + } + + + /* ── Helpers ─────────────────────────────────────────────── */ + /** Cross-driver NOW() */ + public static function now(): string { + return self::isMysql() ? 'NOW()' : "datetime('now')"; + } + + /** Cross-driver datetime comparison for rate limiting */ + public static function sinceSeconds(int $seconds): string { + if (self::isMysql()) { + return "DATE_SUB(NOW(), INTERVAL {$seconds} SECOND)"; + } + return "datetime('now','-{$seconds} seconds')"; + } +} diff --git a/includes/functions.php b/includes/functions.php new file mode 100644 index 0000000..123c04b --- /dev/null +++ b/includes/functions.php @@ -0,0 +1,608 @@ +<?php +if (!defined('NEXUS')) exit('Forbidden'); + +/* ── Output escaping ───────────────────────────────────────── */ +function e(mixed $v): string { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +/* ── URL building ──────────────────────────────────────────── */ +function u(string $path = ''): string { + return BASE . '/' . ltrim($path, '/'); +} +function asset(string $path): string { + return BASE . '/public/' . ltrim($path, '/'); +} + +/* ── Redirects ─────────────────────────────────────────────── */ +function go(string $path): never { + header('Location: ' . u($path)); + exit; +} + +/* ── Settings ──────────────────────────────────────────────── */ +function cfg(string $key, string $default = ''): string { + static $c = null; + if ($c === null) { + try { + $rows = DB::rows('SELECT key, value FROM settings'); + $c = array_column($rows, 'value', 'key'); + } catch (\Throwable $e) { $c = []; } + } + return $c[$key] ?? $default; +} +function cfg_set(string $key, string $value): void { + DB::upsert('settings', 'key', 'value', $key, $value); +} + +/* ── Active theme CSS ──────────────────────────────────────── */ +function active_theme_css(): string { + try { + $theme = DB::row("SELECT css FROM themes WHERE is_active=1 LIMIT 1"); + return $theme ? '<style id="theme-css">' . $theme['css'] . '</style>' : ''; + } catch (\Throwable $e) { return ''; } +} + +/* ── Auth ──────────────────────────────────────────────────── */ +function start_session(): void { + if (session_status() === PHP_SESSION_ACTIVE) return; + session_set_cookie_params(['lifetime' => 86400*30,'path'=>'/','httponly'=>true,'samesite'=>'Lax']); + session_start(); +} +function current_user(): ?array { + start_session(); + if (empty($_SESSION['uid'])) return null; + $u = DB::row('SELECT * FROM users WHERE id=?', [$_SESSION['uid']]); + if (!$u) { unset($_SESSION['uid']); return null; } + $now = DB::now(); + DB::run("UPDATE users SET last_seen=$now WHERE id=?", [$u['id']]); + return $u; +} +function login_user(int $id): void { + start_session(); + session_regenerate_id(true); + $_SESSION['uid'] = $id; +} +function logout_user(): void { + start_session(); + session_destroy(); +} +function must_login(): void { + global $USER; + if (!$USER) go('auth/login.php?next=' . urlencode($_SERVER['REQUEST_URI'])); +} +function must_admin(): void { + global $USER; + must_login(); + if (!is_admin()) render_403(); +} +function must_admin_only(): void { + global $USER; + must_login(); + if ($USER['role'] !== 'admin') render_403(); +} +function is_admin(?array $u = null): bool { + global $USER; + $u = $u ?? $USER; + return $u && in_array($u['role'], ['admin','moderator']); +} +/* Check a specific permission (stored as JSON in users.permissions) */ +function has_perm(string $perm, ?array $u = null): bool { + global $USER; + $u = $u ?? $USER; + if (!$u) return false; + if ($u['role'] === 'admin') return true; + $perms = json_decode($u['permissions'] ?? '{}', true) ?: []; + return !empty($perms[$perm]); +} + +/* ── CSRF ──────────────────────────────────────────────────── */ +function csrf(): string { + start_session(); + if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32)); + return $_SESSION['csrf']; +} +function csrf_input(): string { + return '<input type="hidden" name="csrf" value="' . e(csrf()) . '">'; +} +function csrf_ok(): bool { + $t = $_POST['csrf'] ?? ''; + return $t !== '' && hash_equals(csrf(), $t); +} + +/* ── Slugs ─────────────────────────────────────────────────── */ +function make_slug(string $text): string { + $s = mb_strtolower(trim($text), 'UTF-8'); + $s = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $s); + $s = preg_replace('/[\s\-]+/', '-', $s); + return substr($s, 0, 80) ?: 'post'; +} +function unique_slug(string $text, string $table, ?int $skip = null): string { + $base = make_slug($text); $slug = $base; $n = 1; + while (true) { + $sql = "SELECT id FROM $table WHERE slug=?"; $p = [$slug]; + if ($skip) { $sql .= ' AND id!=?'; $p[] = $skip; } + if (!DB::row($sql, $p)) break; + $slug = $base . '-' . $n++; + } + return $slug; +} + +/* ── Time ──────────────────────────────────────────────────── */ +function time_ago(string $dt): string { + $diff = time() - strtotime($dt); + if ($diff < 60) return 'just now'; + if ($diff < 3600) return floor($diff/60) . 'm ago'; + if ($diff < 86400) return floor($diff/3600) . 'h ago'; + if ($diff < 604800) return floor($diff/86400) . 'd ago'; + return date('M j, Y', strtotime($dt)); +} + +/* ── Notifications ─────────────────────────────────────────── */ +function unread_count(): int { + global $USER; + if (!$USER) return 0; + return (int) DB::val('SELECT COUNT(*) FROM notifications WHERE user_id=? AND `read`=0', [$USER['id']]); +} +function add_notification(int $uid, string $type, array $data): void { + global $USER; + if ($USER && $uid === (int)$USER['id']) return; + DB::insert('INSERT INTO notifications (user_id,type,payload) VALUES (?,?,?)', + [$uid, $type, json_encode($data)]); +} + +/* ── Karma ─────────────────────────────────────────────────── */ +function add_karma(int $uid, int $pts = 1): void { + DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$pts, $uid]); +} + +/* ── Sidebar categories ────────────────────────────────────── */ +function nav_categories(): array { + return DB::rows('SELECT * FROM categories WHERE parent_id IS NULL ORDER BY position, id'); +} + +/* ── Forum-wide stats ──────────────────────────────────────── */ +function forum_stats(): array { + return [ + 'topics' => (int) DB::val('SELECT COUNT(*) FROM topics WHERE archived=0'), + 'posts' => (int) DB::val('SELECT COUNT(*) FROM posts WHERE deleted=0'), + 'users' => (int) DB::val('SELECT COUNT(*) FROM users'), + 'online' => (int) DB::val("SELECT COUNT(*) FROM users WHERE last_seen >= ".DB::sinceSeconds(900)), + 'newest' => DB::row('SELECT username FROM users ORDER BY joined_at DESC LIMIT 1'), + ]; +} + +/* ── Error pages ───────────────────────────────────────────── */ +function render_403(): never { + http_response_code(403); + echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>403</title>' + .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>' + .'<body class="auth-body"><div class="err-pg"><div class="err-code">403</div>' + .'<h1>Access Denied</h1><p>You do not have permission to view this page.</p>' + .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>'; + exit; +} +function render_404(): never { + http_response_code(404); + echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>404</title>' + .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>' + .'<body class="auth-body"><div class="err-pg"><div class="err-code">404</div>' + .'<h1>Not Found</h1><p>The page you are looking for does not exist.</p>' + .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>'; + exit; +} + +/* ── POST/GET helpers ──────────────────────────────────────── */ +function post(string $k, string $d = ''): string { return trim($_POST[$k] ?? $d); } +function get(string $k, string $d = ''): string { return trim($_GET[$k] ?? $d); } + +/* ── JSON response ─────────────────────────────────────────── */ +function json_out(mixed $data, int $code = 200): never { + http_response_code($code); + header('Content-Type: application/json'); + echo json_encode($data); + exit; +} + +/* ── Security: Input sanitisation ──────────────────────────── + * ALL user content MUST pass through sanitise() before DB insert. + * Strips HTML, PHP, script tags, and HTML entities. + */ +function sanitise(string $input): string { + // ── 1. Kill PHP execution tags completely ──────────────────────── + $s = preg_replace('/<\?(?:php|=)?.*?\?>/si', '', $input); + + // ── 2. Multi-round decode to catch all encoding tricks ─────────── + // javascript:, <script>, \u003cscript\u003e etc. + for ($i = 0; $i < 4; $i++) { + $prev = $s; + $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + // Resolve JS-style unicode escapes: \u003c → < + $s = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($m) { + return mb_chr(hexdec($m[1]), 'UTF-8') ?? $m[0]; + }, $s); + if ($s === $prev) break; + } + + // ── 3. Strip execution-dangerous HTML completely (with content) ── + // Everything inside these tags is removed, not just the tags. + $exec = 'script|style|iframe|frame|frameset|object|embed|applet|form'; + $s = preg_replace('/<(' . $exec . ')\b[^>]*>.*?<\/\1>/si', '', $s); + $s = preg_replace('/<(' . $exec . ')\b[^>]*\/?>/si', '', $s); + + // Also strip PHP/server-side tags that survived step 1 + $s = preg_replace('/<\?.*?\?>/s', '', $s); + + // ── 4. NOW escape all remaining < > & so they display as text ──── + // Safe HTML tags like <b>, <i>, <p>, custom tags from users: + // they are NOT executed — they show as literal <b> etc. + // This is the key change: ESCAPE instead of STRIP harmless tags. + $s = htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + // ── 5. Remove dangerous URL schemes (defence in depth) ─────────── + $s = preg_replace('/\b(javascript|vbscript|livescript|mocha)\s*:/i', '[blocked]:', $s); + + // ── 6. Remove ASCII control characters ─────────────────────────── + // Keep tab (\x09) and newlines. Remove everything else < \x20. + $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s); + + // ── 7. Normalise line endings ───────────────────────────────────── + $s = str_replace("\r\n", "\n", $s); + $s = str_replace("\r", "\n", $s); + + return trim($s); +} + +/* ── Math captcha ─────────────────────────────────────────── */ +function captcha_generate(): array { + start_session(); + $a = random_int(2, 9); + $b = random_int(1, 9); + $op = ['+', '-'][random_int(0, 1)]; + // Ensure subtraction result is always positive and non-zero + if ($op === '-') { + if ($a <= $b) $b = $a - 1; + if ($b < 1) { $op = '+'; } + } + $ans = $op === '+' ? $a + $b : $a - $b; + $_SESSION['captcha_ans'] = $ans; + return ['q' => "$a $op $b = ?"]; +} +function captcha_verify(string $input): bool { + start_session(); + $ans = $_SESSION['captcha_ans'] ?? null; + unset($_SESSION['captcha_ans']); + return $ans !== null && intval(trim($input)) === (int)$ans; +} + +/* ── Friend helpers ───────────────────────────────────────── */ +function friend_status(int $userId, int $otherId): string { + if ($userId === $otherId) return 'self'; + try { + $r = DB::row( + 'SELECT status, user_id FROM friends + WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)', + [$userId, $otherId, $otherId, $userId] + ); + if (!$r) return 'none'; + if ($r['status'] === 'accepted') return 'friends'; + if ($r['status'] === 'pending') + return ((int)$r['user_id'] === $userId) ? 'pending_sent' : 'pending_received'; + } catch (Throwable $e) { + return 'none'; // Table may not exist on old installs + } + return 'none'; +} + +function friends_list(int $userId): array { + try { + return DB::rows(" + SELECT u.* FROM users u + JOIN friends f ON (f.user_id=? AND f.friend_id=u.id) + OR (f.friend_id=? AND f.user_id=u.id) + WHERE f.status='accepted' + ORDER BY u.username + ", [$userId, $userId]); + } catch (Throwable $e) { + return []; // Table may not exist on old installs + } +} + +function pending_requests(int $userId): array { + try { + return DB::rows(" + SELECT u.*, f.id AS fid FROM users u + JOIN friends f ON f.user_id=u.id AND f.friend_id=? AND f.status='pending' + ORDER BY f.created_at DESC + ", [$userId]); + } catch (Throwable $e) { + return []; // Table may not exist on old installs + } +} + +/* ── Message unread count ─────────────────────────────────── */ +function unread_messages(): int { + global $USER; + if (!$USER) return 0; + try { + return (int) DB::val( + 'SELECT COUNT(*) FROM messages + WHERE receiver_id=? AND is_read=0 AND deleted_by_receiver=0', + [$USER['id']] + ); + } catch (Throwable $e) { + return 0; // Table may not exist on old installs + } +} + +/* ── @mention processor ───────────────────────────────────── */ +function process_mentions(string $content, int $postId, string $topicSlug, int $authorId, string $authorName): void { + preg_match_all('/@([a-zA-Z0-9_\-]{3,30})/', $content, $m); + $mentioned = array_unique($m[1] ?? []); + foreach ($mentioned as $uname) { + $t = DB::row('SELECT id FROM users WHERE username=?', [$uname]); + if ($t && (int)$t['id'] !== $authorId) { + add_notification((int)$t['id'], 'mention', [ + 'from' => $authorName, + 'topicSlug' => $topicSlug, + 'postId' => $postId, + ]); + } + } +} + +/* ── Rate limiting ─────────────────────────────────────────── */ +/** + * Check if a user has exceeded their rate limit. + * Returns true = allowed, false = rate-limited. + * $limit = max posts allowed, $window = seconds window, $type = event type + */ +function rate_check(int $userId, string $type = 'post'): array { + $enabled = cfg('rate_limit_enabled','0'); + if ($enabled !== '1') return ['ok'=>true,'wait'=>0]; + + // Admins/moderators bypass rate limits + $user = DB::row('SELECT role FROM users WHERE id=?', [$userId]); + if ($user && in_array($user['role'],['admin','moderator'])) return ['ok'=>true,'wait'=>0]; + + $limit = (int) cfg('rate_limit_count','3'); + $window = (int) cfg('rate_limit_window','60'); // seconds + + $since = DB::sinceSeconds($window); + $count = (int) DB::val( + "SELECT COUNT(*) FROM rate_events + WHERE user_id=? AND event_type=? AND created_at >= $since", + [$userId, $type] + ); + + if ($count >= $limit) { + // Find oldest event in window to calculate wait time + $oldest = DB::row( + "SELECT created_at FROM rate_events + WHERE user_id=? AND event_type=? AND created_at >= $since + ORDER BY created_at ASC LIMIT 1", + [$userId, $type] + ); + $wait = 0; + if ($oldest) { + $elapsed = time() - strtotime($oldest['created_at']); + $wait = max(0, $window - $elapsed); + } + return ['ok'=>false,'wait'=>$wait,'limit'=>$limit,'window'=>$window]; + } + return ['ok'=>true,'wait'=>0]; +} + +function rate_record(int $userId, string $type = 'post'): void { + $enabled = cfg('rate_limit_enabled','0'); + if ($enabled !== '1') return; + DB::insert('INSERT INTO rate_events (user_id,event_type) VALUES (?,?)', [$userId, $type]); + // Prune old events (older than 24h) periodically + if (random_int(1,50) === 1) { + $cutoff = DB::sinceSeconds(86400); + DB::run("DELETE FROM rate_events WHERE created_at < $cutoff"); + } +} + +/* ── Post/Reply captcha ────────────────────────────────────── */ +function post_captcha_enabled(): bool { + return cfg('post_captcha_enabled','0') === '1'; +} +function post_captcha_verify(string $input): bool { + start_session(); + $key = 'post_captcha_ans'; + $ans = $_SESSION[$key] ?? null; + unset($_SESSION[$key]); + return $ans !== null && intval(trim($input)) === (int)$ans; +} +function post_captcha_generate(): array { + start_session(); + $a = random_int(2, 9); + $b = random_int(1, 9); + $op = ['+', '-'][random_int(0, 1)]; + if ($op === '-') { + if ($a <= $b) $b = $a - 1; + if ($b < 1) { $op = '+'; } + } + $ans = $op === '+' ? $a + $b : $a - $b; + $_SESSION['post_captcha_ans'] = $ans; + return ['q' => "$a $op $b = ?"]; +} + +/* ── Media embed processor ───────────────────────────────────── + * Converts bare URLs in post content into embeds. + * Called server-side so embeds render even without JS. + * Each URL on its own line (possibly wrapped in <p>) gets replaced. + */ +function process_embeds(string $html): string { + // Process each <p>...</p> block that contains a bare URL. + // We do this line by line to avoid catastrophic regex failures. + return preg_replace_callback( + '/<p>\s*(https?:\/\/[^\s<>"\']+)\s*<\/p>/i', + function ($m) { + $url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'); + $em = try_embed($url); + return $em !== null ? $em : $m[0]; + }, + $html + ); +} + +function try_embed(string $url): ?string { + // YouTube (full URL) + if (preg_match('~youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) { + return yt_embed($m[1]); + } + // YouTube short URL + if (preg_match('~youtu\.be/([a-zA-Z0-9_\-]{11})~', $url, $m)) { + return yt_embed($m[1]); + } + // YouTube Shorts + if (preg_match('~youtube\.com/shorts/([a-zA-Z0-9_\-]{11})~', $url, $m)) { + return yt_embed($m[1], true); + } + // YouTube Music + if (preg_match('~music\.youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) { + return yt_embed($m[1]); + } + // Vimeo + if (preg_match('~vimeo\.com/(\d{5,12})~', $url, $m)) { + return embed_iframe('https://player.vimeo.com/video/' . $m[1] . '?dnt=1', 'Vimeo'); + } + // Twitch VOD + if (preg_match('~twitch\.tv/videos/(\d+)~', $url, $m)) { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + return embed_iframe('https://player.twitch.tv/?video=v' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch VOD'); + } + // Twitch channel + if (preg_match('~twitch\.tv/([a-zA-Z0-9_]{4,25})(?:\?|$|/)~', $url, $m)) { + $host = $_SERVER['HTTP_HOST'] ?? 'localhost'; + return embed_iframe('https://player.twitch.tv/?channel=' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch'); + } + // Dailymotion + if (preg_match('~dailymotion\.com/video/([a-zA-Z0-9]+)~', $url, $m)) { + return embed_iframe('https://www.dailymotion.com/embed/video/' . $m[1], 'Dailymotion'); + } + // Streamable + if (preg_match('~streamable\.com/([a-zA-Z0-9]+)~', $url, $m)) { + return embed_iframe('https://streamable.com/e/' . $m[1], 'Streamable'); + } + // Rumble + if (preg_match('~rumble\.com/(?:embed/)?([a-zA-Z0-9\-]+)(?:\.html)?~', $url, $m)) { + return embed_iframe('https://rumble.com/embed/' . $m[1] . '/', 'Rumble'); + } + // Spotify + if (preg_match('~open\.spotify\.com/(track|album|playlist|episode|artist)/([a-zA-Z0-9]+)~', $url, $m)) { + $h = ($m[1] === 'track' || $m[1] === 'episode') ? '152' : '352'; + return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/' . $m[1] . '/' . $m[2] + . '" width="100%" height="' . $h . '" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>'; + } + // SoundCloud + if (preg_match('~soundcloud\.com/[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+~', $url)) { + return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay"' + . ' src="https://w.soundcloud.com/player/?url=' . urlencode($url) . '&color=%233b82f6&auto_play=false"></iframe></div>'; + } + // Loom + if (preg_match('~loom\.com/share/([a-zA-Z0-9]+)~', $url, $m)) { + return embed_iframe('https://www.loom.com/embed/' . $m[1], 'Loom'); + } + // CodePen + if (preg_match('~codepen\.io/([a-zA-Z0-9\-_]+)/pen/([a-zA-Z0-9]+)~', $url, $m)) { + return embed_iframe_tall('https://codepen.io/' . $m[1] . '/embed/' . $m[2] . '?default-tab=result', 'CodePen', 420); + } + // JSFiddle + if (preg_match('~jsfiddle\.net/([a-zA-Z0-9/]+)~', $url, $m)) { + return embed_iframe_tall('https://jsfiddle.net/' . rtrim($m[1], '/') . '/embedded/result', 'JSFiddle', 380); + } + // Twitter / X + if (preg_match('~(?:twitter|x)\.com/[a-zA-Z0-9_]+/status/(\d+)~', $url, $m)) { + $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8'); + return '<div class="embed-tweet" data-tweet-id="' . $m[1] . '">' + . '<a href="' . $safe . '" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>'; + } + // TED Talks + if (preg_match('~ted\.com/talks/([a-zA-Z0-9_]+)~', $url, $m)) { + return embed_iframe('https://embed.ted.com/talks/' . $m[1], 'TED Talk'); + } + // Bandcamp track + if (preg_match('~([a-zA-Z0-9\-]+)\.bandcamp\.com/track/([a-zA-Z0-9\-]+)~', $url, $m)) { + return '<div class="embed-spotify"><iframe style="border:0;width:100%;height:120px"' + . ' src="https://bandcamp.com/EmbeddedPlayer/track=' . urlencode($m[2]) . '/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/" seamless></iframe></div>'; + } + // No match + return null; +} + + +function yt_embed(string $id, bool $short = false): string { + $pad = $short ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%'; + return '<div class="embed-wrap" style="'.$pad.'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'.htmlspecialchars($id).'?rel=0&modestbranding=1" allowfullscreen loading="lazy" title="YouTube video"></iframe></div>'; +} +function embed_iframe(string $src, string $label = ''): string { + return '<div class="embed-wrap"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>'; +} +function embed_iframe_tall(string $src, string $label = '', int $height = 400): string { + return '<div class="embed-wrap" style="padding-bottom:0;height:'.$height.'px"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>'; +} + +/* ── Karma tier system ─────────────────────────────────────── */ +/* ── Category permission helpers ──────────────────────── */ +// Roles in ascending order: guest < member < moderator < admin +function role_level(string $role): int { + return match($role) { + 'admin' => 30, + 'moderator' => 20, + 'member' => 10, + default => 0, // guest / not logged in + }; +} + +function user_role_level(?array $u = null): int { + global $USER; + $u = $u ?? $USER; + if (!$u) return 0; + return role_level($u['role'] ?? 'member'); +} + +function can_read_category(array $cat, ?array $u = null): bool { + $required = role_level($cat['read_role'] ?? 'guest'); + return user_role_level($u) >= $required; +} + +function can_post_topic(array $cat, ?array $u = null): bool { + $required = role_level($cat['post_role'] ?? 'member'); + return user_role_level($u) >= $required; +} + +function can_reply_topic(array $cat, ?array $u = null): bool { + $required = role_level($cat['reply_role'] ?? 'member'); + return user_role_level($u) >= $required; +} + +function karma_tier(int $karma): array { + $tiers = [ + ['name'=>'Newcomer', 'min'=>0, 'max'=>9, 'icon'=>'🌱', 'color'=>'#94a3b8', 'next'=>10], + ['name'=>'Member', 'min'=>10, 'max'=>49, 'icon'=>'💬', 'color'=>'#64748b', 'next'=>50], + ['name'=>'Regular', 'min'=>50, 'max'=>99, 'icon'=>'⭐', 'color'=>'#f59e0b', 'next'=>100], + ['name'=>'Contributor', 'min'=>100, 'max'=>249, 'icon'=>'🌟', 'color'=>'#f97316', 'next'=>250], + ['name'=>'Veteran', 'min'=>250, 'max'=>499, 'icon'=>'🔥', 'color'=>'#ef4444', 'next'=>500], + ['name'=>'Expert', 'min'=>500, 'max'=>999, 'icon'=>'💎', 'color'=>'#8b5cf6', 'next'=>1000], + ['name'=>'Elite', 'min'=>1000, 'max'=>2499, 'icon'=>'👑', 'color'=>'#7c3aed', 'next'=>2500], + ['name'=>'Legend', 'min'=>2500, 'max'=>PHP_INT_MAX, 'icon'=>'🏆', 'color'=>'#6d28d9', 'next'=>null], + ]; + $current = $tiers[0]; + foreach ($tiers as $tier) { + if ($karma >= $tier['min']) $current = $tier; + else break; + } + // Progress to next tier + $progress = 0; + if ($current['next'] !== null) { + $range = $current['next'] - $current['min']; + $earned = $karma - $current['min']; + $progress = $range > 0 ? min(100, (int)round($earned / $range * 100)) : 100; + } else { + $progress = 100; + } + return array_merge($current, ['karma' => $karma, 'progress' => $progress]); +} diff --git a/includes/markdown.php b/includes/markdown.php new file mode 100644 index 0000000..ff4fed3 --- /dev/null +++ b/includes/markdown.php @@ -0,0 +1,347 @@ +<?php +if (!defined('NEXUS')) exit('Forbidden'); + +/** + * Nexus Forum — Markdown renderer (Discourse/Flarum-compatible) + * + * Input has already been sanitised by sanitise() which: + * - Strips dangerous tags (script, iframe, etc.) + * - htmlspecialchars() remaining content (< > & become entities) + * + * Supported syntax: + * ```lang … ``` fenced code block with syntax highlighting + * ``` … ``` fenced code block, no language + * `inline` inline code span + * > text blockquote (one or more lines) + * **bold** bold + * *italic* italic + * ~~strike~~ strikethrough + * # – ###### headings + * - item / * item bullet list + * 1. item ordered list + * [text](url) link + *  image + * @username mention + * --- horizontal rule + * https://… auto-embed videos / auto-link + */ + +// ───────────────────────────────────────────────────────────────── +// Placeholder markers (STX/ETX can't appear in user content) +// ───────────────────────────────────────────────────────────────── +define('_MK_FP', "\x02FENCE"); +define('_MK_FS', "FNCE\x03"); +define('_MK_IP', "\x02INLIN"); +define('_MK_IS', "INLN\x03"); +define('_MK_BQ', "\x02BQUOT"); +define('_MK_BS', "BQUT\x03"); + +function render_post(string $raw): string +{ + // ── Normalise ──────────────────────────────────────────────── + $s = str_replace(["\r\n", "\r"], "\n", $raw); + + $fences = []; + $quotes = []; + $inlines = []; + + // ── Safe URL check ─────────────────────────────────────────── + $safeUrl = static fn(string $u): bool => + str_starts_with(strtolower(trim($u)), '/') || + (bool) preg_match('#^https?://#i', trim($u)); + + // ── Copy button SVG ────────────────────────────────────────── + $copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" ' + . 'title="Copy" aria-label="Copy code">' + . '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + . 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">' + . '<rect x="9" y="9" width="13" height="13" rx="2"/>' + . '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' + . '</svg><span>Copy</span></button>'; + + // ════════════════════════════════════════════════════════════ + // PASS 1 — Extract code fences (line-by-line state machine) + // + // Runs BEFORE everything else so code content is never touched + // by any other processor. Handles blank lines, indentation, + // special characters without any regex backtracking. + // ════════════════════════════════════════════════════════════ + { + $in = false; + $lang = ''; + $type = ''; + $buf = []; + $newLines = []; + + foreach (explode("\n", $s) as $line) { + if (!$in) { + // Triple-backtick opener: ```lang or ``` + if (preg_match('/^```([ \t]*\w*)[ \t]*$/', $line, $m)) { + $in = true; $type = 'triple'; $lang = trim($m[1]); $buf = []; + // Lone backtick on its own line + } elseif (preg_match('/^`[ \t]*$/', $line)) { + $in = true; $type = 'single'; $lang = ''; $buf = []; + } else { + $newLines[] = $line; + } + } else { + $close = ($type === 'triple' && preg_match('/^```[ \t]*$/', $line)) + || ($type === 'single' && preg_match('/^`[ \t]*$/', $line)); + if ($close) { + // Build code block HTML + // Content is already entity-encoded by sanitise() — don't double-encode + $code = implode("\n", $buf); + $l = strtolower(trim($lang)); + if ($l !== '') { + $lb = htmlspecialchars($l, ENT_QUOTES, 'UTF-8'); + $hdr = '<div class="cb-header">' + . '<span class="cb-lang">' . $lb . '</span>' . $copyBtn + . '</div>'; + $blk = '<pre class="code-block" data-lang="' . $lb . '">' + . '<code class="language-' . $lb . '">' . $code . '</code></pre>'; + } else { + $hdr = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>'; + $blk = '<pre class="code-block"><code>' . $code . '</code></pre>'; + } + $idx = count($fences); + $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>'; + $newLines[] = _MK_FP . $idx . _MK_FS; + $in = false; $buf = []; + } else { + $buf[] = $line; + } + } + } + // Unclosed fence — render what was collected + if ($in && $buf) { + $code = implode("\n", $buf); + $hdr = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>'; + $blk = '<pre class="code-block"><code>' . $code . '</code></pre>'; + $idx = count($fences); + $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>'; + $newLines[] = _MK_FP . $idx . _MK_FS; + } + $s = implode("\n", $newLines); + } + + // ════════════════════════════════════════════════════════════ + // PASS 2 — Extract blockquotes (line-by-line, same approach) + // + // Consecutive "> " lines form one blockquote block. + // Supports nested content: bold, italic, inline code, links. + // Matches both raw > and entity-encoded > from sanitise(). + // ════════════════════════════════════════════════════════════ + { + $bqBuf = []; + $bqOut = []; + + $flushBq = function () use (&$bqBuf, &$bqOut, &$quotes, $copyBtn): void { + if (!$bqBuf) return; + $inner = implode("\n", $bqBuf); + // Let inline markdown run inside blockquote + $inner = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $inner); + $inner = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $inner); + $inner = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $inner); + $inner = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $inner); + // Wrap each line in <p> if multiple lines, otherwise just the text + $bqLines = array_filter(explode("\n", $inner), fn($l) => trim($l) !== ''); + if (count($bqLines) > 1) { + $inner = implode('', array_map(fn($l) => '<p>' . trim($l) . '</p>', $bqLines)); + } else { + $inner = trim($inner); + } + $idx = count($quotes); + $quotes[] = '<blockquote class="post-quote">' . $inner . '</blockquote>'; + $bqOut[] = _MK_BQ . $idx . _MK_BS; + $bqBuf = []; + }; + + foreach (explode("\n", $s) as $line) { + if (preg_match('/^(?:>|>) ?(.*)$/', $line, $m)) { + $bqBuf[] = $m[1]; // already entity-encoded + } else { + $flushBq(); + $bqOut[] = $line; + } + } + $flushBq(); + $s = implode("\n", $bqOut); + } + + // ════════════════════════════════════════════════════════════ + // PASS 3 — Inline code spans + // ════════════════════════════════════════════════════════════ + $s = preg_replace_callback( + '/`([^`\n]+)`/', + static function (array $m) use (&$inlines): string { + $idx = count($inlines); + // Content already entity-encoded by sanitise() + $inlines[] = '<code class="inline-code">' . $m[1] . '</code>'; + return _MK_IP . $idx . _MK_IS; + }, + $s + ); + + // ════════════════════════════════════════════════════════════ + // PASS 4 — Block-level markdown + // ════════════════════════════════════════════════════════════ + + // Headings + $s = preg_replace('/^#{6} (.+)$/m', '<h6>$1</h6>', $s); + $s = preg_replace('/^#{5} (.+)$/m', '<h5>$1</h5>', $s); + $s = preg_replace('/^#{4} (.+)$/m', '<h4>$1</h4>', $s); + $s = preg_replace('/^#{3} (.+)$/m', '<h3>$1</h3>', $s); + $s = preg_replace('/^#{2} (.+)$/m', '<h2>$1</h2>', $s); + $s = preg_replace('/^# (.+)$/m', '<h1>$1</h1>', $s); + + // Horizontal rules + $s = preg_replace('/^(-{3,}|\*{3,}|_{3,})$/m', '<hr>', $s); + + // Lists — bullet + $s = preg_replace('/^[ \t]*[*\-+] (.+)$/m', '<li>$1</li>', $s); + $s = preg_replace('/((?:<li>.*<\/li>\n?)+)/', '<ul>$1</ul>', $s); + $s = preg_replace('/<\/ul>\s*<ul>/', '', $s); + $s = preg_replace_callback('/<ul>(.*?)<\/ul>/s', + static fn($m) => '<ul>' . str_replace("\n", '', $m[1]) . '</ul>', $s); + + // Lists — ordered + $s = preg_replace('/^[ \t]*\d+\. (.+)$/m', '<oli>$1</oli>',$s); + $s = preg_replace('/((?:<oli>.*<\/oli>\n?)+)/', '<ol>$1</ol>', $s); + $s = preg_replace('/<\/ol>\s*<ol>/', '', $s); + $s = str_replace(['<oli>', '</oli>'], ['<li>', '</li>'], $s); + $s = preg_replace_callback('/<ol>(.*?)<\/ol>/s', + static fn($m) => '<ol>' . str_replace("\n", '', $m[1]) . '</ol>', $s); + + // Tables + $s = preg_replace_callback('/\|(.+)\|\n\|[-| :]+\|\n((?:\|.+\|\n?)+)/', + static function (array $m): string { + $ths = implode('', array_map( + static fn($c) => '<th>' . trim($c) . '</th>', + array_filter(explode('|', $m[1]), static fn($c) => trim($c) !== '') + )); + $trs = implode('', array_map(static function (string $row): string { + $cells = array_filter(explode('|', $row), static fn($c) => trim($c) !== ''); + return '<tr>' . implode('', array_map(static fn($c) => '<td>' . trim($c) . '</td>', $cells)) . '</tr>'; + }, array_filter(explode("\n", trim($m[2]))))); + return '<table><thead><tr>' . $ths . '</tr></thead><tbody>' . $trs . '</tbody></table>'; + }, $s); + + // ════════════════════════════════════════════════════════════ + // PASS 5 — Inline markdown + // ════════════════════════════════════════════════════════════ + + $s = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $s); + $s = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $s); + $s = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $s); + $s = preg_replace('/___(.+?)___/s', '<strong><em>$1</em></strong>', $s); + $s = preg_replace('/__(.+?)__/s', '<strong>$1</strong>', $s); + $s = preg_replace('/_([^_\n]+)_/', '<em>$1</em>', $s); + $s = preg_replace('/~~(.+?)~~/s', '<del>$1</del>', $s); + + // Images + $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)/', + static function (array $m) use ($safeUrl): string { + $src = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); + $alt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'); + if (!$safeUrl($src)) return $m[0]; + return '<img src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8') + . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') + . '" loading="lazy" class="post-img" onclick="lightbox(this)">'; + }, $s); + + // Links + $s = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/', + static function (array $m) use ($safeUrl): string { + $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); + $txt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'); + if (!$safeUrl($url)) return htmlspecialchars($txt, ENT_QUOTES, 'UTF-8'); + return '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') + . '" target="_blank" rel="noopener noreferrer nofollow">' + . htmlspecialchars($txt, ENT_QUOTES, 'UTF-8') . '</a>'; + }, $s); + + // @mentions + $base = BASE; + $s = preg_replace_callback('/@([a-zA-Z0-9_\-]{3,30})/', + static function (array $m) use ($base): string { + $u = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8'); + return '<a href="' . $base . '/users/profile.php?u=' . $u + . '" class="mention-tag">@' . $u . '</a>'; + }, $s); + + // ════════════════════════════════════════════════════════════ + // PASS 6 — URL auto-embed + auto-link + // + // EVERY bare URL (on its own line OR inline) is attempted as an embed. + // Entity-encoded URLs from sanitise() are decoded before matching. + // Multiple video links in one post all embed. + // ════════════════════════════════════════════════════════════ + + // Decode entity-encoded URLs so patterns match (sanitise turns & → &) + // We match, decode, try embed or link, then re-encode for output. + $s = preg_replace_callback( + '#(^|[ \t]|<p>)(https?://[^\s<>"\'&]+(?:&[^\s<>"\'&]*)*)#m', + static function (array $m): string { + $pre = $m[1]; + $rawUrl = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); + $safeEnc = htmlspecialchars($rawUrl, ENT_QUOTES, 'UTF-8'); + + // Try embed first + $embed = try_embed($rawUrl); + if ($embed !== null) { + // Wrap standalone embeds cleanly + return $pre . $embed; + } + + // Otherwise link + return $pre . '<a href="' . $safeEnc . '" target="_blank" ' + . 'rel="noopener noreferrer nofollow">' . $safeEnc . '</a>'; + }, + $s + ); + + // ════════════════════════════════════════════════════════════ + // PASS 7 — Paragraph wrapping + // ════════════════════════════════════════════════════════════ + $lines = explode("\n", $s); + $out = []; + $para = ''; + + $fpQ = preg_quote(_MK_FP, '/'); + $fsQ = preg_quote(_MK_FS, '/'); + $bpQ = preg_quote(_MK_BQ, '/'); + $bsQ = preg_quote(_MK_BS, '/'); + + $blockRe = '/^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|' + . $fpQ . '\d+' . $fsQ . '|' + . $bpQ . '\d+' . $bsQ . ')/'; + + $flush = static function () use (&$para, &$out): void { + $t = trim($para); + if ($t !== '') $out[] = '<p>' . $t . '</p>'; + $para = ''; + }; + + foreach ($lines as $line) { + $t = trim($line); + if ($t === '') { + $flush(); + } elseif (preg_match($blockRe, $t)) { + $flush(); + $out[] = $line; + } else { + $para .= ($para !== '' ? ' ' : '') . $line; + } + } + $flush(); + $s = implode("\n", $out); + + // ════════════════════════════════════════════════════════════ + // PASS 8 — Restore all placeholders + // ════════════════════════════════════════════════════════════ + foreach ($fences as $i => $html) $s = str_replace(_MK_FP . $i . _MK_FS, $html, $s); + foreach ($quotes as $i => $html) $s = str_replace(_MK_BQ . $i . _MK_BS, $html, $s); + foreach ($inlines as $i => $html) $s = str_replace(_MK_IP . $i . _MK_IS, $html, $s); + + return process_embeds($s); +} diff --git a/index.php b/index.php new file mode 100644 index 0000000..c47acc2 --- /dev/null +++ b/index.php @@ -0,0 +1,107 @@ +<?php +require_once __DIR__ . '/includes/bootstrap.php'; + +$PAGE_TITLE = cfg('site_name', 'Nexus Forum'); + +$cats = DB::rows(" + SELECT c.*, + (SELECT t.title FROM topics t WHERE t.category_id=c.id ORDER BY t.last_post_at DESC LIMIT 1) AS last_title, + (SELECT t.slug FROM topics t WHERE t.category_id=c.id ORDER BY t.last_post_at DESC LIMIT 1) AS last_slug + FROM categories c WHERE c.parent_id IS NULL ORDER BY c.position, c.id +"); + +$recent = DB::rows(" + SELECT t.*, u.username, u.avatar, c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color + FROM topics t + JOIN users u ON u.id = t.user_id + JOIN categories c ON c.id = t.category_id + WHERE t.archived = 0 + ORDER BY t.last_post_at DESC LIMIT 15 +"); + +$stats = [ + 'topics' => (int) DB::val('SELECT COUNT(*) FROM topics'), + 'posts' => (int) DB::val('SELECT COUNT(*) FROM posts WHERE deleted=0'), + 'users' => (int) DB::val('SELECT COUNT(*) FROM users'), + 'newest' => DB::row('SELECT username FROM users ORDER BY joined_at DESC LIMIT 1'), +]; + +include __DIR__ . '/views/partials/layout.php'; +?> + +<div class="home-hero"> + <h1><?= e(cfg('site_name','Nexus Forum')) ?></h1> + <p><?= e(cfg('site_desc','A community for discussion.')) ?></p> + <?php if (!$USER): ?> + <div class="hero-btns"> + <a href="<?= u('auth/register.php') ?>" class="btn-primary btn-lg">Join the Community</a> + <a href="<?= u('auth/login.php') ?>" class="btn-ghost btn-lg">Log In</a> + </div> + <?php else: ?> + <a href="<?= u('forum/new-topic.php') ?>" class="btn-primary btn-lg">+ New Topic</a> + <?php endif; ?> +</div> + +<div class="stats-row"> + <div class="stat"><strong><?= number_format($stats['topics']) ?></strong><span>Topics</span></div> + <div class="stat-div"></div> + <div class="stat"><strong><?= number_format($stats['posts']) ?></strong><span>Posts</span></div> + <div class="stat-div"></div> + <div class="stat"><strong><?= number_format($stats['users']) ?></strong><span>Members</span></div> + <?php if ($stats['newest']): ?> + <div class="stat-div"></div> + <div class="stat"><span>Newest: <a href="<?= u('users/profile.php?u=' . urlencode($stats['newest']['username'])) ?>">@<?= e($stats['newest']['username']) ?></a></span></div> + <?php endif; ?> +</div> + +<div class="home-grid"> + <section> + <h2 class="sec-title">Categories</h2> + <?php foreach ($cats as $c): ?> + <a href="<?= u('forum/category.php?slug=' . urlencode($c['slug'])) ?>" class="cat-card"> + <div class="cat-stripe" style="background:<?= e($c['color']) ?>"></div> + <div class="cat-icon" style="color:<?= e($c['color']) ?>"><?= e($c['icon']) ?></div> + <div class="cat-body"> + <div class="cat-name"><?= e($c['name']) ?></div> + <div class="cat-desc"><?= e($c['description']) ?></div> + <div class="cat-stats"><?= $c['topic_count'] ?> topics · <?= $c['post_count'] ?> posts</div> + </div> + <div class="cat-chevron">›</div> + </a> + <?php endforeach; ?> + <?php if (empty($cats)): ?> + <p class="empty-msg">No categories yet.</p> + <?php endif; ?> + </section> + + <section> + <h2 class="sec-title">Latest Activity</h2> + <?php foreach ($recent as $t): ?> + <div class="topic-row"> + <?php if ($t['avatar']): ?> + <img src="<?= e($t['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($t['username'][0]) ?></span> + <?php endif; ?> + <div class="tr-body"> + <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>" class="tr-title"> + <?= e($t['title']) ?> + </a> + <div class="tr-meta"> + <a href="<?= u('forum/category.php?slug=' . urlencode($t['cat_slug'])) ?>" class="cat-tag" style="--cc:<?= e($t['cat_color']) ?>"><?= e($t['cat_name']) ?></a> + by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a> + <span class="ago" data-ts="<?= e($t['last_post_at']) ?>"></span> + </div> + </div> + <div class="tr-counts"> + <span>💬 <?= $t['reply_count'] ?></span> + </div> + </div> + <?php endforeach; ?> + <?php if (empty($recent)): ?> + <p class="empty-msg">No topics yet. <a href="<?= u('forum/new-topic.php') ?>">Start one!</a></p> + <?php endif; ?> + </section> +</div> + +<?php include __DIR__ . '/views/partials/layout_end.php'; ?> diff --git a/install/index.php b/install/index.php new file mode 100644 index 0000000..052e48a --- /dev/null +++ b/install/index.php @@ -0,0 +1,632 @@ +<?php +/** + * Nexus Forum — Web Installer + * Supports: SQLite3, MySQL, MariaDB + */ + +/* ── Bootstrap (standalone — does not use bootstrap.php) ─── */ +define('NEXUS', true); +define('ROOT', dirname(__DIR__)); +define('DATA', ROOT . '/data'); +define('UPLOADS', ROOT . '/public/uploads'); + +// Detect web root offset (so the forum works at /forum/, /app/forum/, etc.) +$_dr = rtrim(str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT'] ?? ''), '/'); +$_rp = str_replace('\\', '/', ROOT); +$_b = str_replace($_dr, '', $_rp); +$_b = '/' . trim($_b, '/'); +define('BASE', $_b === '/' ? '' : $_b); +unset($_dr, $_rp, $_b); + +// Already installed → redirect +if (file_exists(DATA . '/installed.lock')) { + header('Location: ' . BASE . '/'); + exit; +} + +// Load DB class only (no bootstrap, no session) +require_once ROOT . '/includes/db.php'; + +/* ── State ──────────────────────────────────────────────── */ +$step = (int)($_GET['step'] ?? 1); +$errs = []; +$info = []; +$selDb = $_POST['db_type'] ?? 'sqlite'; + +// Check which PDO drivers are available +$hasSQLite = extension_loaded('pdo_sqlite') || in_array('sqlite', PDO::getAvailableDrivers()); +$hasMySQL = extension_loaded('pdo_mysql') || in_array('mysql', PDO::getAvailableDrivers()); + +/* ── Requirements ───────────────────────────────────────── */ +$reqs = [ + 'PHP 8.0+' => version_compare(PHP_VERSION, '8.0', '>='), + 'PDO extension' => extension_loaded('PDO'), + 'Cryptographic random' => function_exists('random_int'), + 'JSON support' => function_exists('json_encode'), + 'SQLite3 or MySQL driver' => $hasSQLite || $hasMySQL, + 'data/ directory writable'=> !is_dir(DATA) ? is_writable(ROOT) : is_writable(DATA), +]; +$allOk = !in_array(false, $reqs); + +/* ── Step 2: process form ───────────────────────────────── */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install_submit'])) { + + // Re-read db choice from POST + $selDb = $_POST['db_type'] ?? 'sqlite'; + + // Validate common fields + $siteName = trim($_POST['site_name'] ?? ''); + $siteDesc = trim($_POST['site_desc'] ?? ''); + $adminUser = trim($_POST['admin_user'] ?? ''); + $adminEmail = trim($_POST['admin_email'] ?? ''); + $adminPass = $_POST['admin_pass'] ?? ''; + $adminPass2 = $_POST['admin_pass2'] ?? ''; + + if (!$siteName) $errs[] = 'Site name is required.'; + if (!preg_match('/^[a-zA-Z0-9_\-]{3,30}$/', $adminUser)) $errs[] = 'Admin username must be 3–30 alphanumeric characters (letters, numbers, _ -)'; + if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) $errs[] = 'Admin email is not valid.'; + if (strlen($adminPass) < 8) $errs[] = 'Admin password must be at least 8 characters.'; + if ($adminPass !== $adminPass2) $errs[] = 'Passwords do not match.'; + + // Validate DB-specific fields + $dbHost = trim($_POST['db_host'] ?? '127.0.0.1'); + $dbPort = max(1, min(65535, (int)($_POST['db_port'] ?? 3306))); + $dbName = trim($_POST['db_name'] ?? ''); + $dbUser = trim($_POST['db_user'] ?? ''); + $dbPass = $_POST['db_pass'] ?? ''; + + if ($selDb === 'mysql') { + if (!$hasMySQL) $errs[] = 'PDO MySQL driver is not available on this server.'; + if (!$dbName) $errs[] = 'Database name is required for MySQL.'; + if (!$dbUser) $errs[] = 'Database username is required for MySQL.'; + + // Test connection before proceeding + if (!$errs) { + try { + $testPdo = new PDO( + "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", + $dbUser, $dbPass, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5] + ); + unset($testPdo); + } catch (PDOException $e) { + $errs[] = 'Could not connect to MySQL: ' . htmlspecialchars($e->getMessage()); + } + } + } else { + if (!$hasSQLite) $errs[] = 'PDO SQLite driver is not available on this server. Please choose MySQL.'; + } + + // All good — run installation + if (!$errs) { + try { + /* 1. Write db_config.php */ + $cfgPath = ROOT . '/includes/db_config.php'; + if ($selDb === 'mysql') { + $cfg = "<?php\n" + . "if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); }\n" + . "define('DB_DRIVER', 'mysql');\n" + . "define('DB_HOST', " . var_export($dbHost, true) . ");\n" + . "define('DB_PORT', " . var_export((string)$dbPort, true) . ");\n" + . "define('DB_NAME', " . var_export($dbName, true) . ");\n" + . "define('DB_USER', " . var_export($dbUser, true) . ");\n" + . "define('DB_PASS', " . var_export($dbPass, true) . ");\n" + . "define('DB_CHARSET', 'utf8mb4');\n"; + } else { + $cfg = "<?php\n" + . "if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); }\n" + . "define('DB_DRIVER', 'sqlite');\n"; + } + file_put_contents($cfgPath, $cfg); + @chmod($cfgPath, 0640); + + /* 2. Build schema */ + DB::init(); + $db = DB::connect(); + + /* 3. Insert settings */ + $upsert = DB::isMysql() + ? 'INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)' + : 'INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)'; + $si = $db->prepare($upsert); + $settingsToInsert = [ + 'site_name' => $siteName, + 'site_desc' => $siteDesc ?: 'A community forum', + 'allow_reg' => '1', + 'topics_per_page' => '30', + 'posts_per_page' => '20', + 'rate_limit_enabled' => '0', + 'rate_limit_count' => '3', + 'rate_limit_window' => '60', + 'post_captcha_enabled' => '0', + 'topic_captcha_enabled' => '0', + ]; + foreach ($settingsToInsert as $k => $v) { + $si->execute([$k, $v]); + } + + /* 4. Create admin user */ + $adminId = DB::insert( + 'INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)', + [$adminUser, $adminEmail, password_hash($adminPass, PASSWORD_BCRYPT, ['cost' => 12]), 'admin'] + ); + + /* 5. Create default categories */ + $catStmt = $db->prepare('INSERT INTO categories (name, slug, description, color, icon, position) VALUES (?, ?, ?, ?, ?, ?)'); + $defaultCats = [ + ['Announcements', 'announcements', 'Important updates from the team', '#ef4444', '📢', 1], + ['General', 'general', 'Talk about anything', '#3b82f6', '💬', 2], + ['Support', 'support', 'Get help from the community', '#10b981', '🛟', 3], + ['Ideas', 'ideas', 'Share your suggestions', '#8b5cf6', '💡', 4], + ['Showcase', 'showcase', 'Show off your projects', '#f59e0b', '🌟', 5], + ]; + foreach ($defaultCats as $cat) { + $catStmt->execute($cat); + } + + /* 6. Create welcome topic in General */ + $generalId = (int) DB::val("SELECT id FROM categories WHERE slug = 'general'"); + if ($generalId) { + $welcomeBody = "# Welcome to {$siteName}!\n\n" + . "This is your new community forum. Here's how to get started:\n\n" + . "## Quick Start\n\n" + . "- Browse the categories in the left sidebar\n" + . "- Click **+ New Topic** to start a discussion\n" + . "- Type **@username** in a post to mention someone\n" + . "- Paste a YouTube, Vimeo, or Spotify URL on its own line to auto-embed it\n" + . "- Like posts to give ⭐ Karma to helpful members\n\n" + . "Enjoy the community! 👋"; + + $nowExpr = DB::isMysql() ? 'NOW()' : "datetime('now')"; + $topicId = DB::insert( + "INSERT INTO topics (title, slug, category_id, user_id, pinned, last_post_at) + VALUES (?, ?, ?, ?, 1, {$nowExpr})", + ["Welcome to {$siteName}!", 'welcome', $generalId, $adminId] + ); + DB::insert( + 'INSERT INTO posts (topic_id, user_id, content, post_num) VALUES (?, ?, ?, 1)', + [$topicId, $adminId, $welcomeBody] + ); + DB::run('UPDATE categories SET topic_count = 1, post_count = 1 WHERE id = ?', [$generalId]); + } + + /* 7. Create directories */ + foreach ([DATA, UPLOADS, UPLOADS . '/avatars'] as $dir) { + if (!is_dir($dir)) { + mkdir($dir, 0750, true); + } + } + + /* 8. Security hardening */ + // Block web access to data/ + file_put_contents(DATA . '/.htaccess', "Order Deny,Allow\nDeny from all\n"); + + // Block PHP execution in uploads/ + file_put_contents(UPLOADS . '/.htaccess', + "Options -Indexes -ExecCGI\n" + . "<FilesMatch \"\\.(?i:php|phtml|php3|php4|php5|php7|phar|cgi|pl|sh|exe)$\">\n" + . " Order Allow,Deny\n" + . " Deny from all\n" + . "</FilesMatch>\n" + ); + + // Secure the SQLite file + if (!DB::isMysql() && file_exists(DATA . '/forum.db')) { + @chmod(DATA . '/forum.db', 0640); + } + + /* 9. Write installed.lock */ + file_put_contents(DATA . '/installed.lock', json_encode([ + 'installed_at' => date('c'), + 'db_driver' => $selDb, + 'php_version' => PHP_VERSION, + ])); + @chmod(DATA . '/installed.lock', 0640); + + /* Done */ + $step = 3; + $info = ['username' => $adminUser, 'db' => $selDb]; + + } catch (Throwable $ex) { + $errs[] = 'Installation error: ' . htmlspecialchars($ex->getMessage()); + // Roll back config file if install failed + if (isset($cfgPath) && file_exists($cfgPath)) { + @unlink($cfgPath); + } + } + } +} + +/* ── HTML helpers ────────────────────────────────────────── */ +function esc(mixed $v): string { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +?><!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Install — Nexus Forum</title> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="<?= BASE ?>/public/css/main.css"> + <style> + body { + background: linear-gradient(135deg, #1e40af 0%, #0f172a 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + font-family: 'Inter', sans-serif; + } + .card { + background: #fff; + border-radius: 16px; + padding: 40px; + max-width: 580px; + width: 100%; + box-shadow: 0 25px 60px rgba(0,0,0,.3); + } + .logo-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 32px; + } + .logo-mark { + width: 46px; + height: 46px; + background: #3b82f6; + color: #fff; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: 700; + flex-shrink: 0; + } + .logo-text { font-size: 20px; font-weight: 700; color: #0f172a; } + .logo-sub { font-size: 13px; color: #64748b; } + + /* Steps */ + .steps { display: flex; margin-bottom: 32px; } + .step { flex: 1; text-align: center; position: relative; } + .step::after { content: ''; position: absolute; top: 14px; left: 50%; width: 100%; height: 2px; background: #e2e8f0; z-index: 0; } + .step:last-child::after { display: none; } + .step-n { + width: 30px; height: 30px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 13px; font-weight: 700; margin: 0 auto 6px; + position: relative; z-index: 1; + } + .step.done .step-n { background: #22c55e; color: #fff; } + .step.active .step-n { background: #3b82f6; color: #fff; box-shadow: 0 0 0 4px #dbeafe; } + .step.todo .step-n { background: #e2e8f0; color: #94a3b8; } + .step-label { font-size: 11px; font-weight: 600; color: #94a3b8; } + .step.active .step-label { color: #3b82f6; } + .step.done .step-label { color: #22c55e; } + + /* Req table */ + .req-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; } + .req-row:last-child { border-bottom: none; } + .ok { color: #22c55e; font-weight: 700; } + .fail { color: #ef4444; font-weight: 700; } + + /* DB switcher */ + .db-switch { display: flex; border: 2px solid #e2e8f0; border-radius: 10px; overflow: hidden; margin-bottom: 18px; } + .db-btn { + flex: 1; padding: 14px 12px; text-align: center; cursor: pointer; + background: #f8fafc; border: none; font-family: inherit; + font-size: 14px; font-weight: 600; color: #64748b; + transition: all .2s; line-height: 1.4; + } + .db-btn:first-child { border-right: 2px solid #e2e8f0; } + .db-btn.active { background: #3b82f6; color: #fff; } + .db-btn .db-sub { font-size: 11px; font-weight: 400; opacity: .75; display: block; margin-top: 2px; } + .db-panel { display: none; } + .db-panel.visible { display: block; } + + /* Notices */ + .notice { border-radius: 8px; padding: 11px 14px; font-size: 13px; margin-bottom: 14px; } + .notice-green { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; } + .notice-yellow { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; } + .notice-blue { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; } + + /* Form sections */ + .fsection { margin-bottom: 22px; } + .fsection legend, .fsection-title { + display: block; font-weight: 700; font-size: 13px; color: #374151; + padding-bottom: 8px; border-bottom: 2px solid #f1f5f9; margin-bottom: 14px; width: 100%; + } + .fg { margin-bottom: 14px; } + .fg label { display: block; font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 5px; } + .fg label small { font-weight: 400; color: #94a3b8; } + .fi { + width: 100%; padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 8px; + font-size: 14px; font-family: inherit; color: #111827; outline: none; transition: border-color .18s; + } + .fi:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); } + .grid-2 { display: grid; grid-template-columns: 1fr 90px; gap: 10px; } + + /* Buttons */ + .btn-install { + display: flex; align-items: center; justify-content: center; gap: 8px; + width: 100%; padding: 13px; background: #3b82f6; color: #fff; border: none; + border-radius: 10px; font-size: 16px; font-weight: 700; cursor: pointer; + font-family: inherit; transition: all .2s; margin-top: 6px; + } + .btn-install:hover { background: #2563eb; transform: translateY(-1px); } + .btn-next { + display: flex; align-items: center; justify-content: center; + width: 100%; padding: 12px; background: #3b82f6; color: #fff; border-radius: 10px; + text-decoration: none; font-size: 15px; font-weight: 600; transition: all .2s; + } + .btn-next:hover { background: #2563eb; text-decoration: none; color: #fff; } + + /* Error alert */ + .alert-err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 16px; font-size: 14px; margin-bottom: 18px; } + + /* Success */ + .success-hero { text-align: center; padding: 10px 0 20px; } + .success-hero .emoji { font-size: 4rem; line-height: 1; margin-bottom: 12px; } + .success-hero h2 { font-size: 1.5rem; font-weight: 800; color: #0f172a; margin-bottom: 6px; } + .success-hero p { color: #64748b; font-size: 15px; } + .info-table { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0; margin-bottom: 20px; overflow: hidden; } + .info-row { display: flex; justify-content: space-between; align-items: center; padding: 11px 16px; border-bottom: 1px solid #f1f5f9; font-size: 14px; } + .info-row:last-child { border-bottom: none; } + .info-row .label { font-weight: 600; color: #374151; } + .info-row code { background: #e2e8f0; padding: 2px 8px; border-radius: 5px; font-size: 13px; } + .security-list { list-style: none; padding: 0; margin: 8px 0 0; } + .security-list li { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; margin-bottom: 6px; line-height: 1.4; } + </style> +</head> +<body> +<div class="card"> + + <!-- Logo --> + <div class="logo-row"> + <div class="logo-mark">N</div> + <div> + <div class="logo-text">Nexus Forum</div> + <div class="logo-sub">Installation Wizard</div> + </div> + </div> + + <!-- Steps indicator --> + <div class="steps"> + <?php + $stepDefs = ['Requirements', 'Configure', 'Complete']; + foreach ($stepDefs as $i => $label): + $n = $i + 1; + $cls = $step > $n ? 'done' : ($step === $n ? 'active' : 'todo'); + ?> + <div class="step <?= $cls ?>"> + <div class="step-n"><?= $step > $n ? '✓' : $n ?></div> + <div class="step-label"><?= $label ?></div> + </div> + <?php endforeach; ?> + </div> + + <?php if ($step === 1): /* ─── STEP 1: Requirements ─── */ ?> + + <h2 style="font-size:1.25rem;font-weight:700;margin-bottom:6px">System Check</h2> + <p style="color:#64748b;font-size:14px;margin-bottom:20px">Checking your server before we begin.</p> + + <?php foreach ($reqs as $label => $ok): ?> + <div class="req-row"> + <span><?= esc($label) ?></span> + <span class="<?= $ok ? 'ok' : 'fail' ?>"><?= $ok ? '✓ OK' : '✗ FAIL' ?></span> + </div> + <?php endforeach; ?> + + <div style="margin-top:8px;padding:10px 0;border-top:1px solid #f1f5f9"> + <div class="req-row"> + <span>SQLite3 (PDO)</span> + <span class="<?= $hasSQLite ? 'ok' : 'fail' ?>"><?= $hasSQLite ? '✓ Available' : '✗ Not available' ?></span> + </div> + <div class="req-row"> + <span>MySQL / MariaDB (PDO)</span> + <span class="<?= $hasMySQL ? 'ok' : 'fail' ?>"><?= $hasMySQL ? '✓ Available' : '✗ Not available' ?></span> + </div> + </div> + + <div style="margin-top:22px"> + <?php if ($allOk): ?> + <a href="?step=2" class="btn-next">Continue to Configuration →</a> + <?php else: ?> + <div class="alert-err">Please fix the issues above, then reload this page.</div> + <?php endif; ?> + </div> + + <?php elseif ($step === 2): /* ─── STEP 2: Configure ─── */ ?> + + <h2 style="font-size:1.25rem;font-weight:700;margin-bottom:6px">Configure Your Forum</h2> + <p style="color:#64748b;font-size:14px;margin-bottom:22px">Fill in all fields below and click <strong>Install</strong>.</p> + + <?php if ($errs): ?> + <div class="alert-err"> + <?php foreach ($errs as $e): ?> + <div>• <?= esc($e) ?></div> + <?php endforeach; ?> + </div> + <?php endif; ?> + + <form method="POST" action="?step=2"> + <input type="hidden" name="install_submit" value="1"> + <input type="hidden" name="db_type" id="dbTypeHidden" value="<?= esc($selDb) ?>"> + + <!-- Database selection --> + <div class="fsection"> + <span class="fsection-title">1. Database Engine</span> + + <div class="db-switch"> + <button type="button" class="db-btn <?= $selDb !== 'mysql' ? 'active' : '' ?>" + id="btnSQLite" onclick="switchDb('sqlite')"> + 🗃️ SQLite3 + <span class="db-sub">Easiest — no setup needed</span> + </button> + <button type="button" class="db-btn <?= $selDb === 'mysql' ? 'active' : '' ?>" + id="btnMySQL" onclick="switchDb('mysql')"> + 🐬 MySQL / MariaDB + <span class="db-sub">Recommended for production</span> + </button> + </div> + + <!-- SQLite panel --> + <div id="panelSQLite" class="db-panel <?= $selDb !== 'mysql' ? 'visible' : '' ?>"> + <?php if ($hasSQLite): ?> + <div class="notice notice-green"> + ✓ SQLite will be automatically created at <code>data/forum.db</code>. No extra configuration needed. + </div> + <?php else: ?> + <div class="notice notice-yellow"> + ⚠️ PDO SQLite is not available on this server. Please switch to MySQL. + </div> + <?php endif; ?> + </div> + + <!-- MySQL panel --> + <div id="panelMySQL" class="db-panel <?= $selDb === 'mysql' ? 'visible' : '' ?>"> + <?php if ($hasMySQL): ?> + <div class="grid-2"> + <div class="fg"> + <label>Host</label> + <input type="text" name="db_host" class="fi" value="<?= esc($_POST['db_host'] ?? '127.0.0.1') ?>" placeholder="127.0.0.1"> + </div> + <div class="fg"> + <label>Port</label> + <input type="number" name="db_port" class="fi" value="<?= esc($_POST['db_port'] ?? '3306') ?>" min="1" max="65535"> + </div> + </div> + <div class="fg"> + <label>Database Name</label> + <input type="text" name="db_name" class="fi" value="<?= esc($_POST['db_name'] ?? '') ?>" placeholder="nexus_forum"> + </div> + <div class="fg"> + <label>Database Username</label> + <input type="text" name="db_user" class="fi" value="<?= esc($_POST['db_user'] ?? '') ?>" placeholder="nexus_user"> + </div> + <div class="fg"> + <label>Database Password</label> + <input type="password" name="db_pass" class="fi" placeholder="Your database password"> + </div> + <div class="notice notice-blue" style="font-size:12px"> + <strong>Create the database first if you haven't:</strong><br> + <code style="font-size:11px">CREATE DATABASE nexus_forum CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;</code> + </div> + <?php else: ?> + <div class="notice notice-yellow">⚠️ PDO MySQL is not available on this server.</div> + <?php endif; ?> + </div> + </div> + + <!-- Site details --> + <div class="fsection"> + <span class="fsection-title">2. Site Details</span> + <div class="fg"> + <label>Site Name <span style="color:#ef4444">*</span></label> + <input type="text" name="site_name" class="fi" required + value="<?= esc($_POST['site_name'] ?? 'My Forum') ?>" placeholder="My Community"> + </div> + <div class="fg"> + <label>Description <small>(optional)</small></label> + <input type="text" name="site_desc" class="fi" + value="<?= esc($_POST['site_desc'] ?? '') ?>" placeholder="A place for great discussions"> + </div> + </div> + + <!-- Admin account --> + <div class="fsection"> + <span class="fsection-title">3. Admin Account</span> + <div class="fg"> + <label>Username <span style="color:#ef4444">*</span> <small>(letters, numbers, _ -)</small></label> + <input type="text" name="admin_user" class="fi" required + value="<?= esc($_POST['admin_user'] ?? '') ?>" placeholder="admin" + minlength="3" maxlength="30" autocomplete="off"> + </div> + <div class="fg"> + <label>Email Address <span style="color:#ef4444">*</span></label> + <input type="email" name="admin_email" class="fi" required + value="<?= esc($_POST['admin_email'] ?? '') ?>" placeholder="admin@example.com"> + </div> + <div class="fg"> + <label>Password <span style="color:#ef4444">*</span> <small>(min. 8 characters)</small></label> + <input type="password" name="admin_pass" class="fi" required + minlength="8" placeholder="Choose a strong password" autocomplete="new-password"> + </div> + <div class="fg"> + <label>Confirm Password <span style="color:#ef4444">*</span></label> + <input type="password" name="admin_pass2" class="fi" required + minlength="8" placeholder="Repeat your password" autocomplete="new-password"> + </div> + </div> + + <button type="submit" class="btn-install"> + 🚀 Install Nexus Forum + </button> + </form> + + <?php elseif ($step === 3): /* ─── STEP 3: Done ─── */ ?> + + <div class="success-hero"> + <div class="emoji">🎉</div> + <h2>Installation Complete!</h2> + <p>Your forum is ready. Log in with your admin credentials below.</p> + </div> + + <div class="info-table"> + <div class="info-row"> + <span class="label">Forum URL</span> + <a href="<?= BASE ?>/"><?= BASE ?: '/' ?></a> + </div> + <div class="info-row"> + <span class="label">Admin Panel</span> + <a href="<?= BASE ?>/admin/"><?= BASE ?>/admin/</a> + </div> + <div class="info-row"> + <span class="label">Admin Username</span> + <code><?= esc($info['username'] ?? '') ?></code> + </div> + <div class="info-row"> + <span class="label">Database</span> + <span><?= ($info['db'] ?? 'sqlite') === 'mysql' ? '🐬 MySQL / MariaDB' : '🗃️ SQLite3' ?></span> + </div> + </div> + + <div class="notice notice-blue"> + <strong>🔒 Post-Install Security Checklist</strong> + <ul class="security-list"> + <li>✅ <code>data/.htaccess</code> configured — web access blocked automatically</li> + <li>✅ <code>uploads/.htaccess</code> configured — PHP execution blocked automatically</li> + <li>⚠️ <strong>Delete or restrict the <code>install/</code> directory</strong> — it's no longer needed</li> + <li>⚠️ Enable HTTPS on your web server</li> + <?php if (($info['db'] ?? '') !== 'mysql'): ?> + <li>📦 Back up <code>data/forum.db</code> regularly</li> + <?php endif; ?> + </ul> + </div> + + <a href="<?= BASE ?>/" class="btn-next" style="margin-top:8px">Visit Your Forum →</a> + + <?php endif; ?> + +</div> + +<script> +function switchDb(type) { + // Update hidden input + document.getElementById('dbTypeHidden').value = type; + + // Update button styles + document.getElementById('btnSQLite').className = 'db-btn' + (type === 'sqlite' ? ' active' : ''); + document.getElementById('btnMySQL').className = 'db-btn' + (type === 'mysql' ? ' active' : ''); + + // Show/hide panels + document.getElementById('panelSQLite').className = 'db-panel' + (type === 'sqlite' ? ' visible' : ''); + document.getElementById('panelMySQL').className = 'db-panel' + (type === 'mysql' ? ' visible' : ''); +} +</script> +</body> +</html> diff --git a/messages/chat.php b/messages/chat.php new file mode 100644 index 0000000..61a8772 --- /dev/null +++ b/messages/chat.php @@ -0,0 +1,402 @@ +<?php +/** + * Live Chat — conversation view with real-time polling + */ +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); + +$withUser = get('with'); +$other = null; + +if ($withUser) { + $other = DB::row('SELECT id,username,avatar,role,karma,last_seen FROM users WHERE username=?', [$withUser]); + if (!$other) render_404(); + if ((int)$other['id'] === (int)$USER['id']) go('messages/chat.php'); +} + +$PAGE_TITLE = $other ? 'Chat with @' . $other['username'] : 'Messages'; +include __DIR__ . '/../views/partials/layout.php'; +?> +<div class="chat-shell"> + + <!-- Sidebar: conversation list --> + <aside class="chat-aside" id="chatAside"> + <div class="chat-aside-head"> + <h2>💬 Messages</h2> + <a href="<?= u('messages/chat.php') ?>" class="chat-new-btn" title="New conversation"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <path d="M12 5v14M5 12h14"/> + </svg> + </a> + </div> + <div class="chat-aside-search"> + <input type="text" id="convSearch" class="chat-search-inp" placeholder="Search people…" autocomplete="off"> + <div id="convSearchResults" class="chat-search-results"></div> + </div> + <div class="chat-conv-list" id="convList"> + <div class="chat-loading">Loading…</div> + </div> + </aside> + + <!-- Main chat area --> + <div class="chat-main" id="chatMain"> + <?php if ($other): ?> + <!-- Chat header --> + <div class="chat-head"> + <div class="chat-head-left"> + <button class="chat-back-btn" onclick="history.back()">←</button> + <?php if ($other['avatar']): ?> + <img src="<?= e($other['avatar']) ?>" class="av-md" alt=""> + <?php else: ?> + <span class="av-md av-init"><?= strtoupper($other['username'][0]) ?></span> + <?php endif; ?> + <div> + <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="chat-head-name"> + @<?= e($other['username']) ?> + </a> + <div class="chat-head-status" id="chatStatus"> + <span class="status-dot" id="statusDot"></span> + <span id="statusTxt">Loading…</span> + </div> + </div> + </div> + <div class="chat-head-right"> + <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="btn-ghost btn-sm">View Profile</a> + </div> + </div> + + <!-- Messages area --> + <div class="chat-messages" id="chatMessages"> + <div class="chat-loading-msgs"> + <div class="chat-spinner"></div> + Loading messages… + </div> + </div> + + <!-- Typing + input --> + <div class="chat-input-area"> + <div class="chat-input-wrap"> + <textarea id="chatInput" class="chat-input" placeholder="Write a message… (Enter to send, Shift+Enter for newline)" + rows="1" maxlength="2000"></textarea> + <button id="chatSendBtn" class="chat-send-btn" title="Send"> + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"> + <path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/> + </svg> + </button> + </div> + <div class="chat-input-meta"> + <span id="chatCharCount" class="chat-char">0 / 2000</span> + <span class="chat-hint">Enter ↵ to send · Shift+Enter for new line</span> + </div> + </div> + + <?php else: ?> + <!-- No conversation selected --> + <div class="chat-empty-state"> + <div class="chat-empty-icon">💬</div> + <h2>Your Messages</h2> + <p>Select a conversation or start a new one</p> + <div class="chat-new-search"> + <input type="text" id="newChatSearch" class="fi" placeholder="Search for a user to message…" autocomplete="off"> + <div id="newChatResults" class="chat-search-results chat-search-results-lg"></div> + </div> + </div> + <?php endif; ?> + </div> + +</div> + +<script> +(function() { + var OTHER_ID = <?= $other ? (int)$other['id'] : 'null' ?>; + var OTHER_NAME = <?= $other ? json_encode($other['username']) : 'null' ?>; + var MY_ID = NX.user ? NX.user.id : null; + var lastMsgId = 0; + var pollTimer = null; + var sending = false; + + /* ── Helpers ─────────────────────────────────────────── */ + function esc(s) { + return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + .replace(/"/g,'"').replace(/'/g,'''); + } + + function timeStr(ts) { + if (!ts) return ''; + var d = new Date(ts.replace(' ','T') + 'Z'); + var now = new Date(); + var diff = (now - d) / 1000; + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff/60) + 'm ago'; + if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; + return d.toLocaleDateString(); + } + + function avatarHtml(name, av, size) { + size = size || 'av-sm'; + if (av) return '<img src="'+esc(av)+'" class="'+size+'" alt="">'; + return '<span class="'+size+' av-init">'+esc(name[0].toUpperCase())+'</span>'; + } + + /* ── Render a single message bubble ────────────────── */ + function renderBubble(m) { + var mine = (parseInt(m.sender_id) === MY_ID); + var ts = timeStr(m.created_at); + return '<div class="chat-bubble-row '+(mine?'mine':'theirs')+'" data-id="'+m.id+'">' + + (mine ? '' : '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>') + + '<div class="chat-bubble-wrap">' + + '<div class="chat-bubble">' + + '<div class="chat-bubble-body">'+esc(m.body).replace(/\n/g,'<br>')+'</div>' + + '<div class="chat-bubble-time">'+ts+'</div>' + + '</div>' + + (mine ? '<button class="chat-del-btn" onclick="deleteMsg('+m.id+',this)" title="Delete">✕</button>' : '') + + '</div>' + + (mine ? '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>' : '') + + '</div>'; + } + + /* ── Load conversation ─────────────────────────────── */ + function loadConversation() { + if (!OTHER_ID) return; + var fd = new FormData(); + fd.append('action','load'); fd.append('other_id',OTHER_ID); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + if (!d.ok) return; + var box = document.getElementById('chatMessages'); + if (d.messages.length === 0) { + box.innerHTML = '<div class="chat-no-msgs">No messages yet. Say hello! 👋</div>'; + } else { + box.innerHTML = d.messages.map(renderBubble).join(''); + lastMsgId = d.messages[d.messages.length-1].id; + scrollToBottom(true); + } + updateStatus(d.other); + startPolling(); + }); + } + + /* ── Update online status ─────────────────────────── */ + function updateStatus(other) { + if (!other) return; + var dot = document.getElementById('statusDot'); + var txt = document.getElementById('statusTxt'); + if (!dot || !txt) return; + var ls = new Date((other.last_seen||'').replace(' ','T')+'Z'); + var diff = (Date.now() - ls) / 1000; + if (diff < 300) { + dot.className = 'status-dot online'; + txt.textContent = 'Online'; + } else { + dot.className = 'status-dot offline'; + txt.textContent = 'Last seen ' + timeStr(other.last_seen); + } + } + + /* ── Scroll to bottom ─────────────────────────────── */ + function scrollToBottom(force) { + var box = document.getElementById('chatMessages'); + if (!box) return; + var atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80; + if (force || atBottom) box.scrollTop = box.scrollHeight; + } + + /* ── Poll for new messages ────────────────────────── */ + function poll() { + if (!OTHER_ID) return; + var fd = new FormData(); + fd.append('action','poll'); fd.append('other_id',OTHER_ID); + fd.append('since_id',lastMsgId); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + if (!d.ok || !d.messages.length) return; + var box = document.getElementById('chatMessages'); + var noMsg = box.querySelector('.chat-no-msgs'); + if (noMsg) noMsg.remove(); + d.messages.forEach(function(m){ + if (!box.querySelector('[data-id="'+m.id+'"]')) { + box.insertAdjacentHTML('beforeend', renderBubble(m)); + lastMsgId = Math.max(lastMsgId, m.id); + } + }); + scrollToBottom(); + }); + } + + function startPolling() { + clearInterval(pollTimer); + pollTimer = setInterval(poll, 2500); + } + + /* ── Send message ────────────────────────────────── */ + function sendMessage() { + if (sending) return; + var inp = document.getElementById('chatInput'); + var body = inp.value.trim(); + if (!body || !OTHER_ID) return; + sending = true; + var btn = document.getElementById('chatSendBtn'); + btn.disabled = true; + inp.disabled = true; + + var fd = new FormData(); + fd.append('action','send'); fd.append('to_id',OTHER_ID); + fd.append('body',body); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + sending = false; btn.disabled = false; inp.disabled = false; inp.focus(); + if (d.ok && d.message) { + var box = document.getElementById('chatMessages'); + var noMsg = box.querySelector('.chat-no-msgs'); + if (noMsg) noMsg.remove(); + box.insertAdjacentHTML('beforeend', renderBubble(d.message)); + lastMsgId = Math.max(lastMsgId, d.message.id); + scrollToBottom(true); + inp.value = ''; autoResize(inp); + updateCharCount(); + } else { + toast(d.error || 'Failed to send', 'err'); + } + }) + .catch(function(){ sending=false; btn.disabled=false; inp.disabled=false; toast('Network error','err'); }); + } + + /* ── Delete message ──────────────────────────────── */ + window.deleteMsg = function(id, btn) { + if (!confirm('Delete this message?')) return; + var fd = new FormData(); + fd.append('action','delete'); fd.append('msg_id',id); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + if (d.ok) btn.closest('.chat-bubble-row').remove(); + else toast(d.error,'err'); + }); + }; + + /* ── Input auto-resize ───────────────────────────── */ + function autoResize(ta) { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, 160) + 'px'; + } + function updateCharCount() { + var inp = document.getElementById('chatInput'); + var cnt = document.getElementById('chatCharCount'); + if (inp && cnt) { + var n = inp.value.length; + cnt.textContent = n + ' / 2000'; + cnt.style.color = n > 1800 ? '#ef4444' : ''; + } + } + + /* ── Load conversation list ──────────────────────── */ + function loadConvList() { + var fd = new FormData(); + fd.append('action','conversations'); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/chat.php', {method:'POST', body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + var list = document.getElementById('convList'); + if (!list) return; + if (!d.ok || !d.conversations.length) { + list.innerHTML = '<div class="chat-empty-conv">No conversations yet</div>'; + return; + } + list.innerHTML = d.conversations.map(function(c){ + var isOther = parseInt(c.sender_id) === MY_ID ? false : true; + var name = c.other_name; + var av = c.other_av; + var active = OTHER_NAME && OTHER_NAME === name ? ' active' : ''; + var unread = parseInt(c.unread_count) > 0; + return '<a href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(name)+'" class="conv-item'+active+'">' + + avatarHtml(name, av) + + '<div class="conv-body">' + + '<div class="conv-name">@'+esc(name)+(unread?'<span class="conv-unread">'+c.unread_count+'</span>':'')+'</div>' + + '<div class="conv-preview">'+(parseInt(c.sender_id)===MY_ID?'You: ':'')+esc(c.body.substring(0,50))+(c.body.length>50?'…':'')+'</div>' + + '</div>' + + '<div class="conv-time">'+timeStr(c.created_at)+'</div>' + + '</a>'; + }).join(''); + }); + } + + /* ── Conversation search (sidebar) ──────────────── */ + function setupConvSearch() { + var inp = document.getElementById('convSearch'); + if (!inp) return; + var res = document.getElementById('convSearchResults'); + var timer; + inp.addEventListener('input', function(){ + clearTimeout(timer); + var q = this.value.trim(); + if (q.length < 1) { res.style.display='none'; return; } + timer = setTimeout(function(){ + fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q)) + .then(function(r){return r.json();}) + .then(function(rows){ + if (!rows.length) { res.style.display='none'; return; } + res.innerHTML = rows.map(function(u){ + return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">' + + avatarHtml(u.username, u.avatar) + + '<span>@'+esc(u.username)+'</span></a>'; + }).join(''); + res.style.display='block'; + }); + }, 200); + }); + document.addEventListener('click', function(e){ + if (!inp.contains(e.target)) res.style.display='none'; + }); + } + + /* ── New chat search (empty state) ──────────────── */ + function setupNewChatSearch() { + var inp = document.getElementById('newChatSearch'); + if (!inp) return; + var res = document.getElementById('newChatResults'); + var timer; + inp.addEventListener('input', function(){ + clearTimeout(timer); + var q = this.value.trim(); + if (q.length < 1) { res.style.display='none'; return; } + timer = setTimeout(function(){ + fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q)) + .then(function(r){return r.json();}) + .then(function(rows){ + if (!rows.length) { res.style.display='none'; return; } + res.innerHTML = rows.map(function(u){ + return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">' + + avatarHtml(u.username, u.avatar, 'av-md') + + '<div><div style="font-weight:600">@'+esc(u.username)+'</div></div></a>'; + }).join(''); + res.style.display='block'; + }); + }, 200); + }); + } + + /* ── Wire up input ───────────────────────────────── */ + var inp = document.getElementById('chatInput'); + if (inp) { + inp.addEventListener('keydown', function(e){ + if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } + }); + inp.addEventListener('input', function(){ autoResize(this); updateCharCount(); }); + document.getElementById('chatSendBtn').addEventListener('click', sendMessage); + } + + /* ── Init ─────────────────────────────────────────── */ + loadConvList(); + setupConvSearch(); + setupNewChatSearch(); + if (OTHER_ID) loadConversation(); + + // Refresh conv list every 10s + setInterval(loadConvList, 10000); + +})(); +</script> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/messages/compose.php b/messages/compose.php new file mode 100644 index 0000000..8f86dc6 --- /dev/null +++ b/messages/compose.php @@ -0,0 +1,231 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); + +$toUser = get('to'); +$errs = []; + +// Online users for quick selection +$since = DB::sinceSeconds(900); +$onlineUsers = DB::rows( + "SELECT id, username, avatar, role FROM users + WHERE last_seen >= $since AND id != ? AND suspended = 0 + ORDER BY last_seen DESC LIMIT 12", + [$USER['id']] +); + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!csrf_ok()) { $errs[] = 'Invalid request.'; } + else { + $to = sanitise(post('to')); + $subject = sanitise(post('subject')); + $body = sanitise(post('body')); + + if (!$to) $errs[] = 'Recipient required.'; + if (!$body) $errs[] = 'Message body required.'; + if (strlen($body) > 5000) $errs[] = 'Message too long (max 5000 chars).'; + + if (!$errs) { + $recipient = DB::row('SELECT * FROM users WHERE username=?', [$to]); + if (!$recipient) $errs[] = 'User "@' . e($to) . '" not found.'; + elseif ($recipient['id'] === $USER['id']) $errs[] = 'You cannot message yourself.'; + } + + if (!$errs) { + $newId = DB::insert( + 'INSERT INTO messages (sender_id,receiver_id,subject,body) VALUES (?,?,?,?)', + [$USER['id'], $recipient['id'], $subject, $body] + ); + add_notification((int)$recipient['id'], 'message', [ + 'from' => $USER['username'], + 'from_id' => $USER['id'], + 'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''), + ]); + go('messages/view.php?id=' . $newId); + } + } +} + +$PAGE_TITLE = 'New Message'; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<div class="cp-layout"> + + <!-- ── Compose form ─────────────────────────── --> + <div class="cp-main"> + <div class="form-card"> + <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px"> + <h1 style="font-size:1.2rem;font-weight:700;margin:0">✉️ New Message</h1> + <a href="<?= u('messages/') ?>" class="btn-ghost btn-sm">← Inbox</a> + </div> + + <?php if (!empty($errs)): ?> + <div class="alert err"><?= implode('<br>', array_map('e', $errs)) ?></div> + <?php endif; ?> + + <form method="POST" id="composeForm"> + <?= csrf_input() ?> + + <!-- To field with autocomplete --> + <div class="fg"> + <label>To <span class="req">*</span></label> + <div class="to-input-wrap" style="position:relative"> + <span class="at-prefix">@</span> + <input type="text" name="to" id="toInput" class="fi" required + value="<?= e($_POST['to'] ?? $toUser ?? '') ?>" + placeholder="username" autocomplete="off"> + <div id="toSuggestions" class="to-suggestions"></div> + </div> + </div> + + <!-- Subject --> + <div class="fg"> + <label>Subject <small>(optional)</small></label> + <input type="text" name="subject" class="fi" maxlength="150" + value="<?= e($_POST['subject'] ?? '') ?>" placeholder="What's this about?"> + </div> + + <!-- Body --> + <div class="fg"> + <label>Message <span class="req">*</span></label> + <textarea name="body" id="msgBody" class="fi" rows="10" required maxlength="5000" + placeholder="Write your message…"><?= e($_POST['body'] ?? '') ?></textarea> + <div style="display:flex;justify-content:space-between;margin-top:4px"> + <span class="hint">Max 5000 characters</span> + <span class="hint" id="bodyCount">0 / 5000</span> + </div> + </div> + + <div class="form-actions"> + <a href="<?= u('messages/') ?>" class="btn-ghost">Cancel</a> + <button type="submit" class="btn-primary"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/></svg> + Send Message + </button> + </div> + </form> + </div> + </div> + + <!-- ── Online people panel ────────────────────── --> + <aside class="cp-aside"> + <div class="mx-widget"> + <div class="mx-widget-head"> + <span class="mx-online-dot" style="position:static;margin-right:4px"></span> + Online Now + <span class="mx-widget-count"><?= count($onlineUsers) ?></span> + </div> + <?php if (empty($onlineUsers)): ?> + <div class="mx-widget-empty">No one online right now</div> + <?php else: ?> + <p style="padding:10px 14px 4px;font-size:12px;color:var(--muted)">Click to message them directly</p> + <div class="mx-online-list"> + <?php foreach ($onlineUsers as $ou): ?> + <button class="mx-online-row cp-pick" type="button" + onclick="pickUser('<?= e($ou['username']) ?>')" + title="Send to @<?= e($ou['username']) ?>"> + <div style="position:relative;flex-shrink:0"> + <?php if ($ou['avatar']): ?> + <img src="<?= e($ou['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($ou['username'][0]) ?></span> + <?php endif; ?> + <span class="mx-online-dot mx-online-dot-sm"></span> + </div> + <div class="mx-online-info"> + <span class="mx-online-name">@<?= e($ou['username']) ?></span> + <span class="role-tag role-<?= e($ou['role']) ?>"><?= e($ou['role']) ?></span> + </div> + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--faint)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg> + </button> + <?php endforeach; ?> + </div> + <?php endif; ?> + </div> + + <!-- Search any user --> + <div class="mx-widget" style="margin-top:14px"> + <div class="mx-widget-head">🔍 Find User</div> + <div style="padding:10px"> + <input type="text" id="findUser" class="fi" placeholder="Search username…" autocomplete="off"> + <div id="findResults" class="to-suggestions" style="position:static;margin-top:6px;border-radius:var(--r)"></div> + </div> + </div> + </aside> +</div> + +<script> +/* ── To-field autocomplete ──────────────────── */ +var toInp = document.getElementById('toInput'); +var toSug = document.getElementById('toSuggestions'); +var toTmr; +if (toInp) { + toInp.addEventListener('input', function() { + clearTimeout(toTmr); + var q = this.value.trim().replace(/^@/, ''); + if (!q) { toSug.style.display = 'none'; return; } + toTmr = setTimeout(function() { + fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(q)) + .then(function(r) { return r.json(); }) + .then(function(rows) { + if (!rows.length) { toSug.style.display = 'none'; return; } + toSug.innerHTML = rows.map(function(u) { + return '<div class="to-sug-item" onclick="pickUser(\'' + u.username + '\')">' + + '@' + u.username + '</div>'; + }).join(''); + toSug.style.display = 'block'; + }); + }, 220); + }); + document.addEventListener('click', function(e) { + if (!toInp.contains(e.target)) toSug.style.display = 'none'; + }); +} + +/* ── Find user panel ────────────────────────── */ +var findInp = document.getElementById('findUser'); +var findRes = document.getElementById('findResults'); +var findTmr; +if (findInp) { + findInp.addEventListener('input', function() { + clearTimeout(findTmr); + var q = this.value.trim(); + if (!q) { findRes.style.display = 'none'; return; } + findTmr = setTimeout(function() { + fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(q)) + .then(function(r) { return r.json(); }) + .then(function(rows) { + if (!rows.length) { findRes.style.display = 'none'; return; } + findRes.innerHTML = rows.map(function(u) { + return '<div class="to-sug-item" onclick="pickUser(\'' + u.username + '\')">' + + '@' + u.username + '</div>'; + }).join(''); + findRes.style.display = 'block'; + }); + }, 220); + }); +} + +/* ── Pick user ──────────────────────────────── */ +function pickUser(uname) { + document.getElementById('toInput').value = uname; + toSug.style.display = 'none'; + if (findRes) findRes.style.display = 'none'; + document.getElementById('toInput').focus(); + document.getElementById('toInput').dispatchEvent(new Event('input')); +} + +/* ── Char counter ───────────────────────────── */ +var body = document.getElementById('msgBody'); +var cnt = document.getElementById('bodyCount'); +if (body && cnt) { + body.addEventListener('input', function() { + var n = this.value.length; + cnt.textContent = n + ' / 5000'; + cnt.style.color = n > 4500 ? '#ef4444' : ''; + }); +} +</script> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/messages/index.php b/messages/index.php new file mode 100644 index 0000000..68bda61 --- /dev/null +++ b/messages/index.php @@ -0,0 +1,242 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); + +$box = get('box', 'inbox'); +if (!in_array($box, ['inbox', 'sent'])) $box = 'inbox'; + +/* ── Fetch messages ─────────────────────────────────── */ +if ($box === 'inbox') { + $msgs = DB::rows(" + SELECT m.*, u.username AS other_name, u.avatar AS other_av, + u.last_seen, u.role + FROM messages m + JOIN users u ON u.id = m.sender_id + WHERE m.receiver_id = ? AND m.deleted_by_receiver = 0 + ORDER BY m.created_at DESC + ", [$USER['id']]); +} else { + $msgs = DB::rows(" + SELECT m.*, u.username AS other_name, u.avatar AS other_av, + u.last_seen, u.role + FROM messages m + JOIN users u ON u.id = m.receiver_id + WHERE m.sender_id = ? AND m.deleted_by_sender = 0 + ORDER BY m.created_at DESC + ", [$USER['id']]); +} + +/* ── Online users (last seen within 15 min) ─────────── */ +$since = DB::sinceSeconds(900); +$onlineUsers = DB::rows( + "SELECT id, username, avatar, role FROM users + WHERE last_seen >= $since AND id != ? AND suspended = 0 + ORDER BY last_seen DESC LIMIT 20", + [$USER['id']] +); + +$unread = unread_messages(); +$sentCount = (int)DB::val("SELECT COUNT(*) FROM messages WHERE sender_id=? AND deleted_by_sender=0", [$USER['id']]); + +$PAGE_TITLE = 'Messages'; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<div class="mx-layout"> + + <!-- ── Left: Inbox ───────────────────────────── --> + <div class="mx-main"> + + <div class="mx-topbar"> + <div> + <h1 class="mx-title"> + <?= $box === 'inbox' ? '📥 Inbox' : '📤 Sent' ?> + <?php if ($box === 'inbox' && $unread > 0): ?> + <span class="mx-unread-badge"><?= $unread ?> unread</span> + <?php endif; ?> + </h1> + </div> + <a href="<?= u('messages/compose.php') ?>" class="btn-primary"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg> + Compose + </a> + </div> + + <!-- Tabs --> + <div class="mx-tabs"> + <a href="?box=inbox" class="mx-tab <?= $box === 'inbox' ? 'active' : '' ?>"> + 📥 Inbox + <?php if ($unread > 0): ?> + <span class="mx-tab-badge"><?= $unread ?></span> + <?php endif; ?> + </a> + <a href="?box=sent" class="mx-tab <?= $box === 'sent' ? 'active' : '' ?>"> + 📤 Sent + <?php if ($sentCount > 0): ?> + <span class="mx-tab-badge muted"><?= $sentCount ?></span> + <?php endif; ?> + </a> + </div> + + <?php if (isset($_GET['sent'])): ?> + <div class="alert ok" style="margin-bottom:16px">✓ Message sent successfully.</div> + <?php endif; ?> + + <?php if (empty($msgs)): ?> + <div class="mx-empty"> + <div class="mx-empty-icon"><?= $box === 'inbox' ? '📭' : '📤' ?></div> + <p><?= $box === 'inbox' ? 'Your inbox is empty.' : 'No sent messages yet.' ?></p> + <a href="<?= u('messages/compose.php') ?>" class="btn-primary">Send a message</a> + </div> + <?php else: ?> + <div class="mx-list"> + <?php foreach ($msgs as $m): + $isUnread = !$m['is_read'] && $box === 'inbox'; + $isOnline = strtotime($m['last_seen']) >= (time() - 900); + $name = $m['other_name']; + $av = $m['other_av']; + ?> + <a href="<?= u('messages/view.php?id=' . $m['id']) ?>" + class="mx-row <?= $isUnread ? 'unread' : '' ?>"> + + <!-- Avatar + online dot --> + <div class="mx-row-av"> + <?php if ($av): ?> + <img src="<?= e($av) ?>" class="av-md" alt=""> + <?php else: ?> + <span class="av-md av-init"><?= strtoupper($name[0]) ?></span> + <?php endif; ?> + <?php if ($isOnline): ?> + <span class="mx-online-dot" title="Online now"></span> + <?php endif; ?> + </div> + + <!-- Content --> + <div class="mx-row-body"> + <div class="mx-row-top"> + <span class="mx-row-from"> + <?= $box === 'inbox' ? 'From' : 'To' ?> + <strong>@<?= e($name) ?></strong> + <span class="role-tag role-<?= e($m['role']) ?>"><?= e($m['role']) ?></span> + <?php if ($isOnline): ?> + <span class="mx-online-label">● Online</span> + <?php endif; ?> + </span> + <span class="mx-row-time"> + <?php if ($isUnread): ?> + <span class="mx-new-badge">NEW</span> + <?php endif; ?> + <span class="ago" data-ts="<?= e($m['created_at']) ?>"></span> + </span> + </div> + <?php if ($m['subject']): ?> + <div class="mx-row-subject"><?= e($m['subject']) ?></div> + <?php endif; ?> + <div class="mx-row-preview"><?= e(mb_substr($m['body'], 0, 100)) ?><?= mb_strlen($m['body']) > 100 ? '…' : '' ?></div> + </div> + + <!-- Arrow --> + <div class="mx-row-arrow">›</div> + </a> + <?php endforeach; ?> + </div> + <?php endif; ?> + </div> + + <!-- ── Right: Online users + quick compose ───── --> + <aside class="mx-aside"> + + <!-- Online users --> + <div class="mx-widget"> + <div class="mx-widget-head"> + <span class="mx-online-dot" style="position:static;margin-right:4px"></span> + Online Now + <span class="mx-widget-count"><?= count($onlineUsers) ?></span> + </div> + <?php if (empty($onlineUsers)): ?> + <div class="mx-widget-empty">No one online right now</div> + <?php else: ?> + <div class="mx-online-list"> + <?php foreach ($onlineUsers as $ou): ?> + <div class="mx-online-row"> + <div style="position:relative;flex-shrink:0"> + <?php if ($ou['avatar']): ?> + <img src="<?= e($ou['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($ou['username'][0]) ?></span> + <?php endif; ?> + <span class="mx-online-dot mx-online-dot-sm"></span> + </div> + <div class="mx-online-info"> + <a href="<?= u('users/profile.php?u=' . urlencode($ou['username'])) ?>" + class="mx-online-name">@<?= e($ou['username']) ?></a> + <span class="role-tag role-<?= e($ou['role']) ?>"><?= e($ou['role']) ?></span> + </div> + <a href="<?= u('messages/compose.php?to=' . urlencode($ou['username'])) ?>" + class="mx-msg-quick" title="Send message"> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> + </a> + </div> + <?php endforeach; ?> + </div> + <?php endif; ?> + </div> + + <!-- Quick compose --> + <div class="mx-widget" style="margin-top:16px"> + <div class="mx-widget-head">✏️ Quick Message</div> + <div style="padding:14px"> + <div style="position:relative;margin-bottom:10px"> + <input type="text" id="quickTo" class="fi" placeholder="@username" autocomplete="off" + style="padding-left:26px"> + <span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--faint);font-size:13px;pointer-events:none">@</span> + <div id="quickSuggestions" class="to-suggestions"></div> + </div> + <a id="quickComposeBtn" href="<?= u('messages/compose.php') ?>" class="btn-primary btn-block"> + Compose → + </a> + </div> + </div> + + </aside> +</div> + +<script> +/* Quick compose — update link as user types */ +var qTo = document.getElementById('quickTo'); +var qBtn = document.getElementById('quickComposeBtn'); +var qSug = document.getElementById('quickSuggestions'); +var qTimer; + +if (qTo) { + qTo.addEventListener('input', function() { + var val = this.value.trim().replace(/^@/, ''); + qBtn.href = NX.base + '/messages/compose.php' + (val ? '?to=' + encodeURIComponent(val) : ''); + clearTimeout(qTimer); + if (val.length < 1) { qSug.style.display = 'none'; return; } + qTimer = setTimeout(function() { + fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(val)) + .then(function(r) { return r.json(); }) + .then(function(rows) { + if (!rows.length) { qSug.style.display = 'none'; return; } + qSug.innerHTML = rows.slice(0, 5).map(function(u) { + return '<div class="to-sug-item" onclick="pickQuick(\'' + u.username + '\')">@' + u.username + '</div>'; + }).join(''); + qSug.style.display = 'block'; + }); + }, 220); + }); + document.addEventListener('click', function(e) { + if (!qTo.contains(e.target)) qSug.style.display = 'none'; + }); +} + +function pickQuick(uname) { + qTo.value = uname; + qBtn.href = NX.base + '/messages/compose.php?to=' + encodeURIComponent(uname); + qSug.style.display = 'none'; + qBtn.click(); +} +</script> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/messages/view.php b/messages/view.php new file mode 100644 index 0000000..e2f82ba --- /dev/null +++ b/messages/view.php @@ -0,0 +1,245 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); + +$id = (int)get('id'); +$msg = DB::row(" + SELECT m.*, + s.username AS sname, s.avatar AS sav, s.role AS srole, + s.karma AS skarma, s.last_seen AS s_last_seen, + r.username AS rname, r.avatar AS rav, r.role AS rrole, + r.last_seen AS r_last_seen + FROM messages m + JOIN users s ON s.id = m.sender_id + JOIN users r ON r.id = m.receiver_id + WHERE m.id = ? +", [$id]); + +if (!$msg) render_404(); +if ($msg['sender_id'] !== $USER['id'] && $msg['receiver_id'] !== $USER['id']) render_403(); +if ($msg['receiver_id'] === $USER['id'] && $msg['deleted_by_receiver']) render_404(); +if ($msg['sender_id'] === $USER['id'] && $msg['deleted_by_sender']) render_404(); + +// Mark as read +if ($msg['receiver_id'] === $USER['id'] && !$msg['is_read']) { + DB::run('UPDATE messages SET `is_read`=1 WHERE id=?', [$id]); +} + +// Determine the "other" person +$otherId = $msg['sender_id'] === $USER['id'] ? $msg['receiver_id'] : $msg['sender_id']; +$otherName = $msg['sender_id'] === $USER['id'] ? $msg['rname'] : $msg['sname']; +$otherAv = $msg['sender_id'] === $USER['id'] ? $msg['rav'] : $msg['sav']; +$otherRole = $msg['sender_id'] === $USER['id'] ? $msg['rrole'] : $msg['srole']; +$otherSeen = $msg['sender_id'] === $USER['id'] ? $msg['r_last_seen'] : $msg['s_last_seen']; +$otherOnline = strtotime($otherSeen) >= (time() - 900); + +// Conversation thread (all messages between these two users) +function conv_id_local(int $a, int $b): string { return min($a,$b).'-'.max($a,$b); } +$convId = conv_id_local((int)$USER['id'], $otherId); +$thread = DB::rows(" + SELECT m.*, u.username AS sender_name, u.avatar AS sender_av + FROM messages m JOIN users u ON u.id = m.sender_id + WHERE (m.sender_id=? AND m.receiver_id=?) + OR (m.sender_id=? AND m.receiver_id=?) + ORDER BY m.created_at ASC +", [$USER['id'], $otherId, $otherId, $USER['id']]); + +// Handle POST actions +if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) { + $action = post('action'); + + if ($action === 'delete_msg') { + $delId = (int)post('del_id'); + $dm = DB::row('SELECT * FROM messages WHERE id=?', [$delId]); + if ($dm && ($dm['sender_id'] === $USER['id'] || $dm['receiver_id'] === $USER['id'])) { + if ($dm['sender_id'] === $USER['id']) DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?', [$delId]); + if ($dm['receiver_id'] === $USER['id']) DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$delId]); + } + go('messages/view.php?id=' . $id); + } + + if ($action === 'delete') { + if ($msg['receiver_id'] === $USER['id']) + DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$id]); + else + DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?', [$id]); + go('messages/'); + } + + if ($action === 'reply') { + $body = sanitise(post('body')); + if ($body) { + $subject = $msg['subject'] ? ('Re: ' . ltrim($msg['subject'], 'Re: ')) : ''; + DB::insert( + 'INSERT INTO messages (sender_id,receiver_id,subject,body) VALUES (?,?,?,?)', + [$USER['id'], $otherId, $subject, $body] + ); + add_notification($otherId, 'message', [ + 'from' => $USER['username'], + 'from_id' => $USER['id'], + 'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''), + ]); + go('messages/view.php?id=' . $id . '&replied=1'); + } + } +} + +$PAGE_TITLE = $msg['subject'] ?: 'Conversation with @' . $otherName; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<div class="mv-layout"> + + <!-- ── Thread sidebar ───────────────────────── --> + <aside class="mv-aside"> + <!-- Other person card --> + <div class="mv-user-card"> + <div class="mv-user-av"> + <?php if ($otherAv): ?> + <img src="<?= e($otherAv) ?>" class="av-lg" alt=""> + <?php else: ?> + <span class="av-lg av-init"><?= strtoupper($otherName[0]) ?></span> + <?php endif; ?> + <?php if ($otherOnline): ?> + <span class="mv-online-dot" title="Online now"></span> + <?php endif; ?> + </div> + <div class="mv-user-info"> + <a href="<?= u('users/profile.php?u=' . urlencode($otherName)) ?>" class="mv-user-name"> + @<?= e($otherName) ?> + </a> + <span class="role-tag role-<?= e($otherRole) ?>"><?= e($otherRole) ?></span> + <div class="mv-user-status <?= $otherOnline ? 'online' : 'offline' ?>"> + <span class="mv-status-dot"></span> + <?= $otherOnline ? 'Online now' : 'Last seen <span class="ago" data-ts="' . e($otherSeen) . '"></span>' ?> + </div> + </div> + <div class="mv-user-actions"> + <a href="<?= u('users/profile.php?u=' . urlencode($otherName)) ?>" class="btn-ghost btn-sm">Profile</a> + <a href="<?= u('messages/compose.php?to=' . urlencode($otherName)) ?>" class="btn-ghost btn-sm">New Message</a> + </div> + </div> + + <!-- Conversation stats --> + <div class="mv-conv-stats"> + <div class="mv-cs-item"> + <span class="mv-cs-num"><?= count($thread) ?></span> + <span class="mv-cs-lbl">messages</span> + </div> + <div class="mv-cs-item"> + <span class="mv-cs-num"><?= count(array_filter($thread, fn($m) => !$m['is_read'] && $m['sender_id'] == $otherId)) ?></span> + <span class="mv-cs-lbl">unread</span> + </div> + </div> + + <a href="<?= u('messages/') ?>" class="btn-ghost btn-block" style="margin-top:4px">← Back to Inbox</a> + </aside> + + <!-- ── Main thread ───────────────────────────── --> + <div class="mv-main"> + + <!-- Thread header --> + <div class="mv-thread-head"> + <h1 class="mv-thread-title"> + <?= $msg['subject'] ? e($msg['subject']) : 'Conversation with @' . e($otherName) ?> + </h1> + <form method="POST" style="display:inline-block"> + <?= csrf_input() ?><input type="hidden" name="action" value="delete"> + <button class="btn-ghost btn-sm" style="color:var(--red)" + onclick="return confirm('Delete this message thread?')">🗑 Delete</button> + </form> + </div> + + <?php if (isset($_GET['replied'])): ?> + <div class="alert ok" style="margin-bottom:16px">✓ Reply sent.</div> + <?php endif; ?> + + <!-- Message thread --> + <div class="mv-thread"> + <?php foreach ($thread as $tm): + $isMine = ((int)$tm['sender_id'] === (int)$USER['id']); + $canDel = ($tm['sender_id'] == $USER['id'] && !$tm['deleted_by_sender']) + || ($tm['receiver_id'] == $USER['id'] && !$tm['deleted_by_receiver']); + $isHighlight = ((int)$tm['id'] === $id && !$isMine); + ?> + <div class="mv-msg <?= $isMine ? 'mv-mine' : 'mv-theirs' ?> <?= $isHighlight ? 'mv-highlight' : '' ?>" + id="msg-<?= $tm['id'] ?>"> + + <!-- Avatar --> + <div class="mv-msg-av" <?= $isMine ? 'style="order:3"' : '' ?>> + <?php if ($tm['sender_av']): ?> + <img src="<?= e($tm['sender_av']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($tm['sender_name'][0]) ?></span> + <?php endif; ?> + </div> + + <!-- Bubble --> + <div class="mv-msg-bubble <?= $isMine ? 'mv-bubble-mine' : 'mv-bubble-theirs' ?>"> + <div class="mv-msg-body"><?= nl2br(e($tm['body'])) ?></div> + <div class="mv-msg-meta"> + <span class="ago" data-ts="<?= e($tm['created_at']) ?>"></span> + <?php if ($isMine && $tm['is_read']): ?> + <span class="mv-read-tick" title="Read">✓✓</span> + <?php elseif ($isMine): ?> + <span class="mv-sent-tick" title="Sent">✓</span> + <?php endif; ?> + <?php if ($canDel): ?> + <form method="POST" style="display:inline"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="delete_msg"> + <input type="hidden" name="del_id" value="<?= $tm['id'] ?>"> + <button class="mv-del-btn" title="Delete" onclick="return confirm('Delete this message?')">✕</button> + </form> + <?php endif; ?> + </div> + </div> + </div> + <?php endforeach; ?> + </div> + + <!-- Reply box --> + <div class="mv-reply-box" id="replyBox"> + <div class="mv-reply-head"> + <span>↩ Reply to @<?= e($otherName) ?></span> + <?php if ($otherOnline): ?> + <span style="color:var(--green);font-size:12px">● Online — will see it right away</span> + <?php endif; ?> + </div> + <form method="POST" id="replyForm"> + <?= csrf_input() ?> + <input type="hidden" name="action" value="reply"> + <textarea name="body" id="replyBody" class="mv-reply-ta" rows="4" required + maxlength="5000" placeholder="Write your reply…"></textarea> + <div class="mv-reply-footer"> + <span class="mv-char-cnt" id="charCnt">0 / 5000</span> + <button type="submit" class="btn-primary">Send Reply</button> + </div> + </form> + </div> + + </div> +</div> + +<script> +// Scroll to highlighted message +var hl = document.querySelector('.mv-highlight'); +if (hl) hl.scrollIntoView({behavior:'smooth', block:'center'}); + +// Scroll thread to bottom if viewing latest +var thread = document.querySelector('.mv-thread'); +if (thread && !hl) thread.scrollTop = thread.scrollHeight; + +// Char counter +var ta = document.getElementById('replyBody'); +var cc = document.getElementById('charCnt'); +if (ta && cc) { + ta.addEventListener('input', function() { + var n = this.value.length; + cc.textContent = n + ' / 5000'; + cc.style.color = n > 4500 ? '#ef4444' : ''; + }); +} +</script> + +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..786f217 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,61 @@ +/* ── Admin Layout ────────────────────────────────────────── */ +.admin-body{background:#f1f5f9;margin:0;padding:0;font-family:var(--font)} +.admin-wrap{display:flex;min-height:100vh} +.admin-sb{width:230px;background:#0f172a;flex-shrink:0;position:fixed;top:0;left:0;height:100vh;overflow-y:auto;z-index:50} +.admin-sb-top{padding:18px 16px;border-bottom:1px solid rgba(255,255,255,.07)} +.admin-logo{display:flex;align-items:center;gap:10px;text-decoration:none} +.admin-logo:hover{text-decoration:none} +.admin-logo .logo-mark{width:36px;height:36px;font-size:18px;border-radius:9px} +.admin-logo-name{color:#fff;font-size:14px;font-weight:600} +.admin-logo-sub{color:rgba(255,255,255,.35);font-size:11px} +.admin-nav{padding:10px 8px} +.anav{display:flex;align-items:center;gap:9px;padding:9px 11px;border-radius:7px;font-size:14px;font-weight:500;color:rgba(255,255,255,.5);text-decoration:none;transition:all .18s;margin-bottom:2px} +.anav:hover{background:rgba(255,255,255,.07);color:#fff;text-decoration:none} +.anav.on{background:var(--blue);color:#fff} +.anav-div{height:1px;background:rgba(255,255,255,.07);margin:8px 10px} +.admin-main{flex:1;margin-left:230px;display:flex;flex-direction:column} +.admin-topbar{background:#fff;border-bottom:1px solid #e2e8f0;padding:15px 26px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:40;box-shadow:0 1px 3px rgba(0,0,0,.06)} +.admin-topbar h1{font-size:17px;font-weight:700;color:#0f172a;margin:0} +.admin-topbar span{font-size:13px;color:#64748b} +.admin-content{padding:22px 26px} + +/* ── Stat cards ──────────────────────────────────────────── */ +.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px} +@media(max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}} +.stat-card{background:#fff;border:1px solid #e2e8f0;border-radius:11px;padding:18px;display:flex;align-items:flex-start;gap:14px;box-shadow:0 1px 3px rgba(0,0,0,.06);transition:all .18s} +.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.1)} +.stat-icon{width:46px;height:46px;border-radius:11px;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0} +.stat-num{font-size:1.7rem;font-weight:700;color:#0f172a;line-height:1} +.stat-label{font-size:13px;color:#64748b;margin-top:3px} +.stat-sub{font-size:12px;color:#22c55e;margin-top:2px} + +/* ── Admin Cards ─────────────────────────────────────────── */ +.acard{background:#fff;border:1px solid #e2e8f0;border-radius:11px;overflow:hidden;margin-bottom:18px;box-shadow:0 1px 3px rgba(0,0,0,.06)} +.acard-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #f8fafc} +.acard-head h2{font-size:14px;font-weight:600;color:#0f172a;margin:0} +.acard-body{padding:18px} + +.admin-two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:18px} +@media(max-width:900px){.admin-two-col{grid-template-columns:1fr}} + +/* ── Tables ──────────────────────────────────────────────── */ +.atable{width:100%;border-collapse:collapse;font-size:13px} +.atable thead tr{background:#f8fafc;border-bottom:1px solid #e2e8f0} +.atable th{padding:9px 15px;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;white-space:nowrap} +.atable td{padding:11px 15px;border-bottom:1px solid #f8fafc;color:#0f172a;vertical-align:middle} +.atable tr:last-child td{border-bottom:none} +.atable tbody tr:hover{background:#fafafa} +.atable a{color:var(--blue);text-decoration:none;font-weight:500} +.atable a:hover{text-decoration:underline} + +/* ── Status / role badges ────────────────────────────────── */ +.stag{display:inline-flex;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600} +.stag.active{background:#dcfce7;color:#166534} +.stag.suspended{background:#fee2e2;color:#991b1b} +.stag.silenced{background:#fef3c7;color:#92400e} + +/* ── Admin toolbar ───────────────────────────────────────── */ +.admin-toolbar{margin-bottom:18px} +.at-form{display:flex;gap:10px;align-items:center} +.at-fi{flex:1;max-width:380px;padding:8px 13px;border:1px solid #e2e8f0;border-radius:var(--r);font-size:14px;font-family:var(--font);outline:none;transition:border-color .18s} +.at-fi:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.1)} diff --git a/public/css/main.css b/public/css/main.css new file mode 100644 index 0000000..58b12e8 --- /dev/null +++ b/public/css/main.css @@ -0,0 +1,1165 @@ +/* ═══════════════════════════════════════════════════════════════ + NEXUS FORUM — main.css + Single canonical stylesheet. No duplicates. + ═══════════════════════════════════════════════════════════════ */ + +/* ── Variables ─────────────────────────────────────────────────── */ +:root { + --blue:#3b82f6; --blue-d:#2563eb; --blue-l:#eff6ff; + --green:#22c55e; --red:#ef4444; --amber:#f59e0b; --purple:#8b5cf6; + --bg:#f1f5f9; --surface:#fff; --border:#e2e8f0; --border-l:#f1f5f9; + --text:#0f172a; --muted:#64748b; --faint:#94a3b8; + --header:56px; --sidebar:210px; + --r:8px; --r-lg:12px; + --sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05); + --sh-md:0 4px 12px rgba(0,0,0,.1); + --sh-lg:0 10px 30px rgba(0,0,0,.12); + --font:'Inter',-apple-system,sans-serif; + --mono:'JetBrains Mono','Fira Code',monospace; +} + +/* ── Reset ─────────────────────────────────────────────────────── */ +*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; } +html { font-size:15px; scroll-behavior:smooth; } +body { font-family:var(--font); background:var(--bg); color:var(--text); line-height:1.6; -webkit-font-smoothing:antialiased; min-height:100vh; display:flex; flex-direction:column; } +a { color:var(--blue); text-decoration:none; } +a:hover { text-decoration:underline; } +img { max-width:100%; height:auto; } + +/* ── Header ─────────────────────────────────────────────────────── */ +.site-header { position:fixed; top:0; left:0; right:0; height:var(--header); background:var(--surface); border-bottom:1px solid var(--border); z-index:100; box-shadow:var(--sh); } +.hdr-inner { display:flex; align-items:center; gap:12px; height:100%; padding:0 16px; max-width:1440px; margin:0 auto; } +.hdr-left { display:flex; align-items:center; gap:10px; flex-shrink:0; } +.hdr-search { flex:1; max-width:460px; } +.hdr-right { display:flex; align-items:center; gap:8px; margin-left:auto; flex-shrink:0; } + +/* Burger */ +.burger { width:34px; height:34px; background:none; border:none; cursor:pointer; display:flex; flex-direction:column; justify-content:center; gap:5px; padding:4px; border-radius:6px; transition:background .18s; } +.burger:hover { background:var(--border-l); } +.burger span { display:block; height:2px; background:var(--muted); border-radius:2px; transition:all .25s; } +.burger.open span:nth-child(1) { transform:translateY(7px) rotate(45deg); } +.burger.open span:nth-child(2) { opacity:0; } +.burger.open span:nth-child(3) { transform:translateY(-7px) rotate(-45deg); } + +/* Logo */ +.logo { display:flex; align-items:center; gap:9px; color:var(--text); text-decoration:none; } +.logo:hover { text-decoration:none; } +.logo-mark { width:32px; height:32px; background:var(--blue); color:#fff; border-radius:8px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px; flex-shrink:0; } +.logo-name { font-size:16px; font-weight:600; white-space:nowrap; } +.logo-img { height:32px; width:auto; object-fit:contain; } + +/* Search */ +.search-wrap { position:relative; width:100%; } +.srch-icon { width:14px; height:14px; position:absolute; left:10px; top:50%; transform:translateY(-50%); color:var(--faint); pointer-events:none; } +#searchInput { width:100%; height:36px; border:1px solid var(--border); border-radius:18px; background:var(--bg); padding:0 12px 0 30px; font-size:14px; font-family:var(--font); outline:none; transition:all .18s; } +#searchInput:focus { background:var(--surface); border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.15); } +.search-results { position:absolute; top:calc(100% + 6px); left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:200; display:none; overflow:hidden; } +.search-results.show { display:block; } +.sr-item { display:block; padding:10px 14px; font-size:14px; color:var(--text); text-decoration:none; border-bottom:1px solid var(--border-l); transition:background .18s; } +.sr-item:last-child { border-bottom:none; } +.sr-item:hover { background:var(--bg); text-decoration:none; } +.sr-item small { display:block; font-size:12px; color:var(--muted); margin-top:1px; } + +/* ── Sidebar ─────────────────────────────────────────────────────── */ +.sb-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:90; backdrop-filter:blur(2px); } +.sb-overlay.show { display:block; } +.sidebar { position:fixed; top:var(--header); left:0; width:var(--sidebar); height:calc(100vh - var(--header)); background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; z-index:95; transition:transform .25s; scrollbar-width:thin; } +@media(max-width:900px) { .sidebar { transform:translateX(-100%); } .sidebar.open { transform:translateX(0); } } +.sidebar nav { padding:10px 8px; } +.nav-link { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:7px; font-size:14px; font-weight:500; color:var(--muted); text-decoration:none; transition:all .18s; margin-bottom:2px; } +.nav-link:hover { background:var(--blue-l); color:var(--blue); text-decoration:none; } +.new-link { background:var(--blue)!important; color:#fff!important; margin-bottom:8px; } +.new-link:hover { background:var(--blue-d)!important; } +.nav-sep { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); padding:10px 10px 4px; } +.cat-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; } + +/* ── Main layout ─────────────────────────────────────────────────── */ +.main { margin-top:var(--header); margin-left:var(--sidebar); flex:1; } +@media(max-width:900px) { .main { margin-left:0; } .sidebar { transform:translateX(-100%); } } +.wrap { max-width:1100px; margin:0 auto; padding:28px 24px; } +@media(max-width:600px) { .wrap { padding:16px; } } + +/* ── Footer ─────────────────────────────────────────────────────── */ +.site-footer { margin-left:var(--sidebar); background:var(--surface); border-top:1px solid var(--border); padding:14px 24px; font-size:13px; color:var(--muted); } +@media(max-width:900px) { .site-footer { margin-left:0; } } +.footer-inner { max-width:1100px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; } +.footer-stats { display:flex; align-items:center; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; } +.footer-stats strong { color:var(--text); } +.fs-div { color:var(--faint); } +.footer-right { display:flex; align-items:center; gap:14px; font-size:13px; color:var(--muted); } + +/* ── Dropdowns ───────────────────────────────────────────────────── */ +.notif-wrap,.user-wrap { position:relative; } +.icon-btn { width:36px; height:36px; display:flex; align-items:center; justify-content:center; background:none; border:none; border-radius:50%; cursor:pointer; color:var(--muted); position:relative; transition:all .18s; } +.icon-btn:hover { background:var(--border-l); color:var(--text); } +.badge-dot { position:absolute; top:1px; right:1px; background:var(--red); color:#fff; font-size:9px; font-weight:700; border-radius:10px; padding:1px 4px; min-width:16px; text-align:center; line-height:14px; } +.avatar-btn { width:36px; height:36px; border-radius:50%; border:2px solid var(--border); background:none; cursor:pointer; padding:0; overflow:hidden; transition:border-color .18s; } +.avatar-btn:hover { border-color:var(--blue); } +.notif-drop,.user-drop { position:absolute; right:0; top:calc(100% + 8px); background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--sh-lg); z-index:200; display:none; min-width:200px; animation:dropIn .18s ease; } +.notif-drop.show,.user-drop.show { display:block; } +.notif-drop { min-width:300px; right:-60px; } +@keyframes dropIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} } +.notif-head { display:flex; justify-content:space-between; align-items:center; padding:12px 16px; border-bottom:1px solid var(--border-l); font-weight:600; font-size:14px; } +.link-btn { background:none; border:none; color:var(--blue); font-size:12px; cursor:pointer; padding:2px 6px; font-family:var(--font); } +.link-btn:hover { text-decoration:underline; } +#notifList { max-height:320px; overflow-y:auto; } +.notif-item { display:block; padding:11px 16px; border-bottom:1px solid var(--border-l); font-size:13px; color:var(--text); text-decoration:none; transition:background .18s; } +.notif-item:hover { background:var(--bg); } +.notif-item.unread { background:var(--blue-l); } +.notif-item-time { font-size:11px; color:var(--faint); margin-top:2px; } +.notif-empty { padding:20px; text-align:center; color:var(--muted); font-size:13px; } +.user-drop-top { padding:12px 16px 8px; border-bottom:1px solid var(--border-l); display:flex; align-items:center; gap:8px; } +.user-drop a { display:flex; align-items:center; padding:9px 16px; font-size:14px; color:var(--text); text-decoration:none; transition:background .18s; } +.user-drop a:hover { background:var(--bg); } +.admin-lnk { color:var(--blue)!important; } +.logout-lnk { color:var(--red)!important; } +.drop-div { height:1px; background:var(--border-l); margin:4px 0; } + +/* ── Buttons ─────────────────────────────────────────────────────── */ +.btn-primary { display:inline-flex; align-items:center; gap:6px; padding:8px 18px; background:var(--blue); color:#fff; border:none; border-radius:var(--r); font-size:14px; font-weight:500; font-family:var(--font); cursor:pointer; text-decoration:none; transition:all .18s; white-space:nowrap; } +.btn-primary:hover { background:var(--blue-d); color:#fff; text-decoration:none; transform:translateY(-1px); } +.btn-ghost { display:inline-flex; align-items:center; gap:6px; padding:7px 16px; background:transparent; color:var(--muted); border:1px solid var(--border); border-radius:var(--r); font-size:14px; font-weight:500; font-family:var(--font); cursor:pointer; text-decoration:none; transition:all .18s; } +.btn-ghost:hover { border-color:var(--blue); color:var(--blue); background:var(--blue-l); text-decoration:none; } +.btn-lg { padding:11px 24px; font-size:15px; } +.btn-sm { padding:5px 12px; font-size:13px; } +.btn-block { width:100%; justify-content:center; } +.btn-warn { background:var(--amber); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); } +.btn-ok { background:var(--green); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); } +.btn-danger { background:var(--red); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); } + +/* ── Forms ───────────────────────────────────────────────────────── */ +.fg { margin-bottom:16px; } +.fg label { display:block; font-size:14px; font-weight:500; margin-bottom:5px; } +.fi { width:100%; padding:9px 12px; border:1px solid var(--border); border-radius:var(--r); font-size:14px; font-family:var(--font); color:var(--text); background:var(--surface); outline:none; transition:all .18s; } +.fi:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.12); } +textarea.fi { resize:vertical; min-height:80px; } +select.fi { cursor:pointer; } +.req { color:var(--red); } +.hint { font-size:12px; color:var(--faint); margin-top:3px; display:block; } +.form-actions { display:flex; gap:12px; margin-top:20px; flex-wrap:wrap; } +.form-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:28px; box-shadow:var(--sh); max-width:760px; } +.form-card h1 { font-size:1.4rem; font-weight:700; margin-bottom:20px; } +.form-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:20px; margin-bottom:18px; box-shadow:var(--sh); } +.form-section h2 { font-size:15px; font-weight:600; margin-bottom:14px; } +.pw-row { position:relative; } +.pw-eye { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; font-size:16px; } +.pw-bar { height:4px; background:var(--border); border-radius:2px; margin-top:6px; overflow:hidden; } +.pw-fill { height:100%; width:0; border-radius:2px; transition:all .3s; } +.tag-preview { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; min-height:22px; } + +/* ── Alerts ─────────────────────────────────────────────────────── */ +.alert { padding:12px 16px; border-radius:var(--r); font-size:14px; margin-bottom:18px; } +.alert.err,.alert-err { background:#fef2f2; border:1px solid #fecaca; color:#991b1b; } +.alert.ok { background:#f0fdf4; border:1px solid #bbf7d0; color:#166534; } +.alert.warn{ background:#fffbeb; border:1px solid #fde68a; color:#92400e; } + +/* ── Avatars ─────────────────────────────────────────────────────── */ +.av-sm { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; } +.av-md { width:42px; height:42px; border-radius:50%; object-fit:cover; flex-shrink:0; } +.av-lg { width:54px; height:54px; border-radius:50%; object-fit:cover; flex-shrink:0; } +.av-xl { width:76px; height:76px; border-radius:50%; object-fit:cover; flex-shrink:0; } +.av-init { background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%); color:#fff; font-weight:700; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; } +.av-sm.av-init { width:34px; height:34px; font-size:14px; } +.av-md.av-init { width:42px; height:42px; font-size:16px; } +.av-lg.av-init { width:54px; height:54px; font-size:20px; } +.av-xl.av-init { width:76px; height:76px; font-size:28px; } +.av-xs { width:22px; height:22px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; background:var(--blue); color:#fff; font-size:10px; font-weight:700; } + +/* ── Role tags ───────────────────────────────────────────────────── */ +.role-tag { display:inline-flex; padding:1px 7px; border-radius:10px; font-size:11px; font-weight:600; text-transform:capitalize; } +.role-admin { background:#fee2e2; color:#991b1b; } +.role-moderator { background:#dbeafe; color:#1e40af; } +.role-member { background:#f1f5f9; color:#64748b; } +.role-flair { font-size:9px; font-weight:700; padding:1px 5px; border-radius:4px; text-transform:uppercase; } +.role-flair.admin { background:var(--red); color:#fff; } +.role-flair.mod { background:var(--blue); color:#fff; } + +/* ── Tags / pills ────────────────────────────────────────────────── */ +.tag { display:inline-flex; align-items:center; padding:2px 8px; background:#f1f5f9; color:var(--muted); border-radius:10px; font-size:11px; font-weight:500; border:1px solid var(--border); } +.cat-tag { display:inline-flex; align-items:center; padding:2px 7px; border-radius:10px; font-size:11px; font-weight:500; background:color-mix(in srgb,var(--cc,var(--blue)) 15%,transparent); color:var(--cc,var(--blue)); border:1px solid color-mix(in srgb,var(--cc,var(--blue)) 30%,transparent); text-decoration:none; } +.cat-tag:hover { text-decoration:none; filter:brightness(.9); } + +/* ── Home page ───────────────────────────────────────────────────── */ +.home-hero { background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 60%,#1e3a8a 100%); border-radius:var(--r-lg); padding:44px 36px; margin-bottom:22px; color:#fff; position:relative; overflow:hidden; } +.home-hero::before { content:''; position:absolute; inset:0; background:url("data:image/svg+xml,%3Csvg width='60' height='60' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='7' cy='7' r='7' fill='%23fff' fill-opacity='.04'/%3E%3C/svg%3E"); pointer-events:none; } +.home-hero h1 { font-size:2.2rem; font-weight:700; margin-bottom:8px; position:relative; } +.home-hero p { font-size:1.05rem; opacity:.85; margin-bottom:20px; position:relative; } +.hero-btns { display:flex; gap:12px; flex-wrap:wrap; position:relative; } +.hero-btns .btn-primary { background:#fff; color:var(--blue); } +.hero-btns .btn-primary:hover { background:rgba(255,255,255,.9); } +.hero-btns .btn-ghost { color:#fff; border-color:rgba(255,255,255,.5); } +.hero-btns .btn-ghost:hover { background:rgba(255,255,255,.15); border-color:#fff; color:#fff; } +.stats-row { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); display:flex; align-items:center; padding:14px 20px; margin-bottom:22px; gap:4px; flex-wrap:wrap; box-shadow:var(--sh); } +.stat { display:flex; align-items:center; gap:8px; padding:2px 12px; font-size:13px; color:var(--muted); } +.stat strong { font-size:1.25rem; font-weight:700; color:var(--text); } +.stat-div { width:1px; height:28px; background:var(--border); } +.home-grid { display:grid; grid-template-columns:3fr 2fr; gap:22px; } +@media(max-width:860px) { .home-grid { grid-template-columns:1fr; } } +.sec-title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; margin-bottom:14px; padding-bottom:10px; border-bottom:2px solid var(--border-l); } + +/* ── Category cards ──────────────────────────────────────────────── */ +.cat-card { display:flex; align-items:center; gap:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:15px 16px; text-decoration:none; color:var(--text); transition:all .18s; position:relative; overflow:hidden; box-shadow:var(--sh); margin-bottom:8px; } +.cat-card:hover { border-color:var(--blue); transform:translateY(-1px); box-shadow:var(--sh-md); text-decoration:none; color:var(--text); } +.cat-stripe { position:absolute; left:0; top:0; bottom:0; width:4px; } +.cat-icon { font-size:22px; flex-shrink:0; } +.cat-body { flex:1; min-width:0; } +.cat-name { font-weight:600; font-size:14px; margin-bottom:2px; } +.cat-desc { font-size:12px; color:var(--muted); margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.cat-stats { font-size:11px; color:var(--faint); } +.cat-chevron { font-size:18px; color:var(--faint); flex-shrink:0; } +.empty-msg { color:var(--muted); font-size:14px; padding:20px 0; } + +/* ── Topic rows ──────────────────────────────────────────────────── */ +.topic-row { display:flex; align-items:flex-start; gap:10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:12px 14px; margin-bottom:6px; transition:all .18s; box-shadow:var(--sh); } +.topic-row:hover { border-color:var(--blue); transform:translateY(-1px); box-shadow:var(--sh-md); } +.tr-body { flex:1; min-width:0; } +.tr-title { font-weight:500; font-size:14px; color:var(--text); display:block; margin-bottom:4px; } +.tr-title:hover { color:var(--blue); text-decoration:none; } +.tr-meta { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap; } +.tr-counts { display:flex; flex-direction:column; align-items:flex-end; gap:2px; font-size:12px; color:var(--faint); flex-shrink:0; } + +/* ── Category page ───────────────────────────────────────────────── */ +.bc { display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); margin-bottom:16px; flex-wrap:wrap; } +.bc a { color:var(--muted); } .bc a:hover { color:var(--blue); } +.cat-hdr { display:flex; align-items:center; gap:16px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:22px 24px; margin-bottom:18px; box-shadow:var(--sh); flex-wrap:wrap; } +.cat-hdr-icon { font-size:2.2rem; } +.cat-hdr h1 { font-size:1.4rem; font-weight:700; } +.cat-hdr p { color:var(--muted); margin-top:3px; font-size:14px; } +.cat-hdr-meta { font-size:12px; color:var(--faint); margin-top:6px; } +.subcats { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; } +.subcat { display:inline-flex; align-items:center; gap:6px; padding:5px 13px; background:var(--surface); border:1px solid; border-radius:18px; font-size:13px; font-weight:500; text-decoration:none; color:var(--text); transition:all .18s; } +.subcat:hover { transform:translateY(-1px); text-decoration:none; } +.topic-list { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); } +.tl-hdr { display:grid; grid-template-columns:1fr 80px 70px 100px; padding:10px 18px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--faint); border-bottom:1px solid var(--border); background:var(--bg); } +.tl-row { display:grid; grid-template-columns:1fr 80px 70px 100px; padding:13px 18px; border-bottom:1px solid var(--border-l); align-items:center; transition:background .18s; } +.tl-row:last-child { border-bottom:none; } +.tl-row:hover { background:var(--bg); } +.tl-row.is-pinned { background:#fffbeb; } +.tl-main { display:flex; align-items:flex-start; gap:10px; min-width:0; } +.tl-title { display:flex; align-items:center; gap:5px; flex-wrap:wrap; margin-bottom:3px; } +.tl-title a { font-weight:500; font-size:14px; color:var(--text); } +.tl-title a:hover { color:var(--blue); text-decoration:none; } +.tl-meta { font-size:12px; color:var(--muted); } +.tl-r { text-align:right; font-size:13px; color:var(--muted); } +.empty-state { padding:44px 24px; text-align:center; } +.empty-state p { color:var(--muted); margin-bottom:14px; } + +/* ── Topic page ──────────────────────────────────────────────────── */ +.topic-hdr { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:22px 24px; margin-bottom:20px; display:flex; align-items:flex-start; gap:16px; box-shadow:var(--sh); flex-wrap:wrap; } +.topic-hdr-main { flex:1; } +.topic-badges { display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap; } +.tbadge { display:inline-flex; align-items:center; padding:3px 10px; border-radius:10px; font-size:12px; font-weight:600; } +.tbadge.pin { background:#fef3c7; color:#92400e; } +.tbadge.closed { background:#fee2e2; color:#991b1b; } +.topic-title { font-size:1.5rem; font-weight:700; margin-bottom:10px; letter-spacing:-.02em; line-height:1.3; } +.topic-meta { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; color:var(--muted); } +.topic-mod { display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start; flex-shrink:0; } + +/* ── Posts ───────────────────────────────────────────────────────── */ +.post { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); display:flex; overflow:hidden; margin-bottom:6px; box-shadow:var(--sh); transition:border-color .18s; } +.post:hover { border-color:#c7d3e4; } +.post-op { border-left:3px solid var(--blue)!important; } +.post-side { width:86px; flex-shrink:0; padding:18px 10px; background:var(--bg); border-right:1px solid var(--border-l); display:flex; flex-direction:column; align-items:center; gap:5px; text-align:center; } +.post-name { font-size:11px; font-weight:600; color:var(--text); text-decoration:none; word-break:break-all; } +.post-name:hover { color:var(--blue); } +.post-pcnt { font-size:10px; color:var(--faint); } +.post-location { display:flex; align-items:center; justify-content:center; gap:2px; font-size:10px; color:var(--faint); margin-top:1px; max-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.post-karma { font-size:11px; color:var(--faint); margin-top:2px; } +.post-karma-badge { display:inline-flex; align-items:center; gap:3px; margin-top:4px; padding:3px 7px; background:rgba(34,197,94,.12); border:1px solid rgba(34,197,94,.3); border-radius:10px; font-size:11px; font-weight:700; color:#16a34a; } +.pkb-icon { font-size:10px; display:inline-flex; align-items:center; } +.pkb-val { font-family:var(--mono); letter-spacing:.01em; } +.post-body { flex:1; min-width:0; padding:15px 18px; } +.post-meta-bar { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding-bottom:10px; border-bottom:1px solid var(--border-l); flex-wrap:wrap; } +.pnum { font-size:12px; color:var(--faint); font-weight:600; } +.edit-lbl { font-size:12px; color:var(--faint); font-style:italic; } +.post-acts { display:flex; align-items:center; gap:4px; margin-left:auto; flex-wrap:wrap; } +.pa-btn { display:inline-flex; align-items:center; gap:4px; padding:3px 9px; background:none; border:1px solid var(--border); border-radius:18px; font-size:12px; cursor:pointer; color:var(--muted); transition:all .18s; font-family:var(--font); } +.pa-btn:hover { border-color:var(--blue); color:var(--blue); background:var(--blue-l); } +.pa-btn.liked { color:var(--red)!important; border-color:var(--red)!important; background:#fef2f2!important; } +.pa-btn.del:hover { color:var(--red)!important; border-color:var(--red)!important; background:#fef2f2!important; } +.reply-ref { font-size:12px; color:var(--muted); background:var(--bg); padding:4px 10px; border-radius:6px; margin-bottom:10px; border-left:2px solid var(--blue); display:inline-flex; align-items:center; gap:5px; } + +/* ── Post content (rendered markdown) ────────────────────────────── */ +.post-content,.rendered-post { line-height:1.75; font-size:15px; word-wrap:break-word; overflow-wrap:break-word; } +.rendered-post h1,.rendered-post h2,.rendered-post h3,.rendered-post h4 { font-weight:600; margin:1.1em 0 .5em; line-height:1.3; } +.rendered-post h1{font-size:1.5em} .rendered-post h2{font-size:1.3em} .rendered-post h3{font-size:1.1em} +.rendered-post p,.post-content p { margin-bottom:.9em; } +.rendered-post p:last-child,.post-content p:last-child { margin-bottom:0; } +.rendered-post ul,.rendered-post ol,.post-content ul,.post-content ol { padding-left:1.5em; margin-bottom:.9em; } +.rendered-post li,.post-content li { margin-bottom:.25em; } +/* ── Blockquote ── dark-green circle + light-green background ── */ +.rendered-post blockquote, +.post-content blockquote, +blockquote.post-quote { + position: relative; + margin: 12px 0; + padding: 12px 16px 12px 44px; /* left room for the circle */ + background: #f0fdf4; /* very light green */ + border: 1px solid #bbf7d0; + border-radius: var(--r-lg); + color: #166534; /* dark green text */ + font-style: normal; +} +/* Dark green filled circle on the left */ +.rendered-post blockquote::before, +.post-content blockquote::before, +blockquote.post-quote::before { + content: ''; + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + width: 10px; + height: 10px; + background: #16a34a; /* dark green circle */ + border-radius: 50%; + flex-shrink: 0; +} +.rendered-post blockquote p, +.post-content blockquote p, +blockquote.post-quote p { + margin: 0 0 4px; + color: #166534; +} +.rendered-post blockquote p:last-child, +.post-content blockquote p:last-child, +blockquote.post-quote p:last-child { margin-bottom: 0; } +.rendered-post blockquote strong, +.post-content blockquote strong { color: #14532d; } +.rendered-post blockquote em, +.post-content blockquote em { color: #166534; } +[data-theme="dark"] .rendered-post blockquote, +[data-theme="dark"] .post-content blockquote, +[data-theme="dark"] blockquote.post-quote { + background: rgba(22,163,74,.08); + border-color: rgba(22,163,74,.25); + color: #86efac; +} +[data-theme="dark"] .rendered-post blockquote::before, +[data-theme="dark"] .post-content blockquote::before, +[data-theme="dark"] blockquote.post-quote::before { + background: #4ade80; +} +[data-theme="dark"] .rendered-post blockquote p, +[data-theme="dark"] .post-content blockquote p, +[data-theme="dark"] blockquote.post-quote p { color: #86efac; } + +/* ── Inline code ─────────────────────────────────────────────── */ +.inline-code, +.rendered-post :not(pre) > code, +.post-content :not(pre) > code { + background: #f1f0ff; + color: #7c3aed; + border: 1px solid #ddd6fe; + padding: 1px 6px; + border-radius: 5px; + font-family: var(--mono); + font-size: .88em; + font-style: normal; + white-space: nowrap; + font-weight: 500; +} + +/* ── Code block wrapper ──────────────────────────────────────── */ +.rendered-post pre, +/* ───────────────────────────────────────────────────────────────── + CODE BLOCKS + Wrapper: .code-block-wrap Header: .cb-header Block: pre.code-block + ─────────────────────────────────────────────────────────────────── */ + +/* ── Code block ── purple circle + light-purple background ─────── */ +.code-block-wrap { + position: relative; + margin: 12px 0; + padding: 0; /* inner padding handled by code element */ + background: #faf5ff; /* very light purple */ + border: 1px solid #ddd6fe; + border-radius: var(--r-lg); + overflow: hidden; +} +/* Purple filled circle — same circle concept as blockquote */ +.code-block-wrap::before { + content: ''; + position: absolute; + left: 14px; + top: 14px; + width: 10px; + height: 10px; + background: #7c3aed; /* dark purple circle */ + border-radius: 50%; + z-index: 1; + pointer-events: none; +} + +/* Header bar — language label + copy button */ +.cb-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #f3e8ff; /* slightly deeper purple tint */ + border-bottom: 1px solid #ddd6fe; + padding: 6px 10px 6px 34px; /* 34px left to clear the circle */ + gap: 8px; + user-select: none; + min-height: 34px; +} +/* No-lang header: circle still shows, just copy button on right */ +.cb-header-nolang { + justify-content: flex-end; + padding: 5px 10px 5px 34px; + min-height: 30px; +} + +/* Language label */ +.cb-lang { + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + color: #6d28d9; + text-transform: uppercase; + letter-spacing: .08em; +} + +/* Copy button */ +.cb-copy { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + background: transparent; + border: 1px solid #c4b5fd; + border-radius: 5px; + font-size: 11px; + font-family: var(--font); + font-weight: 500; + color: #7c3aed; + cursor: pointer; + transition: all .15s; + white-space: nowrap; + flex-shrink: 0; +} +.cb-copy:hover { + background: #ede9fe; + border-color: #a78bfa; + color: #6d28d9; +} +.cb-copy.copied { + border-color: #16a34a; + color: #16a34a; + background: #f0fdf4; +} +.cb-copy svg { flex-shrink: 0; } + +/* The <pre> block — light purple background, dark purple text */ +pre.code-block { + background: #faf5ff; + color: #3b1c6e; + margin: 0; + padding: 0; + overflow: hidden; + font-family: var(--mono); + font-size: 13.5px; + line-height: 1.7; + tab-size: 4; + -moz-tab-size: 4; +} + +/* Scrollable code area — preserves ALL whitespace exactly */ +pre.code-block code { + display: block; + padding: 14px 16px 14px 34px; /* 34px left = past the circle */ + overflow-x: auto; + background: transparent; + color: inherit; + font-size: inherit; + font-family: inherit; + line-height: inherit; + white-space: pre; + word-break: normal; + overflow-wrap: normal; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: #c4b5fd transparent; +} +pre.code-block code::-webkit-scrollbar { height: 5px; } +pre.code-block code::-webkit-scrollbar-track { background: transparent; } +pre.code-block code::-webkit-scrollbar-thumb { background: #c4b5fd; border-radius: 3px; } + +/* Inline code */ +.inline-code, +.post-content :not(pre) > code, +.rendered-post :not(pre) > code { + font-family: var(--mono); + font-size: .88em; + background: #f5f3ff; + color: #7c3aed; + border: 1px solid #ddd6fe; + border-radius: 4px; + padding: 1px 6px; + white-space: nowrap; + font-weight: 500; +} + +/* Dark mode */ +[data-theme="dark"] .code-block-wrap { + background: #1e1334; + border-color: #3d2a6b; +} +[data-theme="dark"] .code-block-wrap::before { + background: #a78bfa; +} +[data-theme="dark"] .cb-header { + background: #2a1a4e; + border-color: #3d2a6b; +} +[data-theme="dark"] .cb-header-nolang { + background: #2a1a4e; +} +[data-theme="dark"] .cb-lang { color: #c4b5fd; } +[data-theme="dark"] .cb-copy { + border-color: #4c3a8a; + color: #c4b5fd; +} +[data-theme="dark"] .cb-copy:hover { + background: rgba(167,139,250,.1); + border-color: #7c3aed; + color: #ddd6fe; +} +[data-theme="dark"] pre.code-block { + background: #1e1334; + color: #e9d5ff; +} +[data-theme="dark"] pre.code-block code { + scrollbar-color: #4c3a8a transparent; +} +[data-theme="dark"] .inline-code, +[data-theme="dark"] .post-content :not(pre) > code, +[data-theme="dark"] .rendered-post :not(pre) > code { + background: #2d1f5e; + border-color: #4c3a8a; + color: #c4b5fd; +} + + +/* ── Syntax token colors (GitHub Dark palette) ───────────────── */ +/* These work standalone AND override Prism's injected theme */ + +/* Comments */ +pre.code-block /* Prism.js token colors — tuned for light purple background */ +pre.code-block .token.comment, +pre.code-block .token.prolog, +pre.code-block .token.cdata { color: #6b7280; font-style: italic; } + +pre.code-block .token.keyword, +pre.code-block .token.rule, +pre.code-block .token.atrule { color: #7c3aed; font-weight: 600; } + +pre.code-block .token.string, +pre.code-block .token.attr-value, +pre.code-block .token.char { color: #0369a1; } + +pre.code-block .token.number, +pre.code-block .token.boolean, +pre.code-block .token.constant { color: #b45309; } + +pre.code-block .token.function, +pre.code-block .token.function-name { color: #6d28d9; font-weight: 600; } + +pre.code-block .token.class-name, +pre.code-block .token.namespace { color: #0f766e; } + +pre.code-block .token.tag, +pre.code-block .token.selector { color: #9333ea; } + +pre.code-block .token.attr-name { color: #7c3aed; } + +pre.code-block .token.punctuation { color: #6b7280; } + +pre.code-block .token.operator { color: #7c3aed; } + +pre.code-block .token.variable, +pre.code-block .token.parameter { color: #92400e; } + +pre.code-block .token.builtin { color: #0369a1; } + +/* Dark mode token colors — dark purple background */ +[data-theme="dark"] pre.code-block .token.comment, +[data-theme="dark"] pre.code-block .token.prolog { color: #9ca3af; font-style: italic; } +[data-theme="dark"] pre.code-block .token.keyword { color: #c084fc; font-weight: 600; } +[data-theme="dark"] pre.code-block .token.string { color: #93c5fd; } +[data-theme="dark"] pre.code-block .token.number { color: #fcd34d; } +[data-theme="dark"] pre.code-block .token.function { color: #d8b4fe; font-weight: 600; } +[data-theme="dark"] pre.code-block .token.class-name { color: #6ee7b7; } +[data-theme="dark"] pre.code-block .token.tag { color: #f0abfc; } +[data-theme="dark"] pre.code-block .token.operator { color: #c084fc; } +[data-theme="dark"] pre.code-block .token.punctuation { color: #9ca3af; } +/* ── Edit box ────────────────────────────────────────────────────── */ +.edit-box { display:none; margin-top:14px; border-top:1px solid var(--border-l); padding-top:14px; } +.edit-box.visible { display:block; } +.edit-ta { width:100%; min-height:120px; padding:10px 13px; border:1px solid var(--border); border-radius:var(--r); font-family:var(--mono); font-size:13px; resize:vertical; outline:none; transition:border-color .18s; } +.edit-ta:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.1); } +.edit-btns { display:flex; gap:8px; margin-top:10px; justify-content:flex-end; } + +/* ═══════════════════════════════════════════════════════════════ + POST EDITOR (v19 reply-box design) + ═══════════════════════════════════════════════════════════════ */ + +.reply-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh-md); margin-top:22px; } +.reply-hdr { display:flex; align-items:center; gap:12px; padding:12px 18px; border-bottom:1px solid var(--border-l); background:var(--bg); font-size:14px; color:var(--muted); } +.ed-toolbar { display:flex; align-items:center; gap:2px; padding:8px 12px; background:var(--bg); border-bottom:1px solid var(--border-l); flex-wrap:wrap; } +.ed-toolbar button { width:30px; height:30px; display:flex; align-items:center; justify-content:center; background:none; border:none; border-radius:6px; font-size:13px; cursor:pointer; color:var(--muted); transition:all .18s; font-family:var(--font); } +.ed-toolbar button:hover { background:var(--surface); color:var(--text); } +.ed-toolbar button#prevBtn.on { background:var(--blue-l); color:var(--blue); } +.ed-sep { width:1px; height:20px; background:var(--border); margin:0 4px; } +.ed-panes { position:relative; } +.reply-ta { width:100%; min-height:130px; padding:15px 18px; border:none; outline:none; font-family:var(--mono); font-size:14px; line-height:1.7; resize:vertical; color:var(--text); background:var(--surface); } +.reply-ta.dragging { background:var(--blue-l); } +.reply-preview { min-height:130px; padding:15px 18px; font-size:15px; line-height:1.75; } +.reply-footer { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; border-top:1px solid var(--border-l); background:var(--bg); flex-wrap:wrap; gap:8px; } +.reply-footer-right { display:flex; align-items:center; gap:10px; } +.cnt { font-size:12px; color:var(--faint); } +.hidden { display:none!important; } +.reply-cta { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:28px; text-align:center; margin-top:22px; display:flex; align-items:center; justify-content:center; gap:12px; flex-wrap:wrap; color:var(--muted); box-shadow:var(--sh); } +.reply-cta p { width:100%; margin:0 0 8px; font-weight:500; color:var(--text); } +.closed-notice { background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r); padding:14px 18px; text-align:center; color:#92400e; margin-top:22px; font-size:14px; font-weight:500; } + + +/* ═══════════════════════════════════════════════════════════════ + HORIZONTAL POST LAYOUT + ═══════════════════════════════════════════════════════════════ */ +.posts-horizontal .post { + flex-direction: column; +} +.posts-horizontal .post-side { + width: 100%; + flex-direction: row; + border-right: none; + border-bottom: 1px solid var(--border-l); + padding: 10px 16px; + gap: 10px; + justify-content: flex-start; + align-items: center; + background: var(--bg); +} +.posts-horizontal .post-side .av-lg { + width: 32px; height: 32px; + flex-shrink: 0; +} +.posts-horizontal .post-name { + font-size: 13px; +} +.posts-horizontal .post-pcnt, +.posts-horizontal .post-location { + font-size: 11px; + white-space: nowrap; +} +.posts-horizontal .post-karma-badge { + margin-top: 0; +} +.posts-horizontal .post-side .role-flair { + margin: 0; +} +.posts-horizontal .post-body { + padding: 14px 18px; +} + +/* ── Rate limit / Post captcha ───────────────────────────────────── */ +.rate-limit-info { margin-bottom:10px; } +.rate-limit-bar { display:flex; align-items:center; gap:12px; background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r); padding:10px 14px; font-size:13px; color:#92400e; } +.rate-countdown { display:inline-flex; align-items:center; justify-content:center; background:#f59e0b; color:#fff; font-weight:700; font-size:15px; border-radius:50%; width:38px; height:38px; flex-shrink:0; font-family:var(--mono); } +.post-captcha-row { display:flex; align-items:center; gap:12px; background:#f0fdf4; border:1px solid #bbf7d0; border-radius:var(--r); padding:10px 14px; margin:8px 16px; flex-wrap:wrap; } +.post-captcha-label { font-size:13px; font-weight:500; color:#166534; white-space:nowrap; } +.post-captcha-label .captcha-q { font-size:16px; font-family:var(--mono); color:#15803d; } +.post-captcha-inp { max-width:80px!important; font-size:16px!important; text-align:center; font-family:var(--mono)!important; padding:6px 8px!important; } + +/* ── Captcha (registration) ──────────────────────────────────────── */ +.captcha-box { background:#f8fafc; border:2px solid var(--blue); border-radius:var(--r); padding:16px 18px; margin-bottom:16px; } +.captcha-label { font-size:14px; font-weight:500; color:var(--text); margin-bottom:8px; display:block; } +.captcha-q { font-size:18px; color:var(--blue); font-family:var(--mono); letter-spacing:.06em; } +.captcha-input { max-width:100px!important; font-size:18px!important; text-align:center; font-family:var(--mono)!important; } + +/* ── Media embeds ────────────────────────────────────────────────── */ +.embed-wrap { position:relative; padding-bottom:56.25%; height:0; overflow:hidden; border-radius:var(--r); margin:12px 0; background:#000; box-shadow:var(--sh-md); } +.embed-wrap iframe,.embed-yt { position:absolute; top:0; left:0; width:100%; height:100%; border:none; border-radius:var(--r); } +.embed-wrap[style*="height"] { padding-bottom:0; } +.embed-wrap[style*="height"] iframe { position:absolute; } +.embed-spotify { margin:12px 0; border-radius:var(--r); overflow:hidden; box-shadow:var(--sh); } +.embed-spotify iframe { display:block; border:none; border-radius:var(--r); } +.embed-sc { margin:12px 0; } +.embed-sc iframe { border-radius:var(--r); border:none; display:block; } +.embed-tweet { margin:12px 0; min-height:50px; } +.tweet-fallback { display:inline-flex; align-items:center; gap:8px; padding:10px 16px; background:#1d9bf0; color:#fff; border-radius:var(--r); font-size:14px; font-weight:500; text-decoration:none; transition:background .18s; } +.tweet-fallback:hover { background:#1a8cd8; text-decoration:none; color:#fff; } + +/* ── Pagination / breadcrumb ─────────────────────────────────────── */ +.pager { display:flex; align-items:center; justify-content:center; gap:6px; padding:22px 0; flex-wrap:wrap; } +.pg-btn { display:inline-flex; align-items:center; justify-content:center; min-width:36px; height:36px; padding:0 10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); font-size:14px; color:var(--muted); text-decoration:none; transition:all .18s; } +.pg-btn:hover { border-color:var(--blue); color:var(--blue); text-decoration:none; } +.pg-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; } + +/* ── Auth pages ──────────────────────────────────────────────────── */ +.auth-body { background:linear-gradient(135deg,#eff6ff,#dbeafe); margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; flex-direction:column; padding:24px; } +.auth-page { width:100%; max-width:430px; } +.auth-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:32px; box-shadow:var(--sh-lg); } +.auth-logo { display:flex; align-items:center; gap:10px; margin-bottom:22px; justify-content:center; text-decoration:none; color:var(--text); font-size:18px; font-weight:700; } +.auth-logo:hover { text-decoration:none; } +.auth-card h1 { font-size:1.4rem; font-weight:700; text-align:center; margin-bottom:4px; } +.auth-sub { text-align:center; color:var(--muted); font-size:14px; margin-bottom:22px; } +.auth-foot { text-align:center; margin-top:18px; font-size:14px; color:var(--muted); } +.auth-back { text-align:center; margin-top:16px; font-size:13px; } +.auth-back a { color:var(--muted); } +.auth-terms { font-size:11px; color:var(--faint); text-align:center; margin-top:10px; } + +/* ── Profile page ────────────────────────────────────────────────── */ +.profile-hdr { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:24px; display:flex; align-items:flex-start; gap:18px; margin-bottom:22px; flex-wrap:wrap; box-shadow:var(--sh); } +.profile-info { flex:1; min-width:200px; } +.profile-name-row { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; } +.profile-name-row h1 { font-size:1.4rem; font-weight:700; } +.profile-bio { color:var(--muted); font-size:14px; margin-bottom:10px; } +.profile-meta { display:flex; gap:14px; font-size:13px; color:var(--muted); flex-wrap:wrap; } +.profile-stats { display:flex; gap:18px; flex-shrink:0; } +.pstat { text-align:center; } +.pstat strong { display:block; font-size:1.4rem; font-weight:700; } +.pstat span { font-size:12px; color:var(--muted); } +.pstat.karma-stat strong { color:var(--kc,#f59e0b); font-size:1.6rem; } +.profile-actions { display:flex; flex-wrap:wrap; gap:8px; align-self:flex-start; } +.profile-tabs { display:flex; gap:4px; margin-bottom:14px; border-bottom:2px solid var(--border); } +.tab-btn { padding:9px 18px; background:none; border:none; font-family:var(--font); font-size:14px; font-weight:500; color:var(--muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all .18s; display:inline-flex; align-items:center; } +.tab-btn:hover { color:var(--blue); } +.tab-btn.active { color:var(--blue); border-bottom-color:var(--blue); } +.tab-count { display:inline-flex; align-items:center; justify-content:center; background:var(--border); color:var(--muted); border-radius:10px; font-size:11px; font-weight:600; padding:1px 7px; margin-left:4px; } +.tab-btn.active .tab-count { background:var(--blue); color:#fff; } +.tab-pane.hidden { display:none; } +.reply-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:13px 16px; margin-bottom:8px; } +.rc-top { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-bottom:8px; padding-bottom:8px; border-bottom:1px solid var(--border-l); } +.rc-body { font-size:13px; color:var(--muted); max-height:80px; overflow:hidden; } +.av-upload { display:flex; align-items:center; gap:18px; } + +/* ── Karma tier badge (profile avatar) ───────────────────────────── */ +.profile-tier-badge { display:inline-flex; align-items:center; gap:4px; margin-top:8px; padding:4px 10px; background:color-mix(in srgb,var(--tc,#f59e0b) 15%,transparent); border:1px solid color-mix(in srgb,var(--tc,#f59e0b) 40%,transparent); border-radius:20px; font-size:12px; font-weight:600; color:var(--tc,#f59e0b); white-space:nowrap; max-width:100%; } +.profile-karma-row { margin-top:14px; padding:12px 14px; background:var(--bg); border:1px solid var(--border); border-radius:var(--r); } +.pkr-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; } +.pkr-icon { font-size:18px; } +.pkr-label { font-weight:700; font-size:14px; } +.pkr-pts { margin-left:auto; font-size:13px; font-weight:600; color:var(--muted); font-family:var(--mono); } +.pkr-bar { height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin-bottom:4px; } +.pkr-fill { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.4,0,.2,1); } +.pkr-next { font-size:11px; color:var(--faint); text-align:right; } + +/* ── Friends ──────────────────────────────────────────────────────── */ +.pending-requests { background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r-lg); padding:16px 20px; margin-bottom:20px; } +.pending-requests h3 { font-size:14px; font-weight:600; margin-bottom:12px; color:#92400e; } +.pending-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #fde68a; } +.pending-row:last-child { border-bottom:none; } +.pending-row a { font-weight:500; flex:1; } +.friends-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:12px; padding:4px 0; } +.friend-card { display:flex; flex-direction:column; align-items:center; gap:8px; padding:16px 12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); text-decoration:none; color:var(--text); transition:all .18s; text-align:center; box-shadow:var(--sh); } +.friend-card:hover { border-color:var(--blue); transform:translateY(-2px); box-shadow:var(--sh-md); text-decoration:none; } +.friend-name { font-size:13px; font-weight:500; word-break:break-all; } +.friend-karma { font-size:12px; font-weight:600; font-family:var(--mono); } + +/* ── Messages ────────────────────────────────────────────────────── */ +.msg-page { max-width:860px; margin:0 auto; } +.msg-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; } +.msg-header h1 { font-size:1.4rem; font-weight:700; margin:0; } +.msg-tabs { display:flex; gap:4px; margin-bottom:16px; border-bottom:2px solid var(--border); } +.msg-tabs a.tab-btn { text-decoration:none; display:inline-flex; align-items:center; gap:6px; } +.msg-list { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); } +.msg-row { display:flex; align-items:flex-start; gap:12px; padding:14px 18px; border-bottom:1px solid var(--border-l); text-decoration:none; color:var(--text); transition:background .18s; } +.msg-row:last-child { border-bottom:none; } +.msg-row:hover { background:var(--bg); text-decoration:none; } +.msg-row.unread { background:var(--blue-l); } +.msg-body { flex:1; min-width:0; } +.msg-from { font-size:12px; color:var(--muted); margin-bottom:3px; display:flex; align-items:center; gap:6px; } +.msg-subject { font-weight:600; font-size:14px; margin-bottom:3px; } +.msg-preview { font-size:13px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.msg-time { font-size:12px; color:var(--faint); flex-shrink:0; } +.unread-dot { color:var(--blue); font-size:10px; } +.msg-view-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); margin-bottom:20px; } +.msg-view-header { display:flex; align-items:flex-start; gap:14px; padding:20px; border-bottom:1px solid var(--border-l); background:var(--bg); } +.msg-view-subject { font-size:1.1rem; font-weight:700; margin-bottom:4px; } +.msg-view-meta { font-size:13px; color:var(--muted); } +.msg-view-body { padding:22px; font-size:15px; line-height:1.75; white-space:pre-wrap; word-wrap:break-word; } +.to-input-wrap { position:relative; display:flex; align-items:center; } +.at-prefix { position:absolute; left:12px; color:var(--muted); font-size:15px; font-weight:500; pointer-events:none; z-index:1; } +.to-input-wrap .fi { padding-left:28px; } +.to-suggestions { position:absolute; top:100%; left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:100; display:none; } +.to-sug-item { padding:9px 14px; font-size:14px; cursor:pointer; transition:background .18s; } +.to-sug-item:hover { background:var(--bg); } + +/* ── @mentions ───────────────────────────────────────────────────── */ +.mention-tag { display:inline-flex; align-items:center; background:var(--blue-l); color:var(--blue); border-radius:4px; padding:1px 5px; font-weight:600; font-size:.9em; text-decoration:none; transition:background .18s; } +.mention-tag:hover { background:var(--blue); color:#fff; text-decoration:none; } +.mention-popup { position:absolute; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:500; min-width:180px; max-height:200px; overflow-y:auto; display:none; } +.mention-popup.show { display:block; } +.mention-item { display:flex; align-items:center; gap:8px; padding:8px 12px; font-size:13px; cursor:pointer; transition:background .18s; } +.mention-item:hover,.mention-item.selected { background:var(--blue-l); color:var(--blue); } + +/* ── Error pages ─────────────────────────────────────────────────── */ +.err-pg { text-align:center; padding:60px 24px; } +.err-code { font-size:6rem; font-weight:800; background:linear-gradient(135deg,var(--blue),var(--purple)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; line-height:1; margin-bottom:14px; } +.err-pg h1 { font-size:1.7rem; margin-bottom:8px; } +.err-pg p { color:var(--muted); margin-bottom:22px; } + +/* ── Lightbox ────────────────────────────────────────────────────── */ +.lightbox { position:fixed; inset:0; background:rgba(0,0,0,.92); z-index:9999; display:flex; align-items:center; justify-content:center; cursor:zoom-out; backdrop-filter:blur(4px); animation:fadeIn .2s; } +.lightbox img { max-width:92vw; max-height:90vh; border-radius:var(--r); box-shadow:0 24px 80px rgba(0,0,0,.8); } + +/* ── Toggle switch ───────────────────────────────────────────────── */ +.toggle-sw { position:relative; display:inline-block; width:44px; height:24px; flex-shrink:0; } +.toggle-sw input { opacity:0; width:0; height:0; } +.toggle-knob { position:absolute; cursor:pointer; inset:0; background:#cbd5e1; border-radius:24px; transition:.3s; } +.toggle-knob::before { content:''; position:absolute; width:18px; height:18px; left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.3s; box-shadow:0 1px 3px rgba(0,0,0,.2); } +.toggle-sw input:checked + .toggle-knob { background:var(--blue); } +.toggle-sw input:checked + .toggle-knob::before { transform:translateX(20px); } +.toggle-label { display:flex; align-items:center; justify-content:space-between; padding:8px 0; cursor:pointer; } + +/* ── Search page ─────────────────────────────────────────────────── */ +.search-form { display:flex; gap:10px; margin-bottom:20px; } +.search-fi { flex:1; max-width:600px; } + +/* ── Animations ──────────────────────────────────────────────────── */ +@keyframes fadeIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} } + +/* ── Mobile ──────────────────────────────────────────────────────── */ +@media(max-width:600px) { + .home-hero { padding:24px 18px; } + .home-hero h1 { font-size:1.5rem; } + .topic-title { font-size:1.1rem; } + .post-side { width:58px; } + .post-pcnt { display:none; } + .tl-hdr,.tl-row { grid-template-columns:1fr 60px 60px; } + .profile-hdr { flex-direction:column; } + .post-captcha-row { flex-direction:column; align-items:flex-start; } +} + + + +/* ═══════════════════════════════════════════════════════════════ + LIVE SEARCH DROPDOWN — topics + posts sections + ═══════════════════════════════════════════════════════════════ */ +.sr-section-lbl { + padding: 6px 14px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: var(--faint); + background: var(--bg); + border-bottom: 1px solid var(--border-l); + border-top: 1px solid var(--border-l); +} +.sr-section-lbl:first-child { border-top: none; } + +.sr-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + font-size: 14px; + color: var(--text); + text-decoration: none; + border-bottom: 1px solid var(--border-l); + transition: background .15s; +} +.sr-item:last-child { border-bottom: none; } +.sr-item:hover { background: var(--bg); text-decoration: none; } +.sr-item strong { display: block; font-size: 13px; font-weight: 600; margin-bottom: 1px; } +.sr-item small { display: block; font-size: 11px; color: var(--muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.sr-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.sr-text { flex: 1; min-width: 0; } + +.sr-post { background: #fafafa; } +.sr-post:hover { background: var(--blue-l); } + +.sr-goto-badge { + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + color: var(--blue); + background: var(--blue-l); + border: 1px solid #bfdbfe; + padding: 2px 7px; + border-radius: 8px; +} + +.sr-all { + font-size: 13px; + color: var(--blue); + font-weight: 500; + background: var(--bg); + justify-content: center; + padding: 10px 14px; + border-top: 1px solid var(--border-l); +} +.sr-all:hover { background: var(--blue-l); } + + + + +/* ── Post :target highlight (CSS-only, no JS needed) ────────── */ +.post:target { + border-color: var(--blue) !important; + box-shadow: 0 0 0 3px rgba(59,130,246,.18), var(--sh) !important; + animation: postPing 1.8s ease forwards; +} +@keyframes postPing { + 0% { box-shadow: 0 0 0 5px rgba(59,130,246,.35), var(--sh); } + 60% { box-shadow: 0 0 0 8px rgba(59,130,246,.10), var(--sh); } + 100% { box-shadow: var(--sh); border-color: var(--border); } +} + +/* ═══════════════════════════════════════════════════════════════ + DARK MODE — [data-theme="dark"] overrides + ═══════════════════════════════════════════════════════════════ */ +[data-theme="dark"] { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2d3148; + --border-l: #1e2235; + --text: #e2e8f0; + --muted: #94a3b8; + --faint: #64748b; + --blue-l: #1e3a5f; + --sh: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3); + --sh-md: 0 4px 16px rgba(0,0,0,.4); + --sh-lg: 0 10px 40px rgba(0,0,0,.5); +} + +/* Surfaces */ +[data-theme="dark"] body { background: var(--bg); } +[data-theme="dark"] .site-header { background: #12151e; border-color: var(--border); } +[data-theme="dark"] .sidebar { background: #12151e; border-color: var(--border); } +[data-theme="dark"] .site-footer { background: #12151e; border-color: var(--border); } +[data-theme="dark"] .post { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .post:hover { border-color: #3d4464; } +[data-theme="dark"] .post-side { background: var(--bg); border-color: var(--border-l); } +[data-theme="dark"] .posts-horizontal .post-side { background: #12151e; border-color: var(--border); } +[data-theme="dark"] .form-card { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .acard { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .acard-head { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .acard-body { background: var(--surface); } + +/* Inputs */ +[data-theme="dark"] .fi { background: var(--bg); border-color: var(--border); color: var(--text); } +[data-theme="dark"] .fi:focus { border-color: var(--blue); background: var(--surface); } +[data-theme="dark"] select.fi { background: var(--bg); } +[data-theme="dark"] .reply-ta { background: var(--surface); color: var(--text); } +[data-theme="dark"] .reply-preview { background: var(--surface); } + +/* Navigation */ +[data-theme="dark"] .nav-link:hover { background: rgba(59,130,246,.15); } +[data-theme="dark"] .new-link { background: var(--blue) !important; } + +/* Search */ +[data-theme="dark"] #searchInput { background: #1e2235; border-color: var(--border); color: var(--text); } +[data-theme="dark"] #searchInput:focus { background: var(--surface); } +[data-theme="dark"] .search-results { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .sr-item:hover { background: var(--bg); } + +/* Topic / forum cards */ +[data-theme="dark"] .topic-row { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .topic-row:hover { background: #1e2235; } +[data-theme="dark"] .cat-card { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .cat-card:hover { background: #1e2235; } +[data-theme="dark"] .tl-row { background: var(--surface); } +[data-theme="dark"] .tl-row:hover { background: var(--bg); } + +/* Buttons */ +[data-theme="dark"] .btn-ghost { border-color: var(--border); color: var(--muted); } +[data-theme="dark"] .btn-ghost:hover { background: var(--border-l); color: var(--text); } +[data-theme="dark"] .pa-btn { border-color: var(--border); color: var(--muted); background: transparent; } +[data-theme="dark"] .pa-btn:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-l); } +[data-theme="dark"] .icon-btn { color: var(--muted); } +[data-theme="dark"] .icon-btn:hover { background: var(--border-l); } + +/* Reply box editor */ +[data-theme="dark"] .reply-box { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .reply-hdr { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .ed-toolbar { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .ed-toolbar button { color: var(--muted); } +[data-theme="dark"] .ed-toolbar button:hover { background: var(--surface); color: var(--text); } +[data-theme="dark"] .reply-footer { background: var(--bg); border-color: var(--border); } + +/* Dropdowns */ +[data-theme="dark"] .notif-drop { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .notif-item { border-color: var(--border-l); } +[data-theme="dark"] .notif-item:hover { background: var(--bg); } +[data-theme="dark"] .notif-head { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .user-drop { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .drop-item:hover { background: var(--bg); } +[data-theme="dark"] .drop-div { background: var(--border); } + +/* Code blocks — already dark, just keep them */ +[data-theme="dark"] pre.code-block { border-color: var(--border); } + +/* Inline code */ +[data-theme="dark"] .inline-code, +[data-theme="dark"] .post-content :not(pre) > code { background: #2d1f5e; border-color: #4c3a8a; color: #c4b5fd; } + +/* Blockquote */ +[data-theme="dark"] .post-content blockquote, +[data-theme="dark"] .rendered-post blockquote { background: #1a2744; border-left-color: var(--blue); color: #94a3b8; } + +/* Auth page */ +[data-theme="dark"] .auth-body { background: linear-gradient(135deg,#0f1117,#1a1d27); } +[data-theme="dark"] .auth-card { background: var(--surface); border-color: var(--border); } + +/* Profile */ +[data-theme="dark"] .profile-hdr { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .profile-karma-row { background: var(--bg); border-color: var(--border); } + +/* Messages */ +[data-theme="dark"] .msg-list { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .msg-row { color: var(--text); border-color: var(--border-l); } +[data-theme="dark"] .msg-row:hover { background: var(--bg); } +[data-theme="dark"] .msg-row.unread { background: var(--blue-l); } +[data-theme="dark"] .msg-view-card { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .msg-view-header { background: var(--bg); border-color: var(--border); } + +/* mx-* messages */ +[data-theme="dark"] .mx-layout .mx-main, +[data-theme="dark"] .mx-widget { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .mx-widget-head { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .mx-row { color: var(--text); border-color: var(--border-l); } +[data-theme="dark"] .mx-row:hover { background: var(--bg); } +[data-theme="dark"] .mx-row.unread { background: var(--blue-l); } + +/* Alerts */ +[data-theme="dark"] .alert.ok { background: #052e16; border-color: #166534; color: #bbf7d0; } +[data-theme="dark"] .alert.err { background: #450a0a; border-color: #991b1b; color: #fecaca; } +[data-theme="dark"] .alert.warn { background: #431407; border-color: #9a3412; color: #fed7aa; } + +/* Admin */ +[data-theme="dark"] .admin-sidebar { background: #12151e; border-color: var(--border); } +[data-theme="dark"] .anav { color: var(--muted); } +[data-theme="dark"] .anav:hover,.anav.on { background: var(--blue-l); color: var(--blue); } +[data-theme="dark"] .atable tr:hover td { background: var(--bg); } +[data-theme="dark"] .atable thead tr { background: var(--bg); } + +/* Misc */ +[data-theme="dark"] .reply-cta { background: var(--surface); border-color: var(--border); } +[data-theme="dark"] .closed-notice { background: #431407; border-color: #9a3412; color: #fed7aa; } +[data-theme="dark"] .pager .pg-btn { background: var(--surface); border-color: var(--border); color: var(--muted); } +[data-theme="dark"] .pg-btn.active { background: var(--blue); border-color: var(--blue); color: #fff; } +[data-theme="dark"] .sr-section-lbl { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .sr-item { background: var(--surface); border-color: var(--border-l); } +[data-theme="dark"] .sr-post { background: #1a1d27; } +[data-theme="dark"] .sr-all { background: var(--bg); } +[data-theme="dark"] .tag { background: #1e2235; border-color: var(--border); color: var(--muted); } +[data-theme="dark"] .tag-preview .tag { background: #1a2744; border-color: #2d4a7a; color: #93c5fd; } +[data-theme="dark"] .mention-tag { background: var(--blue-l); color: #93c5fd; } + +/* ── Welcome guide addon CSS ──────────────────────────────────── */ +.wg-guide { + margin-top: 14px; + border-top: 1px solid var(--border-l); + padding-top: 10px; +} +.wg-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: 1px solid var(--border); + border-radius: var(--r); + padding: 4px 10px; + font-size: 12px; + font-family: var(--font); + color: var(--muted); + cursor: pointer; + transition: all .15s; +} +.wg-toggle:hover { background: var(--bg); color: var(--text); } +.wg-toggle.wg-open { background: var(--blue-l); color: var(--blue); border-color: #bfdbfe; } +.wg-arrow { transition: transform .2s; } +.wg-toggle.wg-open .wg-arrow { transform: rotate(180deg); } +.wg-body { + margin-top: 10px; + background: var(--bg); + border: 1px solid var(--border-l); + border-radius: var(--r); + padding: 14px; + animation: fadeIn .18s ease; +} +.wg-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} +.wg-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--faint); + margin-bottom: 8px; +} +.wg-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; + margin-bottom: 5px; + color: var(--muted); +} +.wg-row code, .wg-mono { + font-family: var(--mono); + font-size: 11px; + background: var(--surface); + border: 1px solid var(--border); + padding: 1px 5px; + border-radius: 4px; + color: var(--text); +} +.wg-block-row { flex-direction: column; align-items: flex-start; } +.wg-hint { font-size: 11px; color: var(--faint); font-style: italic; } +[data-theme="dark"] .wg-body { background: var(--bg); border-color: var(--border); } +[data-theme="dark"] .wg-row code, [data-theme="dark"] .wg-mono { background: var(--surface); border-color: var(--border); } + +/* ── Green karma redesign ─────────────────────────────────────── */ +.pkr-label-green { color: #16a34a; font-weight: 700; } +.pkr-fill-green { background: linear-gradient(90deg, #22c55e, #16a34a) !important; } +.profile-tier-badge-green { + background: rgba(34,197,94,.12); + border-color: rgba(34,197,94,.35); + color: #16a34a; + --tc: #16a34a; +} +.pstat.karma-stat strong { color: #16a34a !important; } + +[data-theme="dark"] .pkr-label-green { color: #4ade80; } +[data-theme="dark"] .pkr-fill-green { background: linear-gradient(90deg,#22c55e,#16a34a) !important; } +[data-theme="dark"] .profile-tier-badge-green { background: rgba(34,197,94,.15); border-color: rgba(34,197,94,.3); color: #4ade80; } +[data-theme="dark"] .pstat.karma-stat strong { color: #4ade80 !important; } +[data-theme="dark"] .post-karma-badge { background: rgba(34,197,94,.15); border-color: rgba(34,197,94,.3); color: #4ade80; } +[data-theme="dark"] .post-karma-badge .pkb-icon svg { stroke: #4ade80 !important; } + +/* ── Security warning banner ───────────────────────────────── */ +.sec-warning { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0; + padding: 9px 14px; + background: #fffbeb; + border-top: 1px solid #fde68a; + color: #92400e; + font-size: 12px; + font-family: var(--font); + line-height: 1.5; +} +.sec-warning svg { + flex-shrink: 0; + margin-top: 1px; + color: #f59e0b; +} +[data-theme="dark"] .sec-warning { + background: #431407; + border-color: #9a3412; + color: #fed7aa; +} +[data-theme="dark"] .sec-warning svg { color: #fb923c; } diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..bfbfe40 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1236 @@ +/* ================================================================ + Nexus Forum — app.js v2 + ================================================================ */ +'use strict'; + +/* ── Escape HTML ──────────────────────────────────────────── */ +function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');} + +/* ── Media embed detection ────────────────────────────────── */ +/* Called on bare URLs that end up in <p> tags after md() rendering */ +function tryEmbed(url) { + var m; + // YouTube full + m = url.match(/youtube\.com\/watch\?.*v=([a-zA-Z0-9_\-]{11})/); + if (m) return ytEmbed(m[1]); + // YouTube short + m = url.match(/youtu\.be\/([a-zA-Z0-9_\-]{11})/); + if (m) return ytEmbed(m[1]); + // YouTube Shorts + m = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_\-]{11})/); + if (m) return ytEmbed(m[1], true); + // Vimeo + m = url.match(/vimeo\.com\/(\d{5,12})/); + if (m) return embedIframe('https://player.vimeo.com/video/'+m[1]+'?dnt=1', 'Vimeo'); + // Spotify + m = url.match(/open\.spotify\.com\/(track|album|playlist|episode|artist)\/([a-zA-Z0-9]+)/); + if (m) { + var h = (m[1]==='track'||m[1]==='episode') ? '152' : '352'; + return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/'+m[1]+'/'+m[2]+'" width="100%" height="'+h+'" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>'; + } + // SoundCloud + if (/soundcloud\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+/.test(url)) { + return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" src="https://w.soundcloud.com/player/?url='+encodeURIComponent(url)+'&color=%233b82f6&auto_play=false"></iframe></div>'; + } + // Twitch + m = url.match(/twitch\.tv\/videos\/(\d+)/); + if (m) return embedIframe('https://player.twitch.tv/?video=v'+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch VOD'); + m = url.match(/twitch\.tv\/([a-zA-Z0-9_]{4,25})(?:\/|$|\?)/); + if (m) return embedIframe('https://player.twitch.tv/?channel='+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch'); + // Twitter/X + m = url.match(/(?:twitter|x)\.com\/[a-zA-Z0-9_]+\/status\/(\d+)/); + if (m) return '<div class="embed-tweet" data-tweet-id="'+m[1]+'"><a href="'+esc(url)+'" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>'; + // Streamable + m = url.match(/streamable\.com\/([a-zA-Z0-9]+)/); + if (m) return embedIframe('https://streamable.com/e/'+m[1], 'Streamable'); + // Dailymotion + m = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/); + if (m) return embedIframe('https://www.dailymotion.com/embed/video/'+m[1], 'Dailymotion'); + // Loom + m = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/); + if (m) return embedIframe('https://www.loom.com/embed/'+m[1], 'Loom'); + // CodePen + m = url.match(/codepen\.io\/([a-zA-Z0-9\-_]+)\/pen\/([a-zA-Z0-9]+)/); + if (m) return embedIframeTall('https://codepen.io/'+m[1]+'/embed/'+m[2]+'?default-tab=result', 'CodePen', 420); + // JSFiddle + m = url.match(/jsfiddle\.net\/([a-zA-Z0-9\/]+)/); + if (m) return embedIframeTall('https://jsfiddle.net/'+m[1].replace(/\/$/,'')+'/embedded/result', 'JSFiddle', 380); + // TED + m = url.match(/ted\.com\/talks\/([a-zA-Z0-9_]+)/); + if (m) return embedIframe('https://embed.ted.com/talks/'+m[1], 'TED Talk'); + return null; +} + +function ytEmbed(id, isShort) { + var pad = isShort ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%'; + return '<div class="embed-wrap" style="'+pad+'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'+esc(id)+'?rel=0&modestbranding=1" allowfullscreen loading="lazy" title="YouTube"></iframe></div>'; +} +function embedIframe(src, label) { + return '<div class="embed-wrap"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>'; +} +function embedIframeTall(src, label, h) { + return '<div class="embed-wrap" style="padding-bottom:0;height:'+(h||400)+'px"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>'; +} +function processEmbeds(html) { + return html.replace(/<p>\s*(https?:\/\/[^\s<>"']+)\s*<\/p>/gi, function(match, url) { + var em = tryEmbed(url); + return em !== null ? em : match; + }); +} + + +/* ── Markdown renderer ────────────────────────────────────── */ +function md(raw) { + if (!raw) return ''; + var s = raw; + var fences = [], quotes = [], inlines = []; + + // ── PASS 1: Extract code fences (state machine) ───────────── + var fenceLines = s.split('\n'); + var fenceOut = [], inFence = false, fLang = '', fType = '', fBuf = []; + + var buildFence = function(lang, lines) { + var code = lines.join('\n'); + var ll = lang ? lang.toLowerCase() : ''; + var copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" title="Copy" aria-label="Copy code">' + + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + + 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">' + + '<rect x="9" y="9" width="13" height="13" rx="2"/>' + + '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' + + '</svg><span>Copy</span></button>'; + var hdr, blk; + if (ll) { + hdr = '<div class="cb-header"><span class="cb-lang">' + esc(ll) + '</span>' + copyBtn + '</div>'; + blk = '<pre class="code-block" data-lang="' + esc(ll) + '">' + + '<code class="language-' + esc(ll) + '">' + esc(code) + '</code></pre>'; + } else { + hdr = '<div class="cb-header cb-header-nolang">' + copyBtn + '</div>'; + blk = '<pre class="code-block"><code>' + esc(code) + '</code></pre>'; + } + var i = fences.length; + fences.push('<div class="code-block-wrap">' + hdr + blk + '</div>'); + return '\x02FENCE' + i + 'FNCE\x03'; + }; + + for (var fi = 0; fi < fenceLines.length; fi++) { + var fl = fenceLines[fi]; + if (!inFence) { + var tm = fl.match(/^```([ \t]*\w*)[ \t]*$/); + if (tm) { inFence = true; fType = 'triple'; fLang = tm[1].trim(); fBuf = []; continue; } + if (/^`[ \t]*$/.test(fl)) { inFence = true; fType = 'single'; fLang = ''; fBuf = []; continue; } + fenceOut.push(fl); + } else { + var isClose = (fType === 'triple' && /^```[ \t]*$/.test(fl)) + || (fType === 'single' && /^`[ \t]*$/.test(fl)); + if (isClose) { fenceOut.push(buildFence(fLang, fBuf)); inFence = false; fBuf = []; } + else { fBuf.push(fl); } + } + } + if (inFence && fBuf.length) fenceOut.push(buildFence(fLang, fBuf)); + s = fenceOut.join('\n'); + + // ── PASS 2: Extract blockquotes (state machine) ────────────── + var bqLines = s.split('\n'), bqOut = [], bqBuf = []; + var flushBq = function() { + if (!bqBuf.length) return; + var inner = bqBuf.join('\n'); + inner = inner.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); + inner = inner.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + inner = inner.replace(/\*([^\*\n]+)\*/g, '<em>$1</em>'); + inner = inner.replace(/~~(.+?)~~/g, '<del>$1</del>'); + var bqParts = inner.split('\n').filter(function(l){return l.trim();}); + var content = bqParts.length > 1 + ? bqParts.map(function(l){return '<p>'+l.trim()+'</p>';}).join('') + : inner.trim(); + var i = quotes.length; + quotes.push('<blockquote class="post-quote">' + content + '</blockquote>'); + bqOut.push('\x02BQUOT' + i + 'BQUT\x03'); + bqBuf = []; + }; + for (var bi = 0; bi < bqLines.length; bi++) { + var bm = bqLines[bi].match(/^(?:>|>) ?(.*)/); + if (bm) { bqBuf.push(bm[1]); } + else { flushBq(); bqOut.push(bqLines[bi]); } + } + flushBq(); + s = bqOut.join('\n'); + + // ── PASS 3: Inline code ────────────────────────────────────── + s = s.replace(/`([^`\n]+)`/g, function(_, code) { + var i = inlines.length; + inlines.push('<code class="inline-code">' + esc(code) + '</code>'); + return '\x02INLIN' + i + 'INLN\x03'; + }); + + // ── PASS 4: Block markdown ─────────────────────────────────── + s = s.replace(/^#{6} (.+)$/gm, '<h6>$1</h6>'); + s = s.replace(/^#{5} (.+)$/gm, '<h5>$1</h5>'); + s = s.replace(/^#{4} (.+)$/gm, '<h4>$1</h4>'); + s = s.replace(/^#{3} (.+)$/gm, '<h3>$1</h3>'); + s = s.replace(/^#{2} (.+)$/gm, '<h2>$1</h2>'); + s = s.replace(/^# (.+)$/gm, '<h1>$1</h1>'); + s = s.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>'); + + s = s.replace(/^[ \t]*[*\-+] (.+)$/gm, '<li>$1</li>'); + s = s.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>'); + s = s.replace(/<\/ul>\s*<ul>/g, ''); + + s = s.replace(/^[ \t]*\d+\. (.+)$/gm, '<oli>$1</oli>'); + s = s.replace(/((?:<oli>.*<\/oli>\n?)+)/g, '<ol>$1</ol>'); + s = s.replace(/<\/ol>\s*<ol>/g, ''); + s = s.replace(/<oli>/g, '<li>').replace(/<\/oli>/g, '</li>'); + + // ── PASS 5: Inline markdown ────────────────────────────────── + s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); + s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + s = s.replace(/\*([^\*\n]+)\*/g, '<em>$1</em>'); + s = s.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>'); + s = s.replace(/__(.+?)__/g, '<strong>$1</strong>'); + s = s.replace(/_([^_\n]+)_/g, '<em>$1</em>'); + s = s.replace(/~~(.+?)~~/g, '<del>$1</del>'); + + s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_,alt,src){ + if (!/^https?:\/\/|^\//.test(src)) return esc(_); + return '<img src="'+esc(src)+'" alt="'+esc(alt)+'" loading="lazy" onclick="lightbox(this)">'; + }); + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_,txt,url){ + if (!/^https?:\/\/|^\//.test(url)) return esc(txt); + return '<a href="'+esc(url)+'" target="_blank" rel="noopener noreferrer nofollow">'+esc(txt)+'</a>'; + }); + + // ── PASS 6: Embeds + auto-link (ALL bare URLs) ─────────────── + s = s.replace(/(^|[ \t]|<p>)(https?:\/\/[^\s<>"']+)/gm, function(_, pre, url) { + if (/^<(div|iframe|a|pre)/.test(url)) return pre + url; + var em = tryEmbed(url); + if (em) return pre + em; + return pre + '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer nofollow">' + esc(url) + '</a>'; + }); + + // ── PASS 7: Paragraph wrapping ─────────────────────────────── + var pLines = s.split('\n'), pOut = [], para = ''; + var blockRe = /^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|\x02FENCE|\x02BQUOT)/; + function flush(){ if(para.trim()){ pOut.push('<p>'+para.trim()+'</p>'); para=''; } } + for (var pi = 0; pi < pLines.length; pi++) { + var pl = pLines[pi]; + if (!pl.trim()) { flush(); } + else if (blockRe.test(pl.trim())) { flush(); pOut.push(pl); } + else { para += (para ? ' ' : '') + pl; } + } + flush(); + s = pOut.join('\n'); + + // ── PASS 8: Restore placeholders ───────────────────────────── + fences.forEach(function(b,i){ s = s.replace('\x02FENCE' + i + 'FNCE\x03', b); }); + quotes.forEach(function(b,i){ s = s.replace('\x02BQUOT' + i + 'BQUT\x03', b); }); + inlines.forEach(function(b,i){ s = s.replace('\x02INLIN' + i + 'INLN\x03', b); }); + + return s; +} + + +/* ── Render all .md elements ──────────────────────────────── */ +function renderAllMd() { + document.querySelectorAll('.md[data-raw]').forEach(function(el) { + el.innerHTML = md(atob(el.getAttribute('data-raw'))); + el.removeAttribute('data-raw'); + }); + loadTwitterWidgets(); +} + +/* ── Lightbox ─────────────────────────────────────────────── */ +function lightbox(img) { + var ov = document.createElement('div'); + ov.className = 'lightbox'; + var im = document.createElement('img'); + im.src = img.src; im.alt = img.alt; + ov.appendChild(im); + ov.onclick = function(){ov.remove();}; + document.body.appendChild(ov); +} + +/* ── Time-ago ─────────────────────────────────────────────── */ +function timeAgo(dt) { + var diff = Math.floor((Date.now() - new Date(dt)) / 1000); + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff/60) + 'm ago'; + if (diff < 86400) return Math.floor(diff/3600) + 'h ago'; + if (diff < 604800) return Math.floor(diff/86400) + 'd ago'; + return new Date(dt).toLocaleDateString(); +} +function renderTimeAgo() { + document.querySelectorAll('.ago[data-ts]').forEach(function(el) { + el.textContent = timeAgo(el.dataset.ts); + el.title = new Date(el.dataset.ts).toLocaleString(); + }); +} + +/* ── Sidebar toggle ───────────────────────────────────────── */ +var burger = document.getElementById('burgerBtn'); +var sidebar = document.getElementById('sidebar'); +var sbOv = document.getElementById('sbOverlay'); +if (burger && sidebar) { + burger.addEventListener('click', function(){ + var open = sidebar.classList.toggle('open'); + burger.classList.toggle('open', open); + if (sbOv) sbOv.classList.toggle('show', open); + document.body.style.overflow = open ? 'hidden' : ''; + }); + if (sbOv) sbOv.addEventListener('click', function(){ + sidebar.classList.remove('open'); + burger.classList.remove('open'); + sbOv.classList.remove('show'); + document.body.style.overflow = ''; + }); +} + +/* ── Dropdowns ────────────────────────────────────────────── */ +function setupDrop(btnId, dropId, onOpen) { + var btn = document.getElementById(btnId); + var drop = document.getElementById(dropId); + if (!btn || !drop) return; + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var opening = !drop.classList.contains('show'); + document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');}); + if (opening) { drop.classList.add('show'); if (onOpen) onOpen(); } + }); +} +document.addEventListener('click', function(){ + document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');}); +}); +setupDrop('notifBtn', 'notifDrop', loadNotifs); +setupDrop('userBtn', 'userDrop'); + +/* ── Notifications ────────────────────────────────────────── */ +function loadNotifs() { + if (!window.NX || !NX.user) return; + var list = document.getElementById('notifList'); + if (!list) return; + fetch(NX.base + '/api/notifications.php') + .then(function(r){ return r.json(); }) + .then(function(rows) { + if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; } + list.innerHTML = rows.map(function(n) { + var d = n.payload || {}; + var text = (n.type === 'reply' && d.title) + ? '<strong>@'+esc(d.from)+'</strong> replied in <em>'+esc(d.title)+'</em>' + : esc(n.type); + var href = (n.type === 'reply' && d.slug) ? NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug) : '#'; + return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">' + +'<div class="notif-item-text">'+text+'</div>' + +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>' + +'</a>'; + }).join(''); + }) + .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; }); +} +var readAllBtn = document.getElementById('readAllBtn'); +if (readAllBtn) { + readAllBtn.addEventListener('click', function(){ + fetch(NX.base + '/api/notifications.php?action=read').then(function(){ + var dot = document.querySelector('.badge-dot'); + if (dot) dot.remove(); + loadNotifs(); + }); + }); +} + +/* ── Header search ────────────────────────────────────────── */ +var searchInput = document.getElementById('searchInput'); +var searchResults = document.getElementById('searchResults'); +var searchTimer = null; +if (searchInput && searchResults) { + searchInput.addEventListener('input', function(){ + clearTimeout(searchTimer); + var q = searchInput.value.trim(); + if (q.length < 2) { searchResults.classList.remove('show'); return; } + searchTimer = setTimeout(function(){ + fetch(NX.base + '/api/search.php?q=' + encodeURIComponent(q)) + .then(function(r){ return r.json(); }) + .then(function(d){ + var topics = Array.isArray(d) ? d : (d.topics || []); + var posts = Array.isArray(d) ? [] : (d.posts || []); + if (!topics.length && !posts.length) { + searchResults.classList.remove('show'); return; + } + var html = ''; + if (topics.length) { + html += '<div class="sr-section-lbl">Topics</div>'; + topics.forEach(function(r){ + html += '<a class="sr-item" href="'+NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.slug)+'">' + + '<span class="sr-dot" style="background:'+(r.cat_color||'#3b82f6')+'"></span>' + + '<span class="sr-text"><strong>'+esc(r.title)+'</strong>' + + '<small>'+esc(r.cat)+'</small></span></a>'; + }); + } + if (posts.length) { + html += '<div class="sr-section-lbl">Matching Posts</div>'; + posts.forEach(function(r){ + var preview = (r.content||'').substring(0,65).replace(/\n/g,' '); + var url = NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.topic_slug)+'&goto='+r.post_id+'#post-'+r.post_id; + html += '<a class="sr-item sr-post" href="'+url+'">' + + '<span class="sr-text">' + + '<strong>'+esc(r.topic_title)+'</strong>' + + '<small>Post #'+r.post_num+' by @'+esc(r.username)+': '+esc(preview)+'…</small>' + + '</span>' + + '<span class="sr-goto-badge">→</span></a>'; + }); + } + html += '<a class="sr-item sr-all" href="'+NX.base+'/forum/search.php?q='+encodeURIComponent(q)+'&type=posts">' + + '🔍 See all results for “'+esc(q)+'”</a>'; + searchResults.innerHTML = html; + searchResults.classList.add('show'); + }); + }, 280); + }); + searchInput.addEventListener('keydown', function(e){ + if (e.key === 'Enter') window.location = NX.base + '/forum/search.php?q=' + encodeURIComponent(searchInput.value); + if (e.key === 'Escape') searchResults.classList.remove('show'); + }); + document.addEventListener('click', function(e){ + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) searchResults.classList.remove('show'); + }); +} + +/* ── Editor commands ──────────────────────────────────────── */ +function fmt(cmd, taId) { + var ta = document.getElementById(taId || 'replyTa'); + if (!ta) return; + var s = ta.selectionStart, e = ta.selectionEnd; + var sel = ta.value.slice(s, e); + var pre = ta.value.slice(0, s); + var post = ta.value.slice(e); + + // ── Link ────────────────────────────────────────────────────── + if (cmd === 'link') { + var url = prompt('Enter URL:'); + if (!url) return; + var txt = sel || 'link text'; + ta.value = pre + '[' + txt + '](' + url + ')' + post; + ta.selectionStart = s + txt.length + url.length + 4; + ta.selectionEnd = ta.selectionStart; + ta.focus(); ta.dispatchEvent(new Event('input')); return; + } + + // ── Quote — prefix EVERY selected line with "> " ────────────── + // Mirrors Discourse / GitHub behaviour: + // • each line of the selection gets its own "> " prefix + // • trailing newline in the selection is stripped so no ghost blank line + // • if nothing is selected, inserts a placeholder quote line + // • a blank separator line is added before/after so the block renders correctly + if (cmd === 'quote') { + var text = sel ? sel.replace(/\n+$/, '') : ''; // strip trailing newlines + var lines = text ? text.split('\n') : ['quoted text']; + var quoted = lines.map(function(l) { return '> ' + l; }).join('\n'); + + // Ensure there's a blank line before the quote block (so renderer sees it as a block) + var needPre = pre.length > 0 && !/\n\n$/.test(pre) && !pre.endsWith('\n'); + var before = needPre ? '\n\n' : (pre.length > 0 && !pre.endsWith('\n') ? '\n' : ''); + + // Ensure there's a blank line after so the next paragraph starts clean + var needPost = post.length > 0 && !post.startsWith('\n\n') && !post.startsWith('\n'); + var after = needPost ? '\n\n' : '\n'; + + ta.value = pre + before + quoted + after + post; + + // Select the quoted lines so the user can see exactly what was quoted + var qStart = s + before.length; + var qEnd = qStart + quoted.length; + ta.selectionStart = qStart; + ta.selectionEnd = qEnd; + ta.focus(); + ta.dispatchEvent(new Event('input')); + return; + } + + // ── All other commands ───────────────────────────────────────── + var map = { + bold: ['**', '**', 'bold text'], + italic: ['*', '*', 'italic text'], + strike: ['~~', '~~', 'strikethrough'], + code: ['```\n', '\n```', 'code here'], + codeblock: ['```\n', '\n```', 'code here'], + heading: ['## ', '', 'Heading'], + ul: ['- ', '', 'list item'], + }; + var c = map[cmd]; + if (!c) return; + var text = sel || c[2]; + var newText = c[0] + text + c[1]; + ta.value = pre + newText + post; + ta.selectionStart = s + c[0].length; + ta.selectionEnd = s + c[0].length + text.length; + ta.focus(); ta.dispatchEvent(new Event('input')); +} + +/* ── Preview toggle ───────────────────────────────────────── */ +function togglePreview() { + var ta = document.getElementById('replyTa'); + var prev = document.getElementById('replyPreview'); + var btn = document.getElementById('prevBtn'); + if (!ta || !prev) return; + if (prev.classList.contains('hidden')) { + prev.innerHTML = md(ta.value); + loadTwitterWidgets(); + prev.classList.remove('hidden'); + ta.classList.add('hidden'); + if (btn) btn.classList.add('on'); + } else { + prev.classList.add('hidden'); + ta.classList.remove('hidden'); + if (btn) btn.classList.remove('on'); + } +} + +/* ── Reply submit ─────────────────────────────────────────── */ +function sendReply(slug) { + var ta = document.getElementById('replyTa'); + if (!ta) return; + var content = ta.value.trim(); + if (!content) { toast('Reply cannot be empty', 'err'); return; } + + var btn = document.getElementById('replyBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; } + + var fd = new FormData(); + fd.append('slug', slug); + fd.append('content', content); + fd.append('csrf', NX.csrf); + + // Attach post captcha if present + var capInp = document.getElementById('postCaptchaInput'); + if (capInp) fd.append('post_captcha', capInp.value); + + fetch(NX.base + '/api/reply.php', {method:'POST', body:fd}) + .then(function(r){ return r.json().then(function(d){ d._status=r.status; return d; }); }) + .then(function(data){ + if (data.ok) { + // Clear editor + ta.value = ''; + var prev = document.getElementById('replyPreview'); + if (prev) prev.classList.add('hidden'); + ta.classList.remove('hidden'); + var prevBtn = document.getElementById('prevBtn'); + if (prevBtn) prevBtn.classList.remove('on'); + var cnt = document.getElementById('charCnt'); + if (cnt) { cnt.textContent = '0'; cnt.style.color = ''; } + + // Update captcha if server sent a new one + if (data.new_captcha && capInp) { + var ql = capInp.closest('.post-captcha-row')&&capInp.closest('.post-captcha-row').querySelector('.captcha-q'); + if (ql) ql.textContent = data.new_captcha.q; + capInp.value = ''; + } + + // Append post to list + var list = document.getElementById('postsList'); + if (list && data.post) { + // Server-rendered post via reload or AJAX HTML + list.insertAdjacentHTML('beforeend', buildPost(data.post)); + var np = list.lastElementChild; + renderTimeAgo(); + loadTwitterWidgets(); + np.scrollIntoView({behavior:'smooth',block:'center'}); + } else { location.reload(); } + toast('Reply posted!', 'ok'); + + } else if (data.rate_limited) { + // Rate limit — show countdown + startRateCountdown(data.wait || 30); + toast(data.error, 'warn'); + + } else if (data.captcha_failed) { + toast(data.error, 'err'); + // Reload page to get new captcha + setTimeout(function(){ location.reload(); }, 1500); + + } else { + toast(data.error || 'Failed to post', 'err'); + } + }) + .catch(function(){ toast('Network error — please try again', 'err'); }) + .finally(function(){ if (btn) { btn.disabled=false; btn.textContent='Post Reply'; } }); +} + +/* Rate limit countdown */ +function startRateCountdown(seconds) { + var info = document.getElementById('rateLimitInfo'); + var btn = document.getElementById('replyBtn'); + var msg = document.getElementById('rateLimitMsg'); + var cd = document.getElementById('rateCountdown'); + if (info) info.style.display = ''; + if (btn) btn.disabled = true; + var remaining = seconds; + function tick() { + if (msg) msg.textContent = 'Please wait before posting again:'; + if (cd) cd.textContent = remaining + 's'; + if (remaining <= 0) { + if (info) info.style.display = 'none'; + if (btn) btn.disabled = false; + if (cd) cd.textContent = ''; + return; + } + remaining--; + setTimeout(tick, 1000); + } + tick(); +} + +function buildPost(p) { + var av = p.avatar + ? '<img src="'+esc(p.avatar)+'" class="av-lg" alt="">' + : '<span class="av-lg av-init">'+esc(p.username[0].toUpperCase())+'</span>'; + var flair = p.role==='admin' ? '<span class="role-flair admin">Admin</span>' + : p.role==='moderator' ? '<span class="role-flair mod">Mod</span>' : ''; + return '<div class="post" id="post-'+p.id+'">' + +'<div class="post-side">'+av + +'<a href="'+NX.base+'/users/profile.php?u='+encodeURIComponent(p.username)+'" class="post-name">@'+esc(p.username)+'</a>' + +flair+'<span class="post-pcnt">'+p.post_count+' posts</span></div>' + +'<div class="post-body"><div class="post-meta-bar"><span class="pnum">#'+p.post_num+'</span>' + +'<time class="ago" data-ts="'+esc(p.created_at)+'"></time>' + +'<div class="post-acts">' + +'<button class="pa-btn" onclick="doLike('+p.id+',this)">♥ <span class="lc">0</span></button>' + +'<button class="pa-btn" onclick="doQuote('+p.id+',\''+esc(p.username)+'\')">↩ Reply</button>' + +'</div></div>' + +'<div class="post-content rendered-post" id="pc-'+p.id+'" data-raw="'+btoa(unescape(encodeURIComponent(p.content)))+'">' + +md(p.content)+'</div></div></div>'; +} + +/* ── Like ─────────────────────────────────────────────────── */ +function doLike(pid, btn) { + if (!NX.user) { toast('Log in to like posts', 'warn'); return; } + var fd = new FormData(); fd.append('post_id', pid); fd.append('csrf', NX.csrf); + fetch(NX.base + '/api/like.php', {method:'POST', body:fd}) + .then(function(r){ return r.json(); }) + .then(function(data){ + if (data.ok) { + var lc = btn.querySelector('.lc'); + btn.classList.toggle('liked', data.liked); + if (lc) lc.textContent = data.count; + } + }); +} + +/* ── Quote reply ──────────────────────────────────────────── */ +function doQuote(pid, uname) { + var bodyEl = document.getElementById('pc-'+pid); + var text = bodyEl ? bodyEl.innerText.trim().slice(0,300) : ''; + var ta = document.getElementById('replyTa'); + if (!ta) return; + ta.value = '> **@'+uname+'** wrote:\n> '+text.replace(/\n/g,'\n> ')+'\n\n' + ta.value; + ta.focus(); + ta.scrollIntoView({behavior:'smooth'}); +} + +/* ── Edit post ────────────────────────────────────────────── */ +function doEdit(pid) { + var box = document.getElementById('eb-'+pid); + var body = document.getElementById('pc-'+pid); + var ta = document.getElementById('et-'+pid); + if (!box||!body||!ta) return; + // Use innerText for plaintext content (already sanitised, we edit raw) + var rawAttr = body.getAttribute('data-raw'); + ta.value = rawAttr ? atob(rawAttr) : body.innerText; + box.classList.add('visible'); + box.classList.remove('hidden'); + body.classList.add('hidden'); + ta.focus(); +} +function cancelEdit(pid) { + var box = document.getElementById('eb-'+pid); + var body = document.getElementById('pc-'+pid); + if (box) { box.classList.remove('visible'); box.classList.add('hidden'); } + if (body) body.classList.remove('hidden'); +} +function saveEdit(pid) { + var ta = document.getElementById('et-'+pid); + var ri = document.getElementById('er-'+pid); + if (!ta) return; + var fd = new FormData(); + fd.append('post_id',pid); + fd.append('content', ta.value); + fd.append('reason', ri ? ri.value : ''); + fd.append('csrf', NX.csrf); + fetch(NX.base+'/api/edit.php', {method:'POST',body:fd}) + .then(function(r){ return r.json(); }) + .then(function(data){ + if (data.ok) { + var body = document.getElementById('pc-'+pid); + if (body) { + // Re-render using client-side md() + mention rendering + body.innerHTML = renderMentions(md(data.content)); + // Update data-raw so next edit reads correct value + try { body.setAttribute('data-raw', btoa(unescape(encodeURIComponent(data.content)))); } catch(e){} + body.classList.remove('hidden'); + loadTwitterWidgets(); + } + cancelEdit(pid); + toast('Post updated!','ok'); + var meta = document.querySelector('#post-'+pid+' .post-meta-bar'); + if (meta && !meta.querySelector('.edit-lbl')) { + var em = document.createElement('em'); + em.className = 'edit-lbl'; + em.textContent = ' (edited)'; + var t = meta.querySelector('time'); + if (t) t.after(em); + } + } else { + toast(data.error || 'Failed to save', 'err'); + } + }) + .catch(function(){ toast('Network error', 'err'); }); +} + +/* ── Delete post ──────────────────────────────────────────── */ +function doDelete(pid) { + if (!confirm('Delete this post?')) return; + var fd = new FormData(); fd.append('post_id',pid); fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/delete.php', {method:'POST',body:fd}) + .then(function(r){ return r.json(); }) + .then(function(data){ + if (data.ok) { + var el = document.getElementById('post-'+pid); + if (el) { el.style.opacity='.3'; el.style.pointerEvents='none'; + var b = document.getElementById('pc-'+pid); + if (b) b.innerHTML='<em style="color:#94a3b8">This post has been deleted.</em>'; } + toast('Deleted','ok'); + } + }); +} + +/* ── Image upload ─────────────────────────────────────────── */ +function pickImg(inputId, taId) { + var inp = document.getElementById(inputId); + if (inp) { inp._taId = taId; inp.click(); } +} +function uploadImg(inp, taId) { + if (inp.files && inp.files[0]) uploadFileToEditor(inp.files[0], taId || inp._taId || 'replyTa'); +} +function uploadFileToEditor(file, taId) { + var ta = document.getElementById(taId || 'replyTa'); + if (!ta) return; + + // Client-side size check — max from server setting (NX.maxUploadMb, default 5) + var maxMb = (NX.maxUploadMb || 5); + if (file.size > maxMb * 1024 * 1024) { + toast('Image too large — max ' + maxMb + ' MB', 'err'); + return; + } + + var ph = '![Uploading ' + file.name + '…]()'; + var cur = ta.selectionStart; + ta.value = ta.value.slice(0, cur) + ph + ta.value.slice(cur); + toast('Uploading…', 'warn'); + + var fd = new FormData(); + fd.append('file', file); + fd.append('csrf', NX.csrf); // ← CSRF token (was missing — caused 403) + + fetch(NX.base + '/api/upload.php', { method: 'POST', body: fd }) + .then(function(r) { + // Always try to parse JSON — even error responses are JSON + return r.json().then(function(data) { + return { ok: r.ok, data: data }; + }); + }) + .then(function(result) { + var data = result.data; + if (data.ok) { + ta.value = ta.value.replace(ph, ''); + toast('Image uploaded!', 'ok'); + } else { + ta.value = ta.value.replace(ph, ''); + toast(data.error || 'Upload failed', 'err'); + } + ta.dispatchEvent(new Event('input')); + }) + .catch(function(err) { + ta.value = ta.value.replace(ph, ''); + toast('Upload failed — check console for details', 'err'); + console.error('Upload error:', err); + }); +} + +/* ── DOMContentLoaded ─────────────────────────────────────── */ +document.addEventListener('DOMContentLoaded', function(){ + var ta = document.getElementById('replyTa'); + var cnt = document.getElementById('charCnt'); + if (ta) { + if (cnt) { ta.addEventListener('input', function(){ var n=ta.value.length; cnt.textContent=n; cnt.style.color=n>19000?'var(--red)':n>15000?'var(--amber)':''; }); } + ta.addEventListener('paste', function(e){ + var items = (e.clipboardData || e.originalEvent.clipboardData).items; + for (var i=0; i<items.length; i++) { + if (items[i].type.indexOf('image')!==-1) { e.preventDefault(); uploadFileToEditor(items[i].getAsFile(),'replyTa'); break; } + } + }); + ta.addEventListener('dragover', function(e){ e.preventDefault(); ta.classList.add('dragging'); }); + ta.addEventListener('dragleave', function(){ ta.classList.remove('dragging'); }); + ta.addEventListener('drop', function(e){ + e.preventDefault(); ta.classList.remove('dragging'); + var files = e.dataTransfer.files; + for (var i=0; i<files.length; i++) { if (files[i].type.startsWith('image/')) uploadFileToEditor(files[i],'replyTa'); } + }); + } + renderAllMd(); + renderTimeAgo(); + setInterval(renderTimeAgo, 60000); +}); + +/* ── Toast ────────────────────────────────────────────────── */ +var _tc = 0; +function toast(msg, type) { + var col = {ok:'#22c55e',err:'#ef4444',warn:'#f59e0b'}[type||'ok'] || '#3b82f6'; + var t = document.createElement('div'); + t.style.cssText = 'position:fixed;bottom:'+(20+_tc*56)+'px;right:20px;' + +'background:'+col+';color:#fff;padding:11px 16px;border-radius:8px;' + +'font-size:14px;font-family:var(--font,sans-serif);box-shadow:0 4px 16px rgba(0,0,0,.2);' + +'z-index:9999;max-width:320px;line-height:1.4;animation:fadeIn .25s ease'; + t.textContent = msg; + document.body.appendChild(t); + _tc++; + setTimeout(function(){ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(function(){ t.remove(); _tc=Math.max(0,_tc-1); },300); }, 3400); +} + +/* ================================================================ + @mention autocomplete + ================================================================ */ +(function () { + var popup = null; + var popupItems= []; + var popupIdx = -1; + var mentionStart = -1; + var mentionTA = null; + + function createPopup() { + if (popup) return; + popup = document.createElement('div'); + popup.className = 'mention-popup'; + popup.id = 'mentionPopup'; + document.body.appendChild(popup); + } + + function showPopup(ta, users) { + createPopup(); + popupItems = users; + popupIdx = -1; + if (!users.length) { hidePopup(); return; } + popup.innerHTML = users.map(function(u, i){ + var av = u.avatar + ? '<img src="'+esc(u.avatar)+'" class="av-xs" alt="">' + : '<span class="av-xs">'+esc(u.username[0].toUpperCase())+'</span>'; + return '<div class="mention-item" data-i="'+i+'" onclick="insertMention('+i+')">'+av+'@'+esc(u.username)+'</div>'; + }).join(''); + popup.classList.add('show'); + + // Position popup below cursor + var rect = ta.getBoundingClientRect(); + var coords = getCaretCoords(ta, ta.selectionStart); + var top = rect.top + window.scrollY + coords.top + 20; + var left = rect.left + window.scrollX + coords.left; + popup.style.top = top + 'px'; + popup.style.left = left + 'px'; + popup.style.position = 'absolute'; + } + + function hidePopup() { + if (popup) { popup.classList.remove('show'); popup.innerHTML = ''; } + mentionStart = -1; mentionTA = null; popupItems = []; popupIdx = -1; + } + + window.insertMention = function(idx) { + if (!mentionTA || idx < 0 || idx >= popupItems.length) return; + var u = popupItems[idx]; + var val = mentionTA.value; + var pre = val.slice(0, mentionStart); + var post = val.slice(mentionTA.selectionStart); + var ins = '@' + u.username + ' '; + mentionTA.value = pre + ins + post; + var pos = (pre + ins).length; + mentionTA.selectionStart = mentionTA.selectionEnd = pos; + mentionTA.focus(); + hidePopup(); + }; + + function handleMentionKey(e) { + if (!popup || !popup.classList.contains('show')) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + popupIdx = (popupIdx + 1) % popupItems.length; + updateSelected(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + popupIdx = (popupIdx - 1 + popupItems.length) % popupItems.length; + updateSelected(); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (popupIdx >= 0) { e.preventDefault(); insertMention(popupIdx); } + else hidePopup(); + } else if (e.key === 'Escape') { + hidePopup(); + } + } + + function updateSelected() { + popup.querySelectorAll('.mention-item').forEach(function(el, i){ + el.classList.toggle('selected', i === popupIdx); + if (i === popupIdx) el.scrollIntoView({block:'nearest'}); + }); + } + + var mentionTimer; + function handleMentionInput(ta) { + var val = ta.value; + var caret = ta.selectionStart; + // Find the @ that triggers a mention (word char follows) + var before = val.slice(0, caret); + var match = before.match(/@([a-zA-Z0-9_\-]*)$/); + if (!match) { hidePopup(); return; } + var query = match[1]; + mentionStart = caret - match[0].length; + mentionTA = ta; + if (query.length < 1) { hidePopup(); return; } + clearTimeout(mentionTimer); + mentionTimer = setTimeout(function(){ + fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(query)) + .then(function(r){ return r.json(); }) + .then(function(users){ showPopup(ta, users); }) + .catch(function(){ hidePopup(); }); + }, 200); + } + + // Attach to all textareas present or added + function attachMention(ta) { + if (ta.dataset.mentionBound) return; + ta.dataset.mentionBound = '1'; + ta.addEventListener('input', function(){ handleMentionInput(ta); }); + ta.addEventListener('keydown', handleMentionKey); + ta.addEventListener('blur', function(){ setTimeout(hidePopup, 200); }); + } + + document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('textarea.reply-ta, textarea.edit-ta').forEach(attachMention); + // Also attach to dynamically added textareas via MutationObserver + new MutationObserver(function(muts){ + muts.forEach(function(m){ + m.addedNodes.forEach(function(n){ + if (n.nodeType!==1) return; + n.querySelectorAll && n.querySelectorAll('textarea.reply-ta,textarea.edit-ta').forEach(attachMention); + if (n.matches && (n.matches('textarea.reply-ta')||n.matches('textarea.edit-ta'))) attachMention(n); + }); + }); + }).observe(document.body, {childList:true,subtree:true}); + }); + + /* Simple caret coordinate helper */ + function getCaretCoords(el, pos) { + var div = document.createElement('div'); + var style = getComputedStyle(el); + ['fontFamily','fontSize','fontWeight','lineHeight','padding','border','whiteSpace','wordWrap'].forEach(function(p){ + div.style[p] = style[p]; + }); + div.style.position = 'absolute'; div.style.visibility = 'hidden'; + div.style.overflow = 'auto'; div.style.width = el.offsetWidth + 'px'; + var text = el.value.slice(0, pos); + div.textContent = text; + var span = document.createElement('span'); + span.textContent = '|'; + div.appendChild(span); + document.body.appendChild(div); + var coords = { top: span.offsetTop, left: span.offsetLeft }; + document.body.removeChild(div); + return coords; + } +})(); + +/* ================================================================ + @mention rendering in post content + Convert @username text → clickable mention links + ================================================================ */ +function renderMentions(html) { + return html.replace(/@([a-zA-Z0-9_\-]{3,30})/g, function(_, uname) { + return '<a href="' + NX.base + '/users/profile.php?u=' + encodeURIComponent(uname) + + '" class="mention-tag">@' + esc(uname) + '</a>'; + }); +} + +/* Patch md() to run renderMentions after rendering */ +var _origMd = md; +md = function(raw) { + return renderMentions(_origMd(raw)); +}; + +/* ================================================================ + Extended notification renderer (friend requests, mentions, messages) + ================================================================ */ +var _origLoadNotifs = loadNotifs; +loadNotifs = function() { + if (!window.NX || !NX.user) return; + var list = document.getElementById('notifList'); + if (!list) return; + fetch(NX.base + '/api/notifications.php') + .then(function(r){ return r.json(); }) + .then(function(rows){ + if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; } + list.innerHTML = rows.map(function(n) { + var d = n.payload || {}; + var text = ''; + var href = '#'; + switch (n.type) { + case 'reply': + text = '<strong>@'+esc(d.from||'')+'</strong> replied in <em>'+esc(d.title||'')+'</em>'; + if (d.slug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug); + break; + case 'mention': + text = '<strong>@'+esc(d.from||'')+'</strong> mentioned you in a post'; + if (d.topicSlug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.topicSlug)+'#post-'+(d.postId||''); + break; + case 'friend_request': + text = '<strong>@'+esc(d.from||'')+'</strong> sent you a friend request'; + href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||''); + break; + case 'friend_accepted': + text = '<strong>@'+esc(d.from||'')+'</strong> accepted your friend request 🎉'; + href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||''); + break; + case 'message': + text = '<strong>@'+esc(d.from||'')+'</strong> sent you a message: <em>'+esc(d.subject||'')+'</em>'; + href = NX.base+'/messages/'; + break; + case 'karma_admin': + var diff = d.change||0; + var sign = diff >= 0 ? '+' : ''; + text = 'Your karma was adjusted by an admin: <strong>'+sign+diff+'</strong>' + + (d.new ? ' (now '+d.new+')' : '') + + (d.reason ? ' — <em>'+esc(d.reason)+'</em>' : ''); + href = NX.base+'/users/profile.php?u='+encodeURIComponent(NX.user.name||''); + break; + default: + text = esc(n.type); + } + return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">' + +'<div class="notif-item-text">'+text+'</div>' + +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>' + +'</a>'; + }).join(''); + }) + .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; }); +}; + +/* ================================================================ + Edit toggle — show edit form only when Edit button clicked + (edit button itself is always visible; form is hidden by default) + ================================================================ */ +/* doEdit() already exists above — we just make sure edit-box starts hidden via CSS */ + +/* ================================================================ + Twitter/X widget loader + ================================================================ */ +function loadTwitterWidgets() { + var tweets = document.querySelectorAll('.embed-tweet[data-tweet-id]:not([data-loaded])'); + if (!tweets.length) return; + function doLoad() { + tweets.forEach(function(el) { + el.setAttribute('data-loaded','1'); + var id = el.dataset.tweetId; + if (id && window.twttr && window.twttr.widgets) { + window.twttr.widgets.createTweet(id, el, {theme:'light',dnt:true,align:'left'}); + } + }); + } + if (window.twttr && window.twttr.widgets) { doLoad(); return; } + if (document.querySelector('script[src*="platform.twitter.com"]')) { + // Already loading + var interval = setInterval(function(){ + if (window.twttr && window.twttr.widgets) { clearInterval(interval); doLoad(); } + },300); + return; + } + var s = document.createElement('script'); + s.src = 'https://platform.twitter.com/widgets.js'; + s.async = true; + s.onload = doLoad; + document.head.appendChild(s); +} + +/* ================================================================ + Lazy load embeds — for heavy iframes (only load when in viewport) + ================================================================ */ +document.addEventListener('DOMContentLoaded', function(){ + loadTwitterWidgets(); + + // IntersectionObserver for lazy embed loading + if ('IntersectionObserver' in window) { + var obs = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + if (entry.isIntersecting) { + var iframe = entry.target; + if (iframe.dataset.src) { + iframe.src = iframe.dataset.src; + iframe.removeAttribute('data-src'); + obs.unobserve(iframe); + } + } + }); + }, {rootMargin:'200px'}); + + document.querySelectorAll('iframe.embed-yt[data-src]').forEach(function(el){ + obs.observe(el); + }); + } +}); + +/* ================================================================ + Post content — use server-rendered HTML, add edit toggle via CSS + edit-box already hidden in CSS (display:none on .edit-box.hidden) + ================================================================ */ +/* buildPost is used for AJAX-appended posts (no server rendering there) */ +/* so we keep client-side md() for those */ + +/* ── Post permalink copy ─────────────────────────────────── */ +function copyPostLink(postId, btn) { + var baseUrl = btn.getAttribute('data-url'); + var full = window.location.protocol + '//' + window.location.host + baseUrl; + var icon_link = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'; + var icon_check = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>'; + function showCopied() { + btn.innerHTML = icon_check; + btn.title = 'Copied!'; + setTimeout(function(){ btn.innerHTML = icon_link; btn.title = 'Copy link to this post'; }, 2000); + toast('Post link copied!', 'ok'); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(full).then(showCopied).catch(function() { + prompt('Copy this link:', full); + }); + } else { + prompt('Copy this link:', full); + } +} + +/* ── Code block copy ─────────────────────────────────────── */ +function cbCopy(btn) { + // Walk up to .code-block-wrap, then find the <code> element + var wrap = btn.closest('.code-block-wrap'); + if (!wrap) return; + var code = wrap.querySelector('pre.code-block code'); + if (!code) return; + + var text = code.innerText !== undefined ? code.innerText : code.textContent; + + var ok = function() { + var orig = btn.innerHTML; + btn.classList.add('copied'); + btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" ' + + 'stroke-linecap="round" stroke-linejoin="round" width="14" height="14">' + + '<polyline points="20 6 9 17 4 12"/></svg><span>Copied!</span>'; + setTimeout(function() { + btn.classList.remove('copied'); + btn.innerHTML = orig; + }, 2000); + }; + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(ok).catch(function() { + fallbackCopy(text, ok); + }); + } else { + fallbackCopy(text, ok); + } +} + +function fallbackCopy(text, cb) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + try { document.execCommand('copy'); if (cb) cb(); } + catch(e) {} + document.body.removeChild(ta); +} + + + +/* ── Content security: warn on raw HTML/PHP injection attempt ──── */ +(function () { + // Patterns that suggest someone is typing raw code to inject + var DANGEROUS = [ + /<script/i, + /<iframe/i, + /<object/i, + /<embed/i, + /<form/i, + /<base\s/i, + /<link\s/i, + /<meta\s/i, + /<svg[\s>]/i, + /<\?php/i, + /<\?=/, + /javascript\s*:/i, + /vbscript\s*:/i, + /on\w+\s*=/i, // onerror=, onclick=, onload= etc. + /data\s*:\s*text\/html/i, + ]; + + function checkContent(ta) { + var val = ta.value; + for (var i = 0; i < DANGEROUS.length; i++) { + if (DANGEROUS[i].test(val)) { + showHtmlWarning(ta); + return; + } + } + hideHtmlWarning(ta); + } + + function showHtmlWarning(ta) { + var id = 'sec-warn-' + ta.id; + if (document.getElementById(id)) return; + var box = document.createElement('div'); + box.id = id; + box.className = 'sec-warning'; + box.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + + 'stroke-linecap="round" stroke-linejoin="round" width="15" height="15">' + + '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>' + + '<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>' + + '<span>Raw HTML/code detected. For security, HTML is stripped on save. ' + + 'Use ``` code blocks ``` to display code.</span>'; + ta.parentNode.insertBefore(box, ta.nextSibling); + } + + function hideHtmlWarning(ta) { + var id = 'sec-warn-' + ta.id; + var el = document.getElementById(id); + if (el) el.remove(); + } + + function attachTo(taId) { + var ta = document.getElementById(taId); + if (!ta) return; + ta.addEventListener('input', function () { checkContent(ta); }); + ta.addEventListener('paste', function () { + setTimeout(function () { checkContent(ta); }, 10); + }); + } + + // Attach to both editors on page load and after AJAX reply renders + document.addEventListener('DOMContentLoaded', function () { + attachTo('replyTa'); + }); + + // Expose for dynamic attachment + window.attachSecCheck = attachTo; +})(); + +/* ── Welcome Guide addon toggle ──────────────────────────── */ +function wgToggle() { + var b = document.querySelector('.wg-body'); + var btn = document.querySelector('.wg-toggle'); + if (!b) return; + var open = b.style.display === 'none'; + b.style.display = open ? '' : 'none'; + if (btn) { + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + btn.classList.toggle('wg-open', open); + } +} diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess new file mode 100644 index 0000000..c7c121e --- /dev/null +++ b/public/uploads/.htaccess @@ -0,0 +1,11 @@ +# Deny PHP/script execution in the uploads directory +# Images are served directly; no code should run here. +<FilesMatch "\.(php|php3|php4|php5|php7|phtml|shtml|cgi|pl|py|rb|sh|bash)$"> + <IfModule mod_authz_core.c> + Require all denied + </IfModule> + <IfModule !mod_authz_core.c> + Order Allow,Deny + Deny from all + </IfModule> +</FilesMatch> diff --git a/users/edit.php b/users/edit.php new file mode 100644 index 0000000..33dfe57 --- /dev/null +++ b/users/edit.php @@ -0,0 +1,133 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +must_login(); +$err = $ok = null; + +if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) { + $bio = sanitise(post('bio')); + $location = mb_substr(sanitise(post('location')), 0, 100); + $friends_hidden = isset($_POST['friends_hidden']) ? 1 : 0; + $avUrl = $USER['avatar']; + + // Avatar upload + if (!empty($_FILES['avatar']['name'])) { + $f = $_FILES['avatar']; + if ($f['size'] > 2*1024*1024) { $err = 'Avatar must be under 2 MB.'; } + elseif (!in_array($f['type'],['image/jpeg','image/png','image/gif','image/webp'])) { $err='Image files only.'; } + else { + $ext = strtolower(pathinfo($f['name'],PATHINFO_EXTENSION)); + $name = 'av_'.$USER['id'].'_'.time().'.'.$ext; + $dir = UPLOADS.'/avatars/'; + if (!is_dir($dir)) mkdir($dir,0755,true); + if (move_uploaded_file($f['tmp_name'],$dir.$name)) { + $avUrl = BASE.'/public/uploads/avatars/'.$name; + } else { $err='Upload failed.'; } + } + } + + if (!$err) { + DB::run('UPDATE users SET bio=?,avatar=?,friends_hidden=?,location=? WHERE id=?', + [$bio?:null, $avUrl, $friends_hidden, $location, $USER['id']]); + + // Password change + $np = post('new_password'); + if ($np) { + if (!password_verify(post('cur_password'), $USER['password'])) { $err='Current password incorrect.'; } + elseif (strlen($np)<8) { $err='New password must be 8+ characters.'; } + elseif ($np!==post('new_password2')) { $err='New passwords do not match.'; } + else { + DB::run('UPDATE users SET password=? WHERE id=?', + [password_hash($np,PASSWORD_BCRYPT,['cost'=>12]),$USER['id']]); + } + } + if (!$err) { $ok='Profile updated!'; $USER=current_user(); } + } +} + +$PAGE_TITLE = 'Edit Profile'; +include __DIR__ . '/../views/partials/layout.php'; +?> +<nav class="bc"> + <a href="<?=u('/')?>">Home</a> › + <a href="<?=u('users/profile.php?u='.urlencode($USER['username']))?>">@<?=e($USER['username'])?></a> › + <span>Edit Profile</span> +</nav> +<div class="form-card"> + <h1>Edit Profile</h1> + <?php if($err):?><div class="alert err"><?=e($err)?></div><?php endif;?> + <?php if($ok): ?><div class="alert ok"><?=e($ok)?></div><?php endif;?> + <form method="POST" enctype="multipart/form-data"> + <?=csrf_input()?> + + <div class="form-section"> + <h2>Profile Picture</h2> + <div class="av-upload"> + <?php if($USER['avatar']):?> + <img src="<?=e($USER['avatar'])?>" class="av-xl" id="avPrev" alt=""> + <?php else:?> + <span class="av-xl av-init" id="avPrev"><?=strtoupper($USER['username'][0])?></span> + <?php endif;?> + <div> + <label for="avatar" class="btn-ghost" style="cursor:pointer">Choose Image</label> + <input type="file" id="avatar" name="avatar" accept="image/*" style="display:none" onchange="prevAv(this)"> + <p class="hint">Max 2 MB · JPG, PNG, GIF, WebP</p> + </div> + </div> + </div> + + <div class="form-section"> + <h2>About Me</h2> + <div class="fg"> + <label for="bio">Bio</label> + <textarea name="bio" id="bio" class="fi" rows="4" maxlength="500" + placeholder="Tell the community about yourself…"><?=e($USER['bio']??'')?></textarea> + <span class="hint"><span id="bioLen"><?=mb_strlen($USER['bio']??'')?></span>/500</span> + </div> + <div class="fg"> + <label for="location"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" style="vertical-align:middle;margin-right:4px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> + Location <small class="hint" style="display:inline">(optional)</small> + </label> + <input type="text" name="location" id="location" class="fi" maxlength="100" + value="<?=e($USER['location']??'')?>" placeholder="City, Country"> + <span class="hint">Shown on your posts and profile</span> + </div> + </div> + + <div class="form-section"> + <h2>Privacy</h2> + <label class="toggle-label" style="cursor:pointer"> + <div> + <strong>Hide Friends List</strong> + <p class="hint">Others won't see your friends list on your profile</p> + </div> + <label class="toggle-sw"> + <input type="checkbox" name="friends_hidden" <?=$USER['friends_hidden']?'checked':''?>> + <span class="toggle-knob"></span> + </label> + </label> + </div> + + <div class="form-section"> + <h2>Change Password</h2> + <p class="hint">Leave blank to keep your current password.</p> + <div class="fg"><label>Current Password</label><input type="password" name="cur_password" class="fi" placeholder="Current password"></div> + <div class="fg"><label>New Password</label><input type="password" name="new_password" class="fi" placeholder="Min. 8 characters"></div> + <div class="fg"><label>Confirm New Password</label><input type="password" name="new_password2" class="fi" placeholder="Repeat new password"></div> + </div> + + <div class="form-actions"> + <a href="<?=u('users/profile.php?u='.urlencode($USER['username']))?>" class="btn-ghost">Cancel</a> + <button type="submit" class="btn-primary">Save Changes</button> + </div> + </form> +</div> +<script> +function prevAv(i){if(!i.files[0])return;var r=new FileReader();r.onload=function(e){var p=document.getElementById('avPrev');if(p.tagName==='IMG'){p.src=e.target.result;}else{var img=document.createElement('img');img.src=e.target.result;img.className='av-xl';img.id='avPrev';p.replaceWith(img);}};r.readAsDataURL(i.files[0]);} +document.getElementById('bio').addEventListener('input',function(){document.getElementById('bioLen').textContent=this.value.length;}); +</script> + +<style> +.toggle-label { display:flex; align-items:center; justify-content:space-between; padding:8px 0; } +</style> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/users/profile.php b/users/profile.php new file mode 100644 index 0000000..7537f73 --- /dev/null +++ b/users/profile.php @@ -0,0 +1,250 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; +$uname = get('u'); +$profile = DB::row('SELECT * FROM users WHERE username=?', [$uname]); +if (!$profile) render_404(); + +$topics = DB::rows(" + SELECT t.*,c.name AS cat_name,c.slug AS cat_slug + FROM topics t JOIN categories c ON c.id=t.category_id + WHERE t.user_id=? ORDER BY t.created_at DESC LIMIT 15 +", [$profile['id']]); + +$posts = DB::rows(" + SELECT p.*,t.title AS topic_title,t.slug AS topic_slug + FROM posts p JOIN topics t ON t.id=p.topic_id + WHERE p.user_id=? AND p.deleted=0 ORDER BY p.created_at DESC LIMIT 15 +", [$profile['id']]); + +$showFriends = !$profile['friends_hidden'] + || ($USER && (int)$USER['id'] === (int)$profile['id']) + || ($USER && is_admin()); + +$friendsList = $showFriends ? friends_list((int)$profile['id']) : []; +$fStatus = $USER ? friend_status((int)$USER['id'], (int)$profile['id']) : 'none'; +$kTier = karma_tier((int)$profile['karma']); + +$PAGE_TITLE = '@' . $profile['username']; +include __DIR__ . '/../views/partials/layout.php'; +?> + +<div class="profile-hdr"> + <!-- Avatar --> + <div class="profile-av"> + <?php if ($profile['avatar']): ?> + <img src="<?= e($profile['avatar']) ?>" class="av-xl" alt=""> + <?php else: ?> + <span class="av-xl av-init"><?= strtoupper($profile['username'][0]) ?></span> + <?php endif; ?> + <!-- Karma tier badge under avatar — green leaf --> + <div class="profile-tier-badge profile-tier-badge-green"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> + <?= e($kTier['name']) ?> + </div> + </div> + + <!-- Info --> + <div class="profile-info"> + <div class="profile-name-row"> + <h1>@<?= e($profile['username']) ?></h1> + <span class="role-tag role-<?= e($profile['role']) ?>"><?= e($profile['role']) ?></span> + <?php if ($profile['suspended']): ?> + <span class="role-tag role-admin">Suspended</span> + <?php endif; ?> + </div> + <?php if ($profile['bio']): ?> + <p class="profile-bio"><?= e($profile['bio']) ?></p> + <?php endif; ?> + <div class="profile-meta"> + <span>📅 Joined <span class="ago" data-ts="<?= e($profile['joined_at']) ?>"></span></span> + <span>🕐 Last seen <span class="ago" data-ts="<?= e($profile['last_seen']) ?>"></span></span> + <?php if (!empty($profile['location'])): ?> + <span> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13" style="vertical-align:middle"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg> + <?= e($profile['location']) ?> + </span> + <?php endif; ?> + </div> + + <!-- Karma bar — always green --> + <div class="profile-karma-row"> + <div class="pkr-head"> + <span class="pkr-icon"> + <svg viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg> + </span> + <span class="pkr-label pkr-label-green"><?= e($kTier['name']) ?></span> + <span class="pkr-pts"><?= number_format((int)$profile['karma']) ?> karma</span> + </div> + <div class="pkr-bar"> + <div class="pkr-fill pkr-fill-green" style="width:<?= $kTier['progress'] ?>%"></div> + </div> + <?php if ($kTier['next'] !== null): ?> + <div class="pkr-next"> + <?= $kTier['progress'] ?>% · <?= number_format($kTier['next'] - (int)$profile['karma']) ?> more to <?= e(karma_tier($kTier['next'])['name']) ?> + </div> + <?php else: ?> + <div class="pkr-next">🏆 Maximum tier reached!</div> + <?php endif; ?> + </div> + </div> + + <!-- Stats --> + <div class="profile-stats"> + <div class="pstat"> + <strong><?= number_format($profile['post_count']) ?></strong> + <span>Posts</span> + </div> + <div class="pstat"> + <strong><?= number_format($profile['topic_count']) ?></strong> + <span>Topics</span> + </div> + <div class="pstat karma-stat" style="--kc:<?= e($kTier['color']) ?>"> + <strong><?= number_format((int)$profile['karma']) ?></strong> + <span><?= $kTier['icon'] ?> Karma</span> + </div> + <div class="pstat"> + <strong><?= count($friendsList) ?></strong> + <span>Friends</span> + </div> + </div> + + <!-- Actions --> + <div class="profile-actions"> + <?php if ($USER && $USER['username'] === $profile['username']): ?> + <a href="<?= u('users/edit.php') ?>" class="btn-ghost btn-sm">✏️ Edit Profile</a> + <a href="<?= u('messages/') ?>" class="btn-ghost btn-sm">📬 Messages</a> + <?php elseif ($USER): ?> + <?php if ($fStatus === 'none'): ?> + <button class="btn-primary btn-sm" onclick="friendAction('send',<?= $profile['id'] ?>,this)">+ Add Friend</button> + <?php elseif ($fStatus === 'pending_sent'): ?> + <button class="btn-ghost btn-sm" onclick="friendAction('cancel',<?= $profile['id'] ?>,this)">✓ Sent (Cancel)</button> + <?php elseif ($fStatus === 'pending_received'): ?> + <button class="btn-primary btn-sm" onclick="friendAction('accept',<?= $profile['id'] ?>,this)">✓ Accept</button> + <button class="btn-ghost btn-sm" onclick="friendAction('decline',<?= $profile['id'] ?>,this)">✗ Decline</button> + <?php elseif ($fStatus === 'friends'): ?> + <button class="btn-ghost btn-sm" onclick="friendAction('remove',<?= $profile['id'] ?>,this)">👥 Friends</button> + <?php endif; ?> + <a href="<?= u('messages/compose.php?to='.urlencode($profile['username'])) ?>" class="btn-ghost btn-sm">✉️ Message</a> + <?php endif; ?> + + <?php if ($USER && is_admin() && (int)$USER['id'] !== (int)$profile['id']): ?> + <a href="<?= u('admin/user.php?id='.$profile['id']) ?>" class="btn-ghost btn-sm">🛡️ Admin</a> + <?php endif; ?> + </div> +</div> + +<!-- Pending friend requests (profile owner only) --> +<?php if ($USER && (int)$USER['id'] === (int)$profile['id']): ?> + <?php $pending = pending_requests((int)$USER['id']); ?> + <?php if ($pending): ?> + <div class="pending-requests"> + <h3>📥 Friend Requests (<?= count($pending) ?>)</h3> + <?php foreach ($pending as $req): ?> + <div class="pending-row"> + <?php if ($req['avatar']): ?> + <img src="<?= e($req['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($req['username'][0]) ?></span> + <?php endif; ?> + <a href="<?= u('users/profile.php?u='.urlencode($req['username'])) ?>">@<?= e($req['username']) ?></a> + <button class="btn-primary btn-sm" onclick="friendAction('accept',<?= $req['id'] ?>,this)">Accept</button> + <button class="btn-ghost btn-sm" onclick="friendAction('decline',<?= $req['id'] ?>,this)">Decline</button> + </div> + <?php endforeach; ?> + </div> + <?php endif; ?> +<?php endif; ?> + +<!-- Tabs --> +<div class="profile-tabs"> + <button class="tab-btn active" onclick="showTab('topics',this)"> + Topics <span class="tab-count"><?= count($topics) ?></span> + </button> + <button class="tab-btn" onclick="showTab('replies',this)"> + Replies <span class="tab-count"><?= count($posts) ?></span> + </button> + <?php if ($showFriends): ?> + <button class="tab-btn" onclick="showTab('friends',this)"> + Friends <span class="tab-count"><?= count($friendsList) ?></span> + </button> + <?php endif; ?> +</div> + +<!-- Topics tab --> +<div id="tab-topics" class="tab-pane"> + <?php foreach ($topics as $t): ?> + <div class="topic-row"> + <div class="tr-body"> + <a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" class="tr-title"><?= e($t['title']) ?></a> + <div class="tr-meta"> + <a href="<?= u('forum/category.php?slug='.urlencode($t['cat_slug'])) ?>" + class="cat-tag" style="--cc:#3b82f6"><?= e($t['cat_name']) ?></a> + <span class="ago" data-ts="<?= e($t['created_at']) ?>"></span> + </div> + </div> + <div class="tr-counts"><span>💬 <?= $t['reply_count'] ?></span></div> + </div> + <?php endforeach; ?> + <?php if (!$topics): ?><p class="empty-msg">No topics yet.</p><?php endif; ?> +</div> + +<!-- Replies tab --> +<div id="tab-replies" class="tab-pane hidden"> + <?php foreach ($posts as $p): ?> + <div class="reply-card"> + <div class="rc-top"> + <a href="<?= u('forum/topic.php?slug='.urlencode($p['topic_slug']).'#post-'.$p['id']) ?>"> + ↩ <?= e($p['topic_title']) ?> + </a> + <span class="ago" data-ts="<?= e($p['created_at']) ?>"></span> + </div> + <div class="rc-body md" data-raw="<?= e(base64_encode($p['content'])) ?>"><?= e($p['content']) ?></div> + </div> + <?php endforeach; ?> + <?php if (!$posts): ?><p class="empty-msg">No replies yet.</p><?php endif; ?> +</div> + +<!-- Friends tab --> +<?php if ($showFriends): ?> + <div id="tab-friends" class="tab-pane hidden"> + <?php if ($friendsList): ?> + <div class="friends-grid"> + <?php foreach ($friendsList as $f): ?> + <?php $ft = karma_tier((int)$f['karma']); ?> + <a href="<?= u('users/profile.php?u='.urlencode($f['username'])) ?>" class="friend-card"> + <?php if ($f['avatar']): ?> + <img src="<?= e($f['avatar']) ?>" class="av-md" alt=""> + <?php else: ?> + <span class="av-md av-init"><?= strtoupper($f['username'][0]) ?></span> + <?php endif; ?> + <div class="friend-name">@<?= e($f['username']) ?></div> + <div class="friend-karma" style="color:<?= e($ft['color']) ?>"><?= $ft['icon'] ?> <?= number_format((int)$f['karma']) ?></div> + <div class="friend-role"><span class="role-tag role-<?= e($f['role']) ?>"><?= e($f['role']) ?></span></div> + </a> + <?php endforeach; ?> + </div> + <?php else: ?> + <p class="empty-msg">No friends yet.</p> + <?php endif; ?> + </div> +<?php endif; ?> + +<script> +function showTab(name,btn){ + document.querySelectorAll('.tab-pane').forEach(function(p){p.classList.add('hidden');}); + document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active');}); + document.getElementById('tab-'+name).classList.remove('hidden'); + btn.classList.add('active'); +} +function friendAction(action,targetId,btn){ + var fd=new FormData(); + fd.append('action',action);fd.append('target_id',targetId);fd.append('csrf',NX.csrf); + fetch(NX.base+'/api/friend.php',{method:'POST',body:fd}) + .then(function(r){return r.json();}) + .then(function(d){ + if(d.ok){toast(d.msg,'ok');setTimeout(function(){location.reload();},800);} + else toast(d.error,'err'); + }); +} +</script> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/users/search.php b/users/search.php new file mode 100644 index 0000000..966e4d7 --- /dev/null +++ b/users/search.php @@ -0,0 +1,85 @@ +<?php +require_once __DIR__ . '/../includes/bootstrap.php'; + +$q = get('q'); +$results = []; + +if (mb_strlen($q) >= 2) { + $results = DB::rows( + "SELECT u.id, u.username, u.avatar, u.role, u.karma, u.post_count, u.topic_count, + u.joined_at, u.bio + FROM users u + WHERE (u.username LIKE ? OR u.bio LIKE ?) AND u.suspended=0 + ORDER BY u.post_count DESC LIMIT 40", + ['%'.$q.'%', '%'.$q.'%'] + ); +} + +$PAGE_TITLE = 'Find Users'; +include __DIR__ . '/../views/partials/layout.php'; +?> +<div style="max-width:720px"> + <div class="sec-title" style="margin-bottom:20px"> + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> + Find Users + </div> + + <form method="GET" style="display:flex;gap:10px;margin-bottom:24px"> + <input type="text" name="q" value="<?= e($q) ?>" class="fi" style="flex:1;max-width:500px" + placeholder="Search by username or bio…" autofocus autocomplete="off"> + <button type="submit" class="btn-primary">Search</button> + </form> + + <?php if ($q && mb_strlen($q) >= 2): ?> + <p style="color:var(--muted);font-size:14px;margin-bottom:16px"> + <?= count($results) ?> result<?= count($results) !== 1 ? 's' : '' ?> for + "<strong><?= e($q) ?></strong>" + </p> + + <?php if (empty($results)): ?> + <div class="empty-state"> + <p>No users found matching "<?= e($q) ?>".</p> + </div> + <?php else: ?> + <div class="user-search-grid"> + <?php foreach ($results as $u): + $kt = karma_tier((int)$u['karma']); ?> + <a href="<?= u('users/profile.php?u=' . urlencode($u['username'])) ?>" class="user-search-card"> + <div class="usc-av"> + <?php if ($u['avatar']): ?> + <img src="<?= e($u['avatar']) ?>" class="av-lg" alt=""> + <?php else: ?> + <span class="av-lg av-init"><?= strtoupper($u['username'][0]) ?></span> + <?php endif; ?> + </div> + <div class="usc-body"> + <div class="usc-name"> + @<?= e($u['username']) ?> + <span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span> + </div> + <?php if ($u['bio']): ?> + <div class="usc-bio"><?= e(mb_substr($u['bio'], 0, 80)) ?><?= mb_strlen($u['bio']) > 80 ? '…' : '' ?></div> + <?php endif; ?> + <div class="usc-stats"> + <span><?= number_format($u['post_count']) ?> posts</span> + <span>·</span> + <span style="color:<?= e($kt['color']) ?>"><?= $kt['icon'] ?> <?= number_format((int)$u['karma']) ?> karma</span> + </div> + </div> + <?php if ($USER && (int)$USER['id'] !== (int)$u['id']): ?> + <div class="usc-actions"> + <a href="<?= u('messages/chat.php?with=' . urlencode($u['username'])) ?>" + class="btn-ghost btn-sm" onclick="event.preventDefault();event.stopPropagation();window.location=this.href"> + 💬 Message + </a> + </div> + <?php endif; ?> + </a> + <?php endforeach; ?> + </div> + <?php endif; ?> + <?php elseif ($q): ?> + <div class="alert warn">Please enter at least 2 characters to search.</div> + <?php endif; ?> +</div> +<?php include __DIR__ . '/../views/partials/layout_end.php'; ?> diff --git a/views/partials/admin_layout.php b/views/partials/admin_layout.php new file mode 100644 index 0000000..a380b1d --- /dev/null +++ b/views/partials/admin_layout.php @@ -0,0 +1,42 @@ +<?php +?> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> + <title><?= e($PAGE_TITLE??'Admin') ?> — Admin — <?= e(cfg('site_name')) ?></title> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="<?= asset('css/main.css') ?>"> + <link rel="stylesheet" href="<?= asset('css/admin.css') ?>"> +</head> +<body class="admin-body"> +<div class="admin-wrap"> + <aside class="admin-sb"> + <div class="admin-sb-top"> + <a href="<?= u('/') ?>" class="admin-logo"> + <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div> + <div> + <div class="admin-logo-name"><?= e(cfg('site_name','Nexus Forum')) ?></div> + <div class="admin-logo-sub">Admin Panel</div> + </div> + </a> + </div> + <nav class="admin-nav"> + <?php $p = $ADMIN_PAGE ?? ''; ?> + <a href="<?= u('admin/') ?>" class="anav <?= $p==='dashboard' ?'on':'' ?>">📊 Dashboard</a> + <a href="<?= u('admin/users.php') ?>" class="anav <?= $p==='users' ?'on':'' ?>">👥 Users</a> + <a href="<?= u('admin/categories.php') ?>" class="anav <?= $p==='categories'?'on':'' ?>">📂 Categories</a> + <a href="<?= u('admin/topics.php') ?>" class="anav <?= $p==='topics' ?'on':'' ?>">💬 Topics</a> + <a href="<?= u('admin/themes.php') ?>" class="anav <?= $p==='themes' ?'on':'' ?>">🎨 Themes</a> + <a href="<?= u('admin/settings.php') ?>" class="anav <?= $p==='settings' ?'on':'' ?>">⚙️ Settings</a> + <a href="<?= u('admin/addons.php') ?>" class="anav <?= $p==='addons'?'on':'' ?>">🧩 Addons</a> + <div class="anav-div"></div> + <a href="<?= u('/') ?>" class="anav">🏠 Back to Forum</a> + </nav> + </aside> + <div class="admin-main"> + <header class="admin-topbar"> + <h1><?= e($PAGE_TITLE??'Admin') ?></h1> + <span>@<?= e($USER['username']) ?></span> + </header> + <div class="admin-content"> diff --git a/views/partials/admin_layout_end.php b/views/partials/admin_layout_end.php new file mode 100644 index 0000000..bdfc8eb --- /dev/null +++ b/views/partials/admin_layout_end.php @@ -0,0 +1,8 @@ + </div><!-- /.admin-content --> + </div><!-- /.admin-main --> +</div><!-- /.admin-wrap --> +<script> +var NX={base:'<?= BASE ?>',csrf:'<?= csrf() ?>',user:<?= json_encode(['id'=>(int)$USER['id'],'name'=>$USER['username'],'role'=>$USER['role']]) ?>}; +</script> +<script src="<?= asset('js/app.js') ?>"></script> +</body></html> diff --git a/views/partials/layout.php b/views/partials/layout.php new file mode 100644 index 0000000..61a0d36 --- /dev/null +++ b/views/partials/layout.php @@ -0,0 +1,139 @@ +<?php +$_title = isset($PAGE_TITLE) ? e($PAGE_TITLE) . ' — ' : ''; +$_site = e(cfg('site_name', 'Nexus Forum')); +$_unread = unread_count(); +$_msgs = unread_messages(); +$_cats = nav_categories(); +?> +<!DOCTYPE html> +<html lang="en"> +<script> +(function(){ + try{ + var t=localStorage.getItem('nexus-theme'); + var p=window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches; + if(t==='dark'||(t===null&&p)) document.documentElement.setAttribute('data-theme','dark'); + }catch(e){} +})(); +</script> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title><?= $_title . $_site ?></title> + <link rel="preconnect" href="https://fonts.googleapis.com"> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> + <link rel="stylesheet" href="<?= asset('css/main.css') ?>"> + <?= active_theme_css() ?> + <?= cfg('custom_head') ?> +<?php /* ── before_page_head addon hook (collector) ── */ +echo addon_collect('before_page_head'); +?> +</head> +<body> +<header class="site-header"> + <div class="hdr-inner"> + <div class="hdr-left"> + <button class="burger" id="burgerBtn"><span></span><span></span><span></span></button> + <a href="<?= u('/') ?>" class="logo"> + <?php if (cfg('logo_url')): ?> + <img src="<?= e(cfg('logo_url')) ?>" alt="<?= $_site ?>" class="logo-img"> + <?php else: ?> + <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div> + <?php endif; ?> + <span class="logo-name"><?= $_site ?></span> + </a> + </div> + + <div class="hdr-search"> + <div class="search-wrap"> + <svg class="srch-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13.5 13.5 3 3"/></svg> + <input type="text" id="searchInput" placeholder="Search topics…" autocomplete="off"> + <div class="search-results" id="searchResults"></div> + </div> + </div> + + <div class="hdr-right"> + <?php if ($USER): ?> + + <!-- Messages icon --> + <a href="<?= u('messages/') ?>" class="icon-btn" title="Messages" style="position:relative"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg> + <?php if ($_msgs > 0): ?> + <span class="badge-dot"><?= $_msgs > 9 ? '9+' : $_msgs ?></span> + <?php endif; ?> + </a> + + <!-- Notifications --> + <!-- Dark mode toggle --> + <button class="icon-btn" id="themeToggle" onclick="toggleTheme()" title="Toggle dark mode" aria-label="Toggle dark mode"> + <svg class="theme-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> + <svg class="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg> + </button> + <div class="notif-wrap" id="notifWrap"> + <button class="icon-btn" id="notifBtn" title="Notifications"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> + <?php if ($_unread > 0): ?> + <span class="badge-dot"><?= $_unread > 9 ? '9+' : $_unread ?></span> + <?php endif; ?> + </button> + <div class="notif-drop" id="notifDrop"> + <div class="notif-head"><span>Notifications</span><button id="readAllBtn" class="link-btn">Mark all read</button></div> + <div id="notifList"><p class="notif-empty">Loading…</p></div> + </div> + </div> + + <!-- User menu --> + <div class="user-wrap" id="userWrap"> + <button class="avatar-btn" id="userBtn"> + <?php if ($USER['avatar']): ?> + <img src="<?= e($USER['avatar']) ?>" class="av-sm" alt=""> + <?php else: ?> + <span class="av-sm av-init"><?= strtoupper($USER['username'][0]) ?></span> + <?php endif; ?> + </button> + <div class="user-drop" id="userDrop"> + <div class="user-drop-top"> + <strong>@<?= e($USER['username']) ?></strong> + <span class="role-tag role-<?= e($USER['role']) ?>"><?= e($USER['role']) ?></span> + </div> + <a href="<?= u('users/profile.php?u='.urlencode($USER['username'])) ?>">👤 My Profile</a> + <a href="<?= u('messages/') ?>">📬 Messages <?php if ($_msgs>0):?><span class="badge-dot" style="position:static;margin-left:4px"><?=$_msgs?></span><?php endif;?></a> + <a href="<?= u('users/edit.php') ?>">✏️ Edit Profile</a> + <?php if (is_admin()): ?> + <a href="<?= u('admin/') ?>" class="admin-lnk">🛡️ Admin Panel</a> + <?php endif; ?> + <div class="drop-div"></div> + <a href="<?= u('auth/logout.php') ?>" class="logout-lnk">🚪 Log Out</a> + </div> + </div> + + <?php else: ?> + <a href="<?= u('auth/login.php') ?>" class="btn-ghost">Log In</a> + <a href="<?= u('auth/register.php') ?>" class="btn-primary">Sign Up</a> + <?php endif; ?> + </div> + </div> +</header> + +<div class="sb-overlay" id="sbOverlay"></div> +<aside class="sidebar" id="sidebar"> + <nav> + <a href="<?= u('/') ?>" class="nav-link">🏠 Home</a> + <?php if ($USER): ?> + <a href="<?= u('forum/new-topic.php') ?>" class="nav-link new-link">+ New Topic</a> + <a href="<?= u('messages/') ?>" class="nav-link">📬 Messages<?php if($_msgs>0):?> <span style="background:#ef4444;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;font-weight:700"><?=$_msgs?></span><?php endif;?></a> + <?php endif; ?> + <div class="nav-sep">Categories</div> + <?php foreach ($_cats as $c): ?> + <a href="<?= u('forum/category.php?slug='.urlencode($c['slug'])) ?>" class="nav-link"> + <span class="cat-dot" style="background:<?= e($c['color']) ?>"></span> + <?= e($c['icon']) ?> <?= e($c['name']) ?> + </a> + <?php endforeach; ?> + <div class="nav-sep">More</div> + <a href="<?= u('forum/search.php') ?>" class="nav-link">🔍 Search</a> + </nav> +</aside> + +<main class="main"><div class="wrap"> diff --git a/views/partials/layout_end.php b/views/partials/layout_end.php new file mode 100644 index 0000000..a5b5868 --- /dev/null +++ b/views/partials/layout_end.php @@ -0,0 +1,95 @@ + </div><!-- /.wrap --> +</main> + +<?php $stats = forum_stats(); ?> +<footer class="site-footer"> + <div class="footer-inner"> + <div class="footer-stats"> + <span title="Topics">💬 <strong><?= number_format($stats['topics']) ?></strong> topics</span> + <span class="fs-div">·</span> + <span title="Posts">📝 <strong><?= number_format($stats['posts']) ?></strong> posts</span> + <span class="fs-div">·</span> + <span title="Members">👥 <strong><?= number_format($stats['users']) ?></strong> members</span> + <span class="fs-div">·</span> + <span title="Online now">🟢 <strong><?= $stats['online'] ?></strong> online</span> + <?php if ($stats['newest']): ?> + <span class="fs-div">·</span> + <span>Newest: <a href="<?= u('users/profile.php?u='.urlencode($stats['newest']['username'])) ?>">@<?= e($stats['newest']['username']) ?></a></span> + <?php endif; ?> + </div> + <div class="footer-right"> + <span><?= e(cfg('site_name','Nexus Forum')) ?></span> + <?php if ($USER && is_admin()): ?> + <a href="<?= u('admin/') ?>">Admin</a> + <?php endif; ?> + </div> + </div> +</footer> + +<script> +var NX = { + base: '<?= BASE ?>', + csrf: '<?= csrf() ?>', + maxUploadMb: <?= (int)(cfg('max_upload_mb', '5')) ?>, + user: <?= $USER ? json_encode(['id'=>(int)$USER['id'],'name'=>$USER['username'],'role'=>$USER['role']]) : 'null' ?> +}; +</script> +<!-- Prism.js syntax tokenizer — loaded lazily only when a code block with a language exists --> +<!-- We use our own token color theme from main.css, NOT Prism's default theme CSS --> +<script> +(function(){ + if (!document.querySelector('.code-block-wrap pre.code-block code[class]')) return; + // Load Prism core + autoloader (no theme CSS — we provide our own via main.css) + var s = document.createElement('script'); + s.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js'; + s.onload = function() { + var a = document.createElement('script'); + a.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js'; + a.onload = function() { + // Autoloader path for language components + if (Prism.plugins && Prism.plugins.autoloader) { + Prism.plugins.autoloader.languages_path = + 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/'; + } + Prism.highlightAll(); + }; + document.body.appendChild(a); + }; + document.body.appendChild(s); +})(); +</script> +<script src="<?= asset('js/app.js') ?>"></script> + +<script> +/* ── Dark mode ────────────────────────────────────────────── */ +(function(){ + var stored = localStorage.getItem('nexus-theme'); + var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches; + var dark = stored ? stored === 'dark' : prefersDark; + if (dark) document.documentElement.setAttribute('data-theme','dark'); + updateThemeIcons(dark); +})(); + +function toggleTheme(){ + var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + var newDark = !isDark; + if (newDark) { + document.documentElement.setAttribute('data-theme','dark'); + localStorage.setItem('nexus-theme','dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + localStorage.setItem('nexus-theme','light'); + } + updateThemeIcons(newDark); +} + +function updateThemeIcons(dark){ + var light = document.querySelector('.theme-icon-light'); + var moon = document.querySelector('.theme-icon-dark'); + if (light) light.style.display = dark ? 'none' : ''; + if (moon) moon.style.display = dark ? '' : 'none'; +} +</script> + +</body> +</html>