diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 22:36:11 +0100 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 22:36:11 +0100 |
| commit | d63e3c91a97d77b202e280ab0fa007dfbe1baa46 (patch) | |
| tree | cd3533bfb947ea753d91f71a75406644a73d678d | |
| parent | f60a390f5c51039fd1efc1df9a6a7f3864ce0062 (diff) | |
| download | camagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.tar.gz camagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.zip | |
Add editor with webcam/upload capture, overlay compositing, and gallery feed
20 files changed, 773 insertions, 1 deletions
diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 8a1bcc6..44ebf62 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -8,6 +8,10 @@ server { try_files $uri $uri/ /index.php?$query_string; } + location /uploads/ { + alias /var/www/html/uploads/; + } + location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 375e3a7..cb7befd 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -22,4 +22,8 @@ tls off" > /etc/msmtprc RUN echo "upload_max_filesize = 10M" > /usr/local/etc/php/conf.d/uploads.ini \ && echo "post_max_size = 10M" >> /usr/local/etc/php/conf.d/uploads.ini +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +ENTRYPOINT ["entrypoint.sh"] +CMD ["php-fpm"] + WORKDIR /var/www/html diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh new file mode 100755 index 0000000..de41f7e --- /dev/null +++ b/docker/php/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Ensure the uploads directory exists and is writable by www-data (PHP-FPM). +# Bind-mounted volumes keep host ownership, so we fix permissions at startup. +mkdir -p /var/www/html/uploads/posts +chown -R www-data:www-data /var/www/html/uploads + +exec "$@" 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 ?>">« 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 »</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 Binary files differnew file mode 100644 index 0000000..a4db4c0 --- /dev/null +++ b/src/public/assets/overlays/chest.png diff --git a/src/public/assets/overlays/cloud.png b/src/public/assets/overlays/cloud.png Binary files differnew file mode 100644 index 0000000..b06eff4 --- /dev/null +++ b/src/public/assets/overlays/cloud.png diff --git a/src/public/assets/overlays/eagle.png b/src/public/assets/overlays/eagle.png Binary files differnew file mode 100644 index 0000000..d837908 --- /dev/null +++ b/src/public/assets/overlays/eagle.png diff --git a/src/public/assets/overlays/explosion.png b/src/public/assets/overlays/explosion.png Binary files differnew file mode 100644 index 0000000..008495b --- /dev/null +++ b/src/public/assets/overlays/explosion.png diff --git a/src/public/assets/overlays/fire.png b/src/public/assets/overlays/fire.png Binary files differnew file mode 100644 index 0000000..e9438fd --- /dev/null +++ b/src/public/assets/overlays/fire.png diff --git a/src/public/assets/overlays/hat.png b/src/public/assets/overlays/hat.png Binary files differnew file mode 100644 index 0000000..36f2760 --- /dev/null +++ b/src/public/assets/overlays/hat.png diff --git a/src/public/assets/overlays/heart.png b/src/public/assets/overlays/heart.png Binary files differnew file mode 100644 index 0000000..b54e462 --- /dev/null +++ b/src/public/assets/overlays/heart.png diff --git a/src/public/assets/overlays/rainbow.png b/src/public/assets/overlays/rainbow.png Binary files differnew file mode 100644 index 0000000..3e9719e --- /dev/null +++ b/src/public/assets/overlays/rainbow.png diff --git a/src/public/assets/overlays/water.png b/src/public/assets/overlays/water.png Binary files differnew file mode 100644 index 0000000..7558079 --- /dev/null +++ b/src/public/assets/overlays/water.png 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(); }); |
