aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-03-21 22:36:11 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-03-21 22:36:11 +0100
commitd63e3c91a97d77b202e280ab0fa007dfbe1baa46 (patch)
treecd3533bfb947ea753d91f71a75406644a73d678d /src
parentf60a390f5c51039fd1efc1df9a6a7f3864ce0062 (diff)
downloadcamagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.tar.gz
camagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.zip
Add editor with webcam/upload capture, overlay compositing, and gallery feed
Diffstat (limited to 'src')
-rw-r--r--src/app/Controllers/EditorController.php157
-rw-r--r--src/app/Controllers/GalleryController.php32
-rw-r--r--src/app/Models/Post.php71
-rw-r--r--src/app/Views/editor/index.php49
-rw-r--r--src/app/Views/gallery/index.php40
-rw-r--r--src/config/routes.php7
-rw-r--r--src/public/assets/overlays/chest.pngbin0 -> 115538 bytes
-rw-r--r--src/public/assets/overlays/cloud.pngbin0 -> 79115 bytes
-rw-r--r--src/public/assets/overlays/eagle.pngbin0 -> 63116 bytes
-rw-r--r--src/public/assets/overlays/explosion.pngbin0 -> 117861 bytes
-rw-r--r--src/public/assets/overlays/fire.pngbin0 -> 97785 bytes
-rw-r--r--src/public/assets/overlays/hat.pngbin0 -> 59733 bytes
-rw-r--r--src/public/assets/overlays/heart.pngbin0 -> 64053 bytes
-rw-r--r--src/public/assets/overlays/rainbow.pngbin0 -> 77826 bytes
-rw-r--r--src/public/assets/overlays/water.pngbin0 -> 108751 bytes
-rw-r--r--src/public/css/style.css203
-rw-r--r--src/public/js/app.js200
17 files changed, 758 insertions, 1 deletions
diff --git a/src/app/Controllers/EditorController.php b/src/app/Controllers/EditorController.php
new file mode 100644
index 0000000..7cfb9b9
--- /dev/null
+++ b/src/app/Controllers/EditorController.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+// Editor: webcam/upload image capture, overlay selection, and server-side
+// GD compositing to create posts.
+
+namespace App\Controllers;
+
+use App\Csrf;
+use App\Flash;
+use App\Models\Post;
+
+class EditorController
+{
+ private Post $post;
+
+ public function __construct()
+ {
+ $this->post = new Post();
+ }
+
+ public function show(): void
+ {
+ if (!isset($_SESSION['user_id'])) {
+ header('Location: /login');
+ return;
+ }
+
+ $overlaysDir = \dirname(__DIR__, 2) . '/public/assets/overlays';
+ $overlayFiles = glob($overlaysDir . '/*.png');
+ // Map filesystem paths to URL paths the browser can load
+ $overlays = array_map(static fn($path) => '/assets/overlays/' . basename($path), $overlayFiles);
+
+ $content = __DIR__ . '/../Views/editor/index.php';
+ include __DIR__ . '/../Views/layouts/main.php';
+ }
+
+ public function store(): void
+ {
+ header('Content-Type: application/json');
+
+ if (!isset($_SESSION['user_id'])) {
+ http_response_code(401);
+ echo json_encode(['error' => 'Not authenticated.']);
+ return;
+ }
+
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!$input) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Invalid request.']);
+ return;
+ }
+
+ if (!Csrf::validate($input['csrf_token'] ?? '')) {
+ http_response_code(403);
+ echo json_encode(['error' => 'Invalid CSRF token.']);
+ return;
+ }
+
+ $imageData = $input['image_data'] ?? '';
+ $overlayName = $input['overlay'] ?? '';
+ $overlayScale = (float) ($input['overlay_scale'] ?? 1.0);
+
+ // Validate overlay exists — use basename() to prevent path traversal
+ $overlaysDir = \dirname(__DIR__, 2) . '/public/assets/overlays';
+ $overlayPath = $overlaysDir . '/' . basename($overlayName);
+ if (!file_exists($overlayPath) || !str_ends_with($overlayPath, '.png')) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Invalid overlay.']);
+ return;
+ }
+
+ $overlayScale = max(0.1, min(2.0, $overlayScale));
+
+ // Strip the data URL prefix (e.g. "data:image/jpeg;base64,") to get raw base64
+ $base64 = preg_replace('#^data:image/\w+;base64,#', '', $imageData);
+ $decoded = base64_decode($base64, true);
+ if ($decoded === false) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Invalid image data.']);
+ return;
+ }
+
+ $base = @imagecreatefromstring($decoded);
+ if ($base === false) {
+ http_response_code(400);
+ echo json_encode(['error' => 'Could not process image.']);
+ return;
+ }
+
+ $outputPath = $this->composite($base, $overlayPath, $overlayScale);
+
+
+ if ($outputPath === null) {
+ http_response_code(500);
+ echo json_encode(['error' => 'Failed to save image.']);
+ return;
+ }
+
+ // Store relative path from the web root so it works as a URL
+ $relativePath = 'uploads/posts/' . basename($outputPath);
+ $this->post->create($_SESSION['user_id'], $relativePath);
+
+ echo json_encode(['success' => true, 'redirect' => '/gallery']);
+ }
+
+ /**
+ * Composite the base image with an overlay using GD.
+ * Returns the saved file path, or null on failure.
+ */
+ private function composite(\GdImage $base, string $overlayPath, float $scale): ?string
+ {
+ $canvasSize = 640;
+
+ // Create a square canvas and fill with white
+ $canvas = imagecreatetruecolor($canvasSize, $canvasSize);
+ $white = imagecolorallocate($canvas, 255, 255, 255);
+ imagefill($canvas, 0, 0, $white);
+
+ // Resize base image to cover the 640x640 canvas (center-crop)
+ $srcW = imagesx($base);
+ $srcH = imagesy($base);
+ // Pick the largest square that fits inside the source image
+ $cropSize = min($srcW, $srcH);
+ $srcX = (int) (($srcW - $cropSize) / 2);
+ $srcY = (int) (($srcH - $cropSize) / 2);
+ imagecopyresampled($canvas, $base, 0, 0, $srcX, $srcY, $canvasSize, $canvasSize, $cropSize, $cropSize);
+
+ // Load the overlay PNG with alpha transparency
+ $overlay = imagecreatefrompng($overlayPath);
+ // Enable alpha blending so transparent overlay pixels don't overwrite the base
+ imagealphablending($canvas, true);
+
+ $overlayW = imagesx($overlay);
+ $overlayH = imagesy($overlay);
+ $scaledW = (int) ($overlayW * $scale);
+ $scaledH = (int) ($overlayH * $scale);
+ // Center the overlay on the canvas
+ $destX = (int) (($canvasSize - $scaledW) / 2);
+ $destY = (int) (($canvasSize - $scaledH) / 2);
+
+ imagecopyresampled($canvas, $overlay, $destX, $destY, 0, 0, $scaledW, $scaledH, $overlayW, $overlayH);
+
+
+ // Save as JPEG
+ $uploadsDir = \dirname(__DIR__, 2) . '/uploads/posts';
+
+ $filename = $_SESSION['user_id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.jpg';
+ $path = $uploadsDir . '/' . $filename;
+
+ $ok = imagejpeg($canvas, $path, 85);
+
+
+ return $ok ? $path : null;
+ }
+}
diff --git a/src/app/Controllers/GalleryController.php b/src/app/Controllers/GalleryController.php
new file mode 100644
index 0000000..2edcd17
--- /dev/null
+++ b/src/app/Controllers/GalleryController.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+// Gallery: public paginated feed of all posts, newest first.
+
+namespace App\Controllers;
+
+use App\Models\Post;
+
+class GalleryController
+{
+ private Post $post;
+ private const POSTS_PER_PAGE = 5;
+
+ public function __construct()
+ {
+ $this->post = new Post();
+ }
+
+ public function index(): void
+ {
+ $page = max(1, (int) ($_GET['page'] ?? 1));
+ $offset = ($page - 1) * self::POSTS_PER_PAGE;
+
+ $posts = $this->post->findAllPaginated(self::POSTS_PER_PAGE, $offset);
+ $totalPosts = $this->post->countAll();
+ $totalPages = max(1, (int) ceil($totalPosts / self::POSTS_PER_PAGE));
+
+ $content = __DIR__ . '/../Views/gallery/index.php';
+ include __DIR__ . '/../Views/layouts/main.php';
+ }
+}
diff --git a/src/app/Models/Post.php b/src/app/Models/Post.php
new file mode 100644
index 0000000..66c8c18
--- /dev/null
+++ b/src/app/Models/Post.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+// Post model: database operations for the posts table.
+
+namespace App\Models;
+
+use App\Database;
+
+class Post
+{
+ private \PDO $pdo;
+
+ public function __construct()
+ {
+ $this->pdo = Database::getInstance()->getPdo();
+ }
+
+ public function create(int $userId, string $imagePath): int
+ {
+ $stmt = $this->pdo->prepare(
+ 'INSERT INTO posts (user_id, image_path) VALUES (:user_id, :image_path)'
+ );
+ $stmt->execute(['user_id' => $userId, 'image_path' => $imagePath]);
+ return (int) $this->pdo->lastInsertId();
+ }
+
+ public function findById(int $id): ?array
+ {
+ $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public function findByUserId(int $userId): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT * FROM posts WHERE user_id = :user_id ORDER BY created_at DESC'
+ );
+ $stmt->execute(['user_id' => $userId]);
+ return $stmt->fetchAll();
+ }
+
+ public function findAllPaginated(int $limit, int $offset): array
+ {
+ $stmt = $this->pdo->prepare(
+ 'SELECT posts.*, users.username FROM posts
+ JOIN users ON posts.user_id = users.id
+ ORDER BY posts.created_at DESC
+ LIMIT :limit OFFSET :offset'
+ );
+ // PDO needs explicit int binding for LIMIT/OFFSET
+ $stmt->bindValue('limit', $limit, \PDO::PARAM_INT);
+ $stmt->bindValue('offset', $offset, \PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+ }
+
+ public function countAll(): int
+ {
+ $stmt = $this->pdo->query('SELECT COUNT(*) FROM posts');
+ return (int) $stmt->fetchColumn();
+ }
+
+ public function delete(int $id): void
+ {
+ $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = :id');
+ $stmt->execute(['id' => $id]);
+ }
+}
diff --git a/src/app/Views/editor/index.php b/src/app/Views/editor/index.php
new file mode 100644
index 0000000..624002a
--- /dev/null
+++ b/src/app/Views/editor/index.php
@@ -0,0 +1,49 @@
+<?php // Editor page: capture or upload an image, pick an overlay, preview, and save as a post.?>
+<div class="editor-page">
+ <h1>Create a post</h1>
+ <?php include __DIR__ . '/../partials/flash.php'; ?>
+ <input type="hidden" id="csrf-token" value="<?= htmlspecialchars(\App\Csrf::generate()) ?>">
+
+ <div class="editor-layout">
+ <div class="editor-source">
+ <div class="source-tabs">
+ <button id="tab-webcam" class="tab active">Webcam</button>
+ <button id="tab-upload" class="tab">Upload</button>
+ </div>
+
+ <div id="webcam-panel">
+ <video id="webcam-video" autoplay playsinline></video>
+ <button id="btn-capture" class="btn">Take photo</button>
+ </div>
+
+ <div id="upload-panel" hidden>
+ <input type="file" id="file-input" accept="image/jpeg,image/png">
+ </div>
+ </div>
+
+ <div class="editor-preview">
+ <canvas id="preview-canvas" width="640" height="640"></canvas>
+
+ <div class="overlay-controls">
+ <label for="overlay-scale">Overlay size</label>
+ <input type="range" id="overlay-scale" min="10" max="200" value="100">
+ </div>
+
+ <h2>Overlays</h2>
+ <?php if (empty($overlays)): ?>
+ <p class="hint">No overlays available.</p>
+ <?php else: ?>
+ <div class="overlay-grid">
+ <?php foreach ($overlays as $overlay): ?>
+ <img src="<?= htmlspecialchars($overlay) ?>"
+ class="overlay-thumb"
+ alt="overlay"
+ data-filename="<?= htmlspecialchars(basename($overlay)) ?>">
+ <?php endforeach; ?>
+ </div>
+ <?php endif; ?>
+
+ <button id="btn-save" class="btn" disabled>Save post</button>
+ </div>
+ </div>
+</div>
diff --git a/src/app/Views/gallery/index.php b/src/app/Views/gallery/index.php
new file mode 100644
index 0000000..62fd7f3
--- /dev/null
+++ b/src/app/Views/gallery/index.php
@@ -0,0 +1,40 @@
+<?php // Gallery: public feed of all posts with pagination.?>
+<div class="gallery-page">
+ <h1>Gallery</h1>
+
+ <?php if (empty($posts)): ?>
+ <p class="hint">No posts yet. Be the first!</p>
+ <?php endif; ?>
+
+ <div class="gallery-feed">
+ <?php foreach ($posts as $post): ?>
+ <article class="gallery-post">
+ <div class="post-header">
+ <strong><?= htmlspecialchars($post['username']) ?></strong>
+ <time><?= date('M j, Y', strtotime($post['created_at'])) ?></time>
+ </div>
+ <img src="/<?= htmlspecialchars($post['image_path']) ?>" alt="Post by <?= htmlspecialchars($post['username']) ?>">
+ </article>
+ <?php endforeach; ?>
+ </div>
+
+ <?php if ($totalPages > 1): ?>
+ <nav class="pagination">
+ <?php if ($page > 1): ?>
+ <a href="/gallery?page=<?= $page - 1 ?>">&laquo; Previous</a>
+ <?php endif; ?>
+
+ <?php for ($i = 1; $i <= $totalPages; $i++): ?>
+ <?php if ($i === $page): ?>
+ <span class="current-page"><?= $i ?></span>
+ <?php else: ?>
+ <a href="/gallery?page=<?= $i ?>"><?= $i ?></a>
+ <?php endif; ?>
+ <?php endfor; ?>
+
+ <?php if ($page < $totalPages): ?>
+ <a href="/gallery?page=<?= $page + 1 ?>">Next &raquo;</a>
+ <?php endif; ?>
+ </nav>
+ <?php endif; ?>
+</div>
diff --git a/src/config/routes.php b/src/config/routes.php
index e5b4a9a..c8bb52f 100644
--- a/src/config/routes.php
+++ b/src/config/routes.php
@@ -24,3 +24,10 @@ $router->post('/profile/username', 'ProfileController', 'updateUsername');
$router->post('/profile/email', 'ProfileController', 'updateEmail');
$router->post('/profile/password', 'ProfileController', 'updatePassword');
$router->post('/profile/notifications', 'ProfileController', 'updateNotifications');
+
+// Gallery
+$router->get('/gallery', 'GalleryController', 'index');
+
+// Editor
+$router->get('/editor', 'EditorController', 'show');
+$router->post('/editor', 'EditorController', 'store');
diff --git a/src/public/assets/overlays/chest.png b/src/public/assets/overlays/chest.png
new file mode 100644
index 0000000..a4db4c0
--- /dev/null
+++ b/src/public/assets/overlays/chest.png
Binary files differ
diff --git a/src/public/assets/overlays/cloud.png b/src/public/assets/overlays/cloud.png
new file mode 100644
index 0000000..b06eff4
--- /dev/null
+++ b/src/public/assets/overlays/cloud.png
Binary files differ
diff --git a/src/public/assets/overlays/eagle.png b/src/public/assets/overlays/eagle.png
new file mode 100644
index 0000000..d837908
--- /dev/null
+++ b/src/public/assets/overlays/eagle.png
Binary files differ
diff --git a/src/public/assets/overlays/explosion.png b/src/public/assets/overlays/explosion.png
new file mode 100644
index 0000000..008495b
--- /dev/null
+++ b/src/public/assets/overlays/explosion.png
Binary files differ
diff --git a/src/public/assets/overlays/fire.png b/src/public/assets/overlays/fire.png
new file mode 100644
index 0000000..e9438fd
--- /dev/null
+++ b/src/public/assets/overlays/fire.png
Binary files differ
diff --git a/src/public/assets/overlays/hat.png b/src/public/assets/overlays/hat.png
new file mode 100644
index 0000000..36f2760
--- /dev/null
+++ b/src/public/assets/overlays/hat.png
Binary files differ
diff --git a/src/public/assets/overlays/heart.png b/src/public/assets/overlays/heart.png
new file mode 100644
index 0000000..b54e462
--- /dev/null
+++ b/src/public/assets/overlays/heart.png
Binary files differ
diff --git a/src/public/assets/overlays/rainbow.png b/src/public/assets/overlays/rainbow.png
new file mode 100644
index 0000000..3e9719e
--- /dev/null
+++ b/src/public/assets/overlays/rainbow.png
Binary files differ
diff --git a/src/public/assets/overlays/water.png b/src/public/assets/overlays/water.png
new file mode 100644
index 0000000..7558079
--- /dev/null
+++ b/src/public/assets/overlays/water.png
Binary files differ
diff --git a/src/public/css/style.css b/src/public/css/style.css
index 897f54b..e152519 100644
--- a/src/public/css/style.css
+++ b/src/public/css/style.css
@@ -207,9 +207,212 @@ footer {
height: 1rem;
}
+/* Gallery page */
+.gallery-page {
+ max-width: 600px;
+ margin: 0 auto;
+}
+
+.gallery-page h1 {
+ margin-bottom: 1.5rem;
+}
+
+.gallery-feed {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.gallery-post {
+ background: #fff;
+ border: 1px solid #dbdbdb;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.post-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+}
+
+.post-header time {
+ color: #999;
+ font-size: 0.85rem;
+}
+
+.gallery-post img {
+ width: 100%;
+ display: block;
+}
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.5rem;
+ margin-top: 2rem;
+}
+
+.pagination a {
+ padding: 0.4rem 0.75rem;
+ border: 1px solid #dbdbdb;
+ border-radius: 4px;
+ text-decoration: none;
+ color: #0095f6;
+}
+
+.pagination a:hover {
+ background: #f0f0f0;
+}
+
+.pagination .current-page {
+ padding: 0.4rem 0.75rem;
+ background: #0095f6;
+ color: #fff;
+ border-radius: 4px;
+}
+
+/* Editor page */
+.editor-page {
+ max-width: 960px;
+ margin: 0 auto;
+}
+
+.editor-page h1 {
+ margin-bottom: 1.5rem;
+}
+
+.editor-layout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ align-items: start;
+}
+
+.editor-source,
+.editor-preview {
+ background: #fff;
+ border: 1px solid #dbdbdb;
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+.source-tabs {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.tab {
+ flex: 1;
+ padding: 0.5rem;
+ background: #fafafa;
+ border: 1px solid #dbdbdb;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.95rem;
+}
+
+.tab.active {
+ background: #0095f6;
+ color: #fff;
+ border-color: #0095f6;
+}
+
+#webcam-video {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ border-radius: 4px;
+ background: #000;
+}
+
+#preview-canvas {
+ width: 100%;
+ height: auto;
+ border-radius: 4px;
+ border: 1px solid #dbdbdb;
+}
+
+.overlay-controls {
+ margin: 0.75rem 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.overlay-controls input[type="range"] {
+ flex: 1;
+}
+
+.editor-preview h2 {
+ font-size: 1rem;
+ margin-bottom: 0.5rem;
+}
+
+.overlay-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.overlay-thumb {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: contain;
+ border: 2px solid #dbdbdb;
+ border-radius: 4px;
+ cursor: pointer;
+ opacity: 0.6;
+ transition: opacity 0.15s, border-color 0.15s;
+ background: #fafafa;
+ padding: 4px;
+}
+
+.overlay-thumb:hover {
+ opacity: 0.8;
+}
+
+.overlay-thumb.selected {
+ opacity: 1;
+ border-color: #0095f6;
+}
+
+.btn {
+ width: 100%;
+ padding: 0.6rem;
+ background: #0095f6;
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ cursor: pointer;
+ margin-top: 0.5rem;
+}
+
+.btn:hover {
+ background: #007ad9;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+#file-input {
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
@media (max-width: 600px) {
header nav {
flex-direction: column;
gap: 0.5rem;
}
+ .editor-layout {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/src/public/js/app.js b/src/public/js/app.js
index 86b3c33..a6bb9c5 100644
--- a/src/public/js/app.js
+++ b/src/public/js/app.js
@@ -1,3 +1,201 @@
document.addEventListener('DOMContentLoaded', function () {
- // App initialization
+ var canvas = document.getElementById('preview-canvas');
+ if (!canvas) return;
+
+ var ctx = canvas.getContext('2d');
+ var video = document.getElementById('webcam-video');
+ var btnCapture = document.getElementById('btn-capture');
+ var btnSave = document.getElementById('btn-save');
+ var fileInput = document.getElementById('file-input');
+ var scaleSlider = document.getElementById('overlay-scale');
+ var tabWebcam = document.getElementById('tab-webcam');
+ var tabUpload = document.getElementById('tab-upload');
+ var webcamPanel = document.getElementById('webcam-panel');
+ var uploadPanel = document.getElementById('upload-panel');
+ var csrfToken = document.getElementById('csrf-token').value;
+ var overlayThumbs = document.querySelectorAll('.overlay-thumb');
+
+ // State
+ var baseImageData = null; // base64 data URL of the captured/uploaded image
+ var selectedOverlay = null; // filename of the selected overlay
+ var overlayImg = null; // loaded Image object for the selected overlay
+ var webcamStream = null;
+
+ // -- Tabs --
+
+ tabWebcam.addEventListener('click', function () {
+ tabWebcam.classList.add('active');
+ tabUpload.classList.remove('active');
+ webcamPanel.hidden = false;
+ uploadPanel.hidden = true;
+ startWebcam();
+ });
+
+ tabUpload.addEventListener('click', function () {
+ tabUpload.classList.add('active');
+ tabWebcam.classList.remove('active');
+ uploadPanel.hidden = false;
+ webcamPanel.hidden = true;
+ stopWebcam();
+ });
+
+ // -- Webcam --
+
+ function startWebcam() {
+ if (webcamStream) return;
+ navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 640, facingMode: 'user' } })
+ .then(function (stream) {
+ webcamStream = stream;
+ video.srcObject = stream;
+ })
+ .catch(function () {
+ // Camera not available — switch to upload tab
+ tabUpload.click();
+ });
+ }
+
+ function stopWebcam() {
+ if (webcamStream) {
+ webcamStream.getTracks().forEach(function (t) { t.stop(); });
+ webcamStream = null;
+ video.srcObject = null;
+ }
+ }
+
+ btnCapture.addEventListener('click', function () {
+ // Draw current video frame onto an offscreen canvas to get a data URL
+ var offscreen = document.createElement('canvas');
+ offscreen.width = 640;
+ offscreen.height = 640;
+ var offCtx = offscreen.getContext('2d');
+ // Center-crop the video frame into the square canvas
+ var vw = video.videoWidth;
+ var vh = video.videoHeight;
+ var crop = Math.min(vw, vh);
+ var sx = (vw - crop) / 2;
+ var sy = (vh - crop) / 2;
+ offCtx.drawImage(video, sx, sy, crop, crop, 0, 0, 640, 640);
+ baseImageData = offscreen.toDataURL('image/jpeg', 0.9);
+ renderPreview();
+ });
+
+ // -- Upload --
+
+ fileInput.addEventListener('change', function () {
+ var file = fileInput.files[0];
+ if (!file) return;
+ var reader = new FileReader();
+ reader.onload = function (e) {
+ baseImageData = e.target.result;
+ renderPreview();
+ };
+ reader.readAsDataURL(file);
+ });
+
+ // -- Overlay selection --
+
+ overlayThumbs.forEach(function (thumb) {
+ thumb.addEventListener('click', function () {
+ // Deselect previous
+ overlayThumbs.forEach(function (t) { t.classList.remove('selected'); });
+ thumb.classList.add('selected');
+ selectedOverlay = thumb.dataset.filename;
+
+ // Preload the full overlay image for canvas rendering
+ overlayImg = new Image();
+ overlayImg.onload = renderPreview;
+ overlayImg.src = thumb.src;
+ });
+ });
+
+ // -- Scale slider --
+
+ scaleSlider.addEventListener('input', renderPreview);
+
+ // -- Preview --
+
+ function renderPreview() {
+ ctx.clearRect(0, 0, 640, 640);
+ // Fill with light gray so the canvas isn't transparent
+ ctx.fillStyle = '#eee';
+ ctx.fillRect(0, 0, 640, 640);
+
+ if (!baseImageData) {
+ ctx.fillStyle = '#999';
+ ctx.font = '18px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('Take a photo or upload an image', 320, 320);
+ updateSaveButton();
+ return;
+ }
+
+ var img = new Image();
+ img.onload = function () {
+ // Center-crop into the 640x640 canvas
+ var sw = img.width;
+ var sh = img.height;
+ var crop = Math.min(sw, sh);
+ var sx = (sw - crop) / 2;
+ var sy = (sh - crop) / 2;
+ ctx.drawImage(img, sx, sy, crop, crop, 0, 0, 640, 640);
+
+ // Draw overlay on top if one is selected
+ if (overlayImg && overlayImg.complete) {
+ var scale = scaleSlider.value / 100;
+ var ow = overlayImg.width * scale;
+ var oh = overlayImg.height * scale;
+ var ox = (640 - ow) / 2;
+ var oy = (640 - oh) / 2;
+ ctx.drawImage(overlayImg, ox, oy, ow, oh);
+ }
+
+ updateSaveButton();
+ };
+ img.src = baseImageData;
+ }
+
+ function updateSaveButton() {
+ // Both a base image and an overlay must be selected
+ btnSave.disabled = !(baseImageData && selectedOverlay);
+ }
+
+ // -- Save --
+
+ btnSave.addEventListener('click', function () {
+ if (btnSave.disabled) return;
+ btnSave.disabled = true;
+ btnSave.textContent = 'Saving...';
+
+ var scale = scaleSlider.value / 100;
+
+ fetch('/editor', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ image_data: baseImageData,
+ overlay: selectedOverlay,
+ overlay_scale: scale,
+ csrf_token: csrfToken
+ })
+ })
+ .then(function (res) { return res.json(); })
+ .then(function (data) {
+ if (data.success) {
+ window.location.href = data.redirect;
+ } else {
+ alert(data.error || 'Something went wrong.');
+ btnSave.disabled = false;
+ btnSave.textContent = 'Save post';
+ }
+ })
+ .catch(function () {
+ alert('Network error. Please try again.');
+ btnSave.disabled = false;
+ btnSave.textContent = 'Save post';
+ });
+ });
+
+ // Start webcam by default
+ startWebcam();
+ renderPreview();
});