From 3a1433b71e2b9eb74d81d74bd0b4a0c1ed5982d5 Mon Sep 17 00:00:00 2001 From: Philip Guzman III Date: Tue, 15 Sep 2026 15:10:43 -0700 Subject: [PATCH] Add drag-to-pan + zoom photo repositioning for card/avatar crops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every photo upload (session setup, novena group, Rosary Builder title photo) gets shown two ways: full-size on the presentation cover slide (unaffected, stays untouched), and cropped to a fixed box everywhere else — home page cards, profile cards, the novena day-picker's circular hero photo, and each admin form's own preview thumbnail. All of those crops used to just take the image's dead center, with no way to control what part of the photo that was — cropping out people's heads on portrait photos. - New sessions/novena_groups columns: photo_focal_x, photo_focal_y (0-100%), photo_zoom (1-3x), defaulting to 50/50/1 — today's exact centered/ unzoomed behavior, so this is fully backward compatible until someone actively repositions a photo. - New assets/js/photo-crop.js: a reusable drag-to-pan + zoom modal editor. The crop frame renders with the *exact* CSS recipe used at final render time (object-position + transform:scale/transform-origin), so the editor is a truthful live preview, not an approximation. A reference thumbnail shows the full photo dimmed outside a rectangle marking the current crop. All math reads actual rendered box dimensions rather than assuming fixed pixel sizes, so it holds up responsively at any viewport width — caught and fixed a real mismatch bug here by testing the widget standalone in a browser before wiring it into any PHP form. - New includes/photo.php: photo_crop_style() builds the inline style="..." from a session/group row, used everywhere a crop is displayed. - Wired into all three upload locations (admin/setup.php, admin/novena_group.php, admin/builder.php) with a "Reposition" button, and persisted through api/save_session.php, admin/novena_group.php's save handler, and api/builder_session.php. Co-Authored-By: Claude Sonnet 5 --- README.md | 14 +++ admin/builder.php | 33 ++++++ admin/novena_group.php | 63 +++++++++-- admin/setup.php | 22 ++-- api/builder_session.php | 15 ++- api/save_session.php | 40 ++++--- assets/css/photo-crop.css | 118 +++++++++++++++++++++ assets/js/builder.js | 77 ++++++++++++++ assets/js/photo-crop.js | 213 ++++++++++++++++++++++++++++++++++++++ assets/js/setup.js | 38 +++++++ includes/photo.php | 26 +++++ index.php | 7 +- install.php | 6 ++ novena_public.php | 4 +- profile.php | 9 +- schema.sql | 12 ++- 16 files changed, 655 insertions(+), 42 deletions(-) create mode 100644 assets/css/photo-crop.css create mode 100644 assets/js/photo-crop.js create mode 100644 includes/photo.php diff --git a/README.md b/README.md index 076b5cc..7f867d9 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,20 @@ ALTER TABLE users ADD COLUMN locked_until DATETIME NULL; ``` +Also needed for the photo reposition/zoom tool (`photo_focal_x`/`photo_focal_y`/`photo_zoom` on both `sessions` and `novena_groups` — defaults reproduce today's centered, no-zoom crop, so this is safe to run any time): + +```sql +ALTER TABLE sessions + ADD COLUMN photo_focal_x FLOAT NOT NULL DEFAULT 50, + ADD COLUMN photo_focal_y FLOAT NOT NULL DEFAULT 50, + ADD COLUMN photo_zoom FLOAT NOT NULL DEFAULT 1; + +ALTER TABLE novena_groups + ADD COLUMN photo_focal_x FLOAT NOT NULL DEFAULT 50, + ADD COLUMN photo_focal_y FLOAT NOT NULL DEFAULT 50, + ADD COLUMN photo_zoom FLOAT NOT NULL DEFAULT 1; +``` + ## Deployment Checklist - [ ] `config/db.php` filled in with production credentials diff --git a/admin/builder.php b/admin/builder.php index 2b1f9f3..d3f9ff7 100644 --- a/admin/builder.php +++ b/admin/builder.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/csrf.php'; +require_once __DIR__ . '/../includes/photo.php'; require_role('superuser'); @@ -73,6 +74,7 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary <?= $page_title ?> — <?= htmlspecialchars($site_name) ?> + @@ -148,6 +150,36 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary + +
+ Title page photo (optional — shown on the cover slide and in listings) +
+ +
+ Current photo +
+ + + + + + + + + + +
+
+
@@ -276,6 +308,7 @@ var EXISTING_STEPS = [ 'attribution' => $s['attribution'] ?? 'leader_all', ], $edit_steps)) ?>; + diff --git a/admin/novena_group.php b/admin/novena_group.php index 521783a..da552f0 100644 --- a/admin/novena_group.php +++ b/admin/novena_group.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/csrf.php'; +require_once __DIR__ . '/../includes/photo.php'; require_auth(); @@ -61,6 +62,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) { csrf_verify(); $g_name = trim($_POST['g_name'] ?? ''); $g_photo = trim($_POST['g_photo'] ?? '') ?: null; + $g_focal_x = max(0, min(100, (float)($_POST['g_photo_focal_x'] ?? 50))); + $g_focal_y = max(0, min(100, (float)($_POST['g_photo_focal_y'] ?? 50))); + $g_zoom = max(1, min(3, (float)($_POST['g_photo_zoom'] ?? 1))); $g_public = isset($_POST['is_public']) ? 1 : 0; if ($is_dm) { @@ -88,19 +92,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) { SET name = ?, mystery_set = ?, subject_name = ?, subject_pronoun = ?, subject_dates = ?, photo_path = COALESCE(?, photo_path), + photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?, is_public = ? WHERE id = ? - ')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_public, $gid]); + ')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_focal_x, $g_focal_y, $g_zoom, $g_public, $gid]); $pdo->prepare(' UPDATE sessions SET mystery_set = ?, subject_name = ?, subject_pronoun = ?, subject_dates = ?, photo_path = COALESCE(?, photo_path), + photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?, name = CONCAT(?, CONCAT(\' — Day \', novena_day)), is_public = ? WHERE novena_group_id = ? - ')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_name, $g_public, $gid]); + ')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_focal_x, $g_focal_y, $g_zoom, $g_name, $g_public, $gid]); $save_success = true; // Reload group @@ -131,6 +137,7 @@ $mystery_labels = [ <?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?> + @@ -234,7 +241,7 @@ $mystery_labels = [
Current photo + alt="Current photo" class="photo-preview" style="">
@@ -311,16 +324,27 @@ $mystery_labels = [
+ diff --git a/admin/setup.php b/admin/setup.php index 29cf5ef..1b0d808 100644 --- a/admin/setup.php +++ b/admin/setup.php @@ -5,6 +5,7 @@ require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/csrf.php'; +require_once __DIR__ . '/../includes/photo.php'; require_auth(); @@ -47,6 +48,7 @@ $page_title = $session ? 'Edit Session' : 'New Session'; <?= $page_title ?> — <?= htmlspecialchars($site_name) ?> + @@ -213,17 +215,22 @@ $page_title = $session ? 'Edit Session' : 'New Session';
- -
- Current photo -

Current photo. Upload a new one to replace it.

-
- +
+ Current photo +

JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.

+ + + +
@@ -252,6 +259,7 @@ $page_title = $session ? 'Edit Session' : 'New Session'; + diff --git a/api/builder_session.php b/api/builder_session.php index adaf828..5b3f79b 100644 --- a/api/builder_session.php +++ b/api/builder_session.php @@ -45,6 +45,10 @@ $is_public = (int)!empty($body['is_public']); $subject_name = trim($body['subject_name'] ?? ''); $subject_pronoun = in_array($body['subject_pronoun'] ?? '', ['he','she']) ? $body['subject_pronoun'] : 'he'; $subject_dates = trim($body['subject_dates'] ?? ''); +$photo_path = trim($body['photo_path'] ?? '') ?: null; +$photo_focal_x = max(0, min(100, (float)($body['photo_focal_x'] ?? 50))); +$photo_focal_y = max(0, min(100, (float)($body['photo_focal_y'] ?? 50))); +$photo_zoom = max(1, min(3, (float)($body['photo_zoom'] ?? 1))); $steps = $body['steps'] ?? []; $session_id = (int)($body['id'] ?? 0); @@ -105,9 +109,9 @@ try { $pdo->prepare(" UPDATE sessions SET name=?, is_public=?, subject_name=?, subject_pronoun=?, subject_dates=?, - slug=?, updated_at=NOW() + photo_path=?, photo_focal_x=?, photo_focal_y=?, photo_zoom=?, slug=?, updated_at=NOW() WHERE id=? - ")->execute([$name, $is_public, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null, $slug, $session_id]); + ")->execute([$name, $is_public, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null, $photo_path, $photo_focal_x, $photo_focal_y, $photo_zoom, $slug, $session_id]); // Replace all steps $pdo->prepare("DELETE FROM builder_steps WHERE session_id = ?")->execute([$session_id]); @@ -120,9 +124,10 @@ try { $pdo->prepare(" INSERT INTO sessions (user_id, is_public, slug, name, occasion, mystery_set, - subject_name, subject_pronoun, subject_dates) - VALUES (?, ?, ?, ?, 'custom', 'custom', ?, ?, ?) - ")->execute([$uid, $is_public, $slug, $name, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null]); + subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom) + VALUES (?, ?, ?, ?, 'custom', 'custom', ?, ?, ?, ?, ?, ?, ?) + ")->execute([$uid, $is_public, $slug, $name, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null, $photo_path, $photo_focal_x, $photo_focal_y, $photo_zoom]); $session_id = (int)$pdo->lastInsertId(); } diff --git a/api/save_session.php b/api/save_session.php index 8215dfe..a327011 100644 --- a/api/save_session.php +++ b/api/save_session.php @@ -37,6 +37,9 @@ $subject_name = trim($_POST['subject_name'] ?? '') ?: null; $subject_pronoun = trim($_POST['subject_pronoun'] ?? '') ?: null; $subject_dates = trim($_POST['subject_dates'] ?? '') ?: null; $photo_path = trim($_POST['photo_path'] ?? '') ?: null; +$photo_focal_x = max(0, min(100, (float)($_POST['photo_focal_x'] ?? 50))); +$photo_focal_y = max(0, min(100, (float)($_POST['photo_focal_y'] ?? 50))); +$photo_zoom = max(1, min(3, (float)($_POST['photo_zoom'] ?? 1))); $is_public = isset($_POST['is_public']) ? 1 : 0; // For novena sessions, mystery_set is determined by novena_mystery_mode @@ -102,6 +105,7 @@ try { SET name = ?, occasion = ?, mystery_set = ?, subject_name = ?, subject_pronoun = ?, subject_dates = ?, photo_path = COALESCE(?, photo_path), + photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?, is_public = ?' . ($new_slug !== null ? ', slug = ?' : '') . ' WHERE id = ? @@ -110,7 +114,7 @@ try { $params = [ $name, $occasion, $mystery_set, $subject_name, $subject_pronoun, $subject_dates, - $photo_path, $is_public, + $photo_path, $photo_focal_x, $photo_focal_y, $photo_zoom, $is_public, ]; if ($new_slug !== null) $params[] = $new_slug; $params[] = $id; @@ -137,18 +141,20 @@ try { $grp = $pdo->prepare(' INSERT INTO novena_groups - (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); - $grp->execute([$name, $mystery_set, null, null, null, $photo_path, $uid, $is_public, $grp_slug]); + $grp->execute([$name, $mystery_set, null, null, null, $photo_path, $photo_focal_x, $photo_focal_y, $photo_zoom, $uid, $is_public, $grp_slug]); $group_id = (int)$pdo->lastInsertId(); $insert = $pdo->prepare(' INSERT INTO sessions (name, occasion, mystery_set, novena_day, - subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id, + subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, novena_group_id, user_id, is_public, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); $created_ids = []; for ($day = 1; $day <= 9; $day++) { @@ -156,7 +162,8 @@ try { $day_slug = unique_slug($day_name, $uid, 'sessions'); $insert->execute([ $day_name, $occasion, $mystery_set, $day, - null, null, null, $photo_path, $group_id, + null, null, null, $photo_path, + $photo_focal_x, $photo_focal_y, $photo_zoom, $group_id, $uid, $is_public, $day_slug, ]); $created_ids[] = (int)$pdo->lastInsertId(); @@ -170,18 +177,20 @@ try { $grp = $pdo->prepare(' INSERT INTO novena_groups - (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, user_id, is_public, slug) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); - $grp->execute([$name, $mystery_set, $subject_name, $subject_pronoun, $subject_dates, $photo_path, $uid, $is_public, $grp_slug]); + $grp->execute([$name, $mystery_set, $subject_name, $subject_pronoun, $subject_dates, $photo_path, $photo_focal_x, $photo_focal_y, $photo_zoom, $uid, $is_public, $grp_slug]); $group_id = (int)$pdo->lastInsertId(); $insert = $pdo->prepare(' INSERT INTO sessions (name, occasion, mystery_set, novena_day, - subject_name, subject_pronoun, subject_dates, photo_path, novena_group_id, + subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, novena_group_id, user_id, is_public, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); $created_ids = []; for ($day = 1; $day <= 9; $day++) { @@ -189,7 +198,8 @@ try { $day_slug = unique_slug($day_name, $uid, 'sessions'); $insert->execute([ $day_name, $occasion, $mystery_set, $day, - $subject_name, $subject_pronoun, $subject_dates, $photo_path, $group_id, + $subject_name, $subject_pronoun, $subject_dates, $photo_path, + $photo_focal_x, $photo_focal_y, $photo_zoom, $group_id, $uid, $is_public, $day_slug, ]); $created_ids[] = (int)$pdo->lastInsertId(); @@ -207,12 +217,14 @@ try { INSERT INTO sessions (name, occasion, mystery_set, novena_day, subject_name, subject_pronoun, subject_dates, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, user_id, is_public, slug) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '); $stmt->execute([ $name, $occasion, $mystery_set, $novena_day, $subject_name, $subject_pronoun, $subject_dates, $photo_path, + $photo_focal_x, $photo_focal_y, $photo_zoom, $uid, $is_public, $slug, ]); echo json_encode(['id' => (int)$pdo->lastInsertId()]); diff --git a/assets/css/photo-crop.css b/assets/css/photo-crop.css new file mode 100644 index 0000000..c792b0f --- /dev/null +++ b/assets/css/photo-crop.css @@ -0,0 +1,118 @@ +.photo-crop-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(17, 24, 39, .6); + z-index: 1000; + align-items: center; + justify-content: center; +} +.photo-crop-overlay.open { display: flex; } + +.photo-crop-box { + background: #fff; + border-radius: 10px; + padding: 24px; + max-width: 560px; + width: calc(100% - 32px); + box-shadow: 0 12px 40px rgba(0,0,0,.25); +} + +.photo-crop-box h3 { + margin: 0 0 16px; + font-size: 16px; +} + +.photo-crop-main { + display: flex; + gap: 20px; + align-items: flex-start; + flex-wrap: wrap; +} + +.photo-crop-frame { + position: relative; + overflow: hidden; + border-radius: 6px; + border: 2px solid #1e3a5f; + background: #e5e7eb; + cursor: grab; + touch-action: none; + flex-shrink: 0; + max-width: 100%; + aspect-ratio: 2 / 1; + height: auto; +} +.photo-crop-frame:active { cursor: grabbing; } + +.photo-crop-frame-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.photo-crop-ref { + position: relative; + overflow: hidden; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #f3f4f6; + flex-shrink: 0; + max-width: 100%; + aspect-ratio: 1 / 1; + height: auto; +} + +.photo-crop-ref-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; + user-select: none; +} + +.photo-crop-ref-rect { + position: absolute; + border: 2px solid #c9973d; + box-shadow: 0 0 0 9999px rgba(17, 24, 39, .55); + pointer-events: none; +} + +.photo-crop-zoom-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 16px; +} +.photo-crop-zoom-row label { + font-size: 13px; + font-weight: 600; + color: #374151; + flex-shrink: 0; +} +.photo-crop-zoom-slider { flex: 1; } + +.photo-crop-hint { + font-size: 12px; + color: #6b7280; + margin: 10px 0 0; +} + +.photo-crop-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; +} + +@media (max-width: 480px) { + .photo-crop-main { flex-direction: column; align-items: stretch; } + .photo-crop-frame, .photo-crop-ref { width: 100%; } +} diff --git a/assets/js/builder.js b/assets/js/builder.js index e653ff8..8d31e83 100644 --- a/assets/js/builder.js +++ b/assets/js/builder.js @@ -97,6 +97,79 @@ // Save session document.getElementById('btn-save').addEventListener('click', saveSession); + + // Title page photo upload + document.getElementById('photo-file').addEventListener('change', uploadPhoto); + document.getElementById('photo-reposition').addEventListener('click', repositionPhoto); + } + + /* ───────────────────────────────────────────────────────── + Title page photo + ───────────────────────────────────────────────────────── */ + function applyPhotoCropStyle() { + const previewImg = document.getElementById('photo-preview'); + const fx = document.getElementById('photo-focal-x').value; + const fy = document.getElementById('photo-focal-y').value; + const zoom = document.getElementById('photo-zoom').value; + previewImg.style.cssText = + 'object-position:' + fx + '% ' + fy + '%;transform:scale(' + zoom + ');transform-origin:' + fx + '% ' + fy + '%;'; + } + + function uploadPhoto() { + const fileInput = document.getElementById('photo-file'); + const photoHidden = document.getElementById('photo-path'); + const focalXHidden = document.getElementById('photo-focal-x'); + const focalYHidden = document.getElementById('photo-focal-y'); + const zoomHidden = document.getElementById('photo-zoom'); + const photoStatus = document.getElementById('photo-status'); + const previewWrap = document.querySelector('#photo-group .photo-preview-wrap'); + const previewImg = document.getElementById('photo-preview'); + const repositionBtn = document.getElementById('photo-reposition'); + + const file = fileInput.files[0]; + if (!file) return; + + const fd = new FormData(); + fd.append('photo', file); + fd.append('csrf_token', CSRF_TOKEN); + photoStatus.textContent = 'Uploading…'; + + fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.path) { + photoHidden.value = data.path; + // A newly uploaded photo starts centered/unzoomed — it's + // a different photo than whatever crop was set before. + focalXHidden.value = 50; + focalYHidden.value = 50; + zoomHidden.value = 1; + previewImg.src = '/' + data.path.replace(/^\//, ''); + applyPhotoCropStyle(); + previewWrap.style.display = ''; + repositionBtn.style.display = ''; + photoStatus.textContent = 'Photo ready.'; + } else { + photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error'); + } + }) + .catch(function () { photoStatus.textContent = 'Upload failed.'; }); + } + + function repositionPhoto() { + const previewImg = document.getElementById('photo-preview'); + PhotoCrop.open({ + imageUrl: previewImg.src, + focalX: parseFloat(document.getElementById('photo-focal-x').value), + focalY: parseFloat(document.getElementById('photo-focal-y').value), + zoom: parseFloat(document.getElementById('photo-zoom').value), + onApply: function (fx, fy, zoom) { + document.getElementById('photo-focal-x').value = fx; + document.getElementById('photo-focal-y').value = fy; + document.getElementById('photo-zoom').value = zoom; + applyPhotoCropStyle(); + } + }); } /* ───────────────────────────────────────────────────────── @@ -453,6 +526,10 @@ subject_name: document.getElementById('subject-name').value.trim(), subject_pronoun: document.getElementById('subject-pronoun').value, subject_dates: document.getElementById('subject-dates').value.trim(), + photo_path: document.getElementById('photo-path').value.trim(), + photo_focal_x: parseFloat(document.getElementById('photo-focal-x').value) || 50, + photo_focal_y: parseFloat(document.getElementById('photo-focal-y').value) || 50, + photo_zoom: parseFloat(document.getElementById('photo-zoom').value) || 1, steps: STEPS.map(s => s.step_type === 'bead' ? { step_type: 'bead', bead_type: s.bead_type } : { step_type: 'prayer', prayer_id: s.prayer_id, attribution: s.attribution, bead_type: s.bead_type || null }), diff --git a/assets/js/photo-crop.js b/assets/js/photo-crop.js new file mode 100644 index 0000000..926554c --- /dev/null +++ b/assets/js/photo-crop.js @@ -0,0 +1,213 @@ +/** + * photo-crop.js — reusable drag-to-pan + zoom photo crop editor. + * + * Usage: + * PhotoCrop.open({ + * imageUrl: '/uploads/xyz.jpg', + * focalX: 50, focalY: 50, zoom: 1, // current stored values (0-100, 0-100, 1-2.5) + * onApply: function (focalX, focalY, zoom) { ... } + * }); + * + * The crop frame uses the exact same CSS recipe (object-position + transform + * scale/transform-origin) that final card thumbnails use elsewhere in the + * app, so what you see here is what renders everywhere else. + * + * All math reads the frame/reference boxes' *actual rendered* size + * (getBoundingClientRect) rather than assuming fixed pixel dimensions — + * the boxes are responsive (aspect-ratio + max-width:100%), so this must + * hold at any viewport size, mobile included. + */ +var PhotoCrop = (function () { + 'use strict'; + + var FRAME_DEFAULT_W = 320, FRAME_DEFAULT_H = 160; // initial size hint only + var REF_DEFAULT_W = 160, REF_DEFAULT_H = 160; + var ZOOM_MIN = 1, ZOOM_MAX = 2.5; + + var els = null; // DOM refs, built once + var state = null; // { naturalW, naturalH, focalX, focalY, zoom, onApply } + var drag = null; // { startX, startY, startFocalX, startFocalY } while dragging + + function ensureBuilt() { + if (els) return; + + var overlay = document.createElement('div'); + overlay.className = 'photo-crop-overlay'; + overlay.innerHTML = + '
' + + '

Reposition Photo

' + + '
' + + '
' + + ' ' + + '
' + + '
' + + ' ' + + '
' + + '
' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '

Drag the photo to reposition it. Scroll or use the slider to zoom.

' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + document.body.appendChild(overlay); + + els = { + overlay: overlay, + frame: overlay.querySelector('.photo-crop-frame'), + frameImg: overlay.querySelector('.photo-crop-frame-img'), + ref: overlay.querySelector('.photo-crop-ref'), + refImg: overlay.querySelector('.photo-crop-ref-img'), + refRect: overlay.querySelector('.photo-crop-ref-rect'), + zoomSlider: overlay.querySelector('.photo-crop-zoom-slider'), + }; + + overlay.addEventListener('click', function (e) { + if (e.target === overlay) close(); + }); + overlay.querySelector('.photo-crop-cancel').addEventListener('click', close); + overlay.querySelector('.photo-crop-reset').addEventListener('click', function () { + state.focalX = 50; state.focalY = 50; state.zoom = 1; + render(); + }); + overlay.querySelector('.photo-crop-apply').addEventListener('click', function () { + var cb = state.onApply; + var fx = state.focalX, fy = state.focalY, z = state.zoom; + close(); + if (cb) cb(fx, fy, z); + }); + + els.zoomSlider.addEventListener('input', function () { + state.zoom = parseFloat(els.zoomSlider.value); + clampFocal(); + render(); + }); + + els.frame.addEventListener('wheel', function (e) { + e.preventDefault(); + var delta = e.deltaY < 0 ? 0.1 : -0.1; + state.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, state.zoom + delta)); + clampFocal(); + render(); + }, { passive: false }); + + els.frame.addEventListener('pointerdown', function (e) { + drag = { startX: e.clientX, startY: e.clientY, startFocalX: state.focalX, startFocalY: state.focalY }; + els.frame.setPointerCapture(e.pointerId); + }); + els.frame.addEventListener('pointermove', function (e) { + if (!drag) return; + var f = frameSize(); + var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH); + var effW = state.naturalW * coverScale * state.zoom; + var effH = state.naturalH * coverScale * state.zoom; + var dxPct = -((e.clientX - drag.startX) / effW) * 100; + var dyPct = -((e.clientY - drag.startY) / effH) * 100; + state.focalX = drag.startFocalX + dxPct; + state.focalY = drag.startFocalY + dyPct; + clampFocal(); + render(); + }); + var endDrag = function () { drag = null; }; + els.frame.addEventListener('pointerup', endDrag); + els.frame.addEventListener('pointercancel', endDrag); + } + + function frameSize() { + var r = els.frame.getBoundingClientRect(); + return { w: r.width, h: r.height }; + } + function refSize() { + var r = els.ref.getBoundingClientRect(); + return { w: r.width, h: r.height }; + } + + /** Keep the crop window within the image's natural bounds. */ + function clampFocal() { + var f = frameSize(); + var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH); + var cropW = f.w / (coverScale * state.zoom); + var cropH = f.h / (coverScale * state.zoom); + var minX = (cropW / 2) / state.naturalW * 100; + var maxX = 100 - minX; + var minY = (cropH / 2) / state.naturalH * 100; + var maxY = 100 - minY; + // If the crop window is bigger than the image on an axis (zoom≈min), + // min > max — just center it. + state.focalX = minX <= maxX ? Math.max(minX, Math.min(maxX, state.focalX)) : 50; + state.focalY = minY <= maxY ? Math.max(minY, Math.min(maxY, state.focalY)) : 50; + } + + function render() { + var fx = state.focalX.toFixed(2), fy = state.focalY.toFixed(2), z = state.zoom.toFixed(3); + var cropStyle = 'object-position:' + fx + '% ' + fy + '%;transform:scale(' + z + ');transform-origin:' + fx + '% ' + fy + '%;'; + els.frameImg.style.cssText = cropStyle; + els.zoomSlider.value = state.zoom; + + // Reference thumbnail: full image, contain-fit, with a rectangle + // overlay marking the current crop window (dimmed outside it via + // a CSS box-shadow "spotlight"). + var f = frameSize(), rf = refSize(); + var containScale = Math.min(rf.w / state.naturalW, rf.h / state.naturalH); + var imgW = state.naturalW * containScale, imgH = state.naturalH * containScale; + var offX = (rf.w - imgW) / 2, offY = (rf.h - imgH) / 2; + + var coverScale = Math.max(f.w / state.naturalW, f.h / state.naturalH); + var cropNatW = f.w / (coverScale * state.zoom); + var cropNatH = f.h / (coverScale * state.zoom); + var centerXnat = (state.focalX / 100) * state.naturalW; + var centerYnat = (state.focalY / 100) * state.naturalH; + var cropLeftNat = centerXnat - cropNatW / 2; + var cropTopNat = centerYnat - cropNatH / 2; + + var rectLeft = offX + cropLeftNat * containScale; + var rectTop = offY + cropTopNat * containScale; + var rectW = cropNatW * containScale; + var rectH = cropNatH * containScale; + + els.refRect.style.left = rectLeft + 'px'; + els.refRect.style.top = rectTop + 'px'; + els.refRect.style.width = rectW + 'px'; + els.refRect.style.height = rectH + 'px'; + } + + function open(opts) { + ensureBuilt(); + state = { + naturalW: 0, naturalH: 0, + focalX: opts.focalX != null ? opts.focalX : 50, + focalY: opts.focalY != null ? opts.focalY : 50, + zoom: opts.zoom != null ? opts.zoom : 1, + onApply: opts.onApply, + }; + els.overlay.classList.add('open'); + els.frameImg.style.cssText = ''; + els.refRect.style.display = 'none'; + + var loader = new Image(); + loader.onload = function () { + state.naturalW = loader.naturalWidth; + state.naturalH = loader.naturalHeight; + els.frameImg.src = opts.imageUrl; + els.refImg.src = opts.imageUrl; + els.refRect.style.display = ''; + clampFocal(); + render(); + }; + loader.src = opts.imageUrl; + } + + function close() { + if (els) els.overlay.classList.remove('open'); + drag = null; + } + + return { open: open, close: close }; +})(); diff --git a/assets/js/setup.js b/assets/js/setup.js index f3c6311..1c0ab72 100644 --- a/assets/js/setup.js +++ b/assets/js/setup.js @@ -89,6 +89,34 @@ // ------------------------------------------------------------------ var photoUploading = false; + function applyPhotoCropStyle() { + var previewImg = document.getElementById('photo-preview'); + var fx = document.getElementById('photo_focal_x').value; + var fy = document.getElementById('photo_focal_y').value; + var zoom = document.getElementById('photo_zoom').value; + previewImg.style.cssText = + 'object-position:' + fx + '% ' + fy + '%;transform:scale(' + zoom + ');transform-origin:' + fx + '% ' + fy + '%;'; + } + + var repositionBtn = document.getElementById('photo-reposition'); + if (repositionBtn) { + repositionBtn.addEventListener('click', function () { + var previewImg = document.getElementById('photo-preview'); + PhotoCrop.open({ + imageUrl: previewImg.src, + focalX: parseFloat(document.getElementById('photo_focal_x').value), + focalY: parseFloat(document.getElementById('photo_focal_y').value), + zoom: parseFloat(document.getElementById('photo_zoom').value), + onApply: function (fx, fy, zoom) { + document.getElementById('photo_focal_x').value = fx; + document.getElementById('photo_focal_y').value = fy; + document.getElementById('photo_zoom').value = zoom; + applyPhotoCropStyle(); + } + }); + }); + } + if (photoInput) { photoInput.addEventListener('change', async function () { if (!this.files || !this.files[0]) return; @@ -115,6 +143,16 @@ uploadStatus.textContent = 'Upload failed: ' + data.error; } else { document.getElementById('photo_path').value = data.path; + // A newly uploaded photo starts centered/unzoomed. + document.getElementById('photo_focal_x').value = 50; + document.getElementById('photo_focal_y').value = 50; + document.getElementById('photo_zoom').value = 1; + var previewImg = document.getElementById('photo-preview'); + var previewWrap = document.querySelector('#field-photo .photo-preview-wrap'); + previewImg.src = '/' + data.path.replace(/^\//, ''); + applyPhotoCropStyle(); + previewWrap.style.display = ''; + document.getElementById('photo-reposition').style.display = ''; // Clear the file input so the file is not re-sent with the main form photoInput.value = ''; uploadStatus.className = 'upload-status success'; diff --git a/includes/photo.php b/includes/photo.php new file mode 100644 index 0000000..d9db383 --- /dev/null +++ b/includes/photo.php @@ -0,0 +1,26 @@ + using + * object-fit: cover. Falls back to 50/50/1 (dead center, no zoom — today's + * default behavior) when the row predates this feature or the values are + * otherwise missing, so old photos render exactly as they always have. + */ +function photo_crop_style(array $row): string { + $x = isset($row['photo_focal_x']) && $row['photo_focal_x'] !== null ? (float)$row['photo_focal_x'] : 50.0; + $y = isset($row['photo_focal_y']) && $row['photo_focal_y'] !== null ? (float)$row['photo_focal_y'] : 50.0; + $zoom = isset($row['photo_zoom']) && $row['photo_zoom'] !== null ? (float)$row['photo_zoom'] : 1.0; + + $x = max(0, min(100, $x)); + $y = max(0, min(100, $y)); + $zoom = max(1, min(3, $zoom)); + + return sprintf( + 'object-position:%.2f%% %.2f%%;transform:scale(%.3f);transform-origin:%.2f%% %.2f%%;', + $x, $y, $zoom, $x, $y + ); +} diff --git a/index.php b/index.php index 4079bfd..273dbd7 100644 --- a/index.php +++ b/index.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/donate.php'; require_once __DIR__ . '/includes/csrf.php'; +require_once __DIR__ . '/includes/photo.php'; _auth_start(); $pdo = get_pdo(); @@ -22,6 +23,7 @@ $is_admin = $logged_in && has_role('admin'); // Pinned sessions $pinned_sessions = $pdo->query(" SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, + s.photo_focal_x, s.photo_focal_y, s.photo_zoom, s.is_pinned, s.created_at, u.username, u.display_name FROM sessions s JOIN users u ON u.id = s.user_id @@ -35,6 +37,7 @@ $pinned_sessions = $pdo->query(" // Pinned novena groups $pinned_novenas = $pdo->query(" SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, + ng.photo_focal_x, ng.photo_focal_y, ng.photo_zoom, ng.is_pinned, ng.created_at, u.username, u.display_name, COUNT(s.id) AS day_count FROM novena_groups ng @@ -50,6 +53,7 @@ $pinned_novenas = $pdo->query(" // Regular (unpinned) sessions $sessions = $pdo->query(" SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, + s.photo_focal_x, s.photo_focal_y, s.photo_zoom, s.is_pinned, s.created_at, u.username, u.display_name FROM sessions s JOIN users u ON u.id = s.user_id @@ -63,6 +67,7 @@ $sessions = $pdo->query(" // Regular (unpinned) novena groups $novenas = $pdo->query(" SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, + ng.photo_focal_x, ng.photo_focal_y, ng.photo_zoom, ng.is_pinned, ng.created_at, u.username, u.display_name, COUNT(s.id) AS day_count FROM novena_groups ng @@ -163,7 +168,7 @@ function render_card(array $row, bool $is_admin, array $mystery_labels, array $o + alt="" style="">
diff --git a/install.php b/install.php index 67bd523..7859a3e 100644 --- a/install.php +++ b/install.php @@ -41,6 +41,9 @@ inst_sql($pdo, 'Create sessions table', " subject_pronoun VARCHAR(10) NULL, subject_dates VARCHAR(150) NULL, photo_path VARCHAR(500) NULL, + photo_focal_x FLOAT NOT NULL DEFAULT 50, + photo_focal_y FLOAT NOT NULL DEFAULT 50, + photo_zoom FLOAT NOT NULL DEFAULT 1, is_pinned TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP @@ -59,6 +62,9 @@ inst_sql($pdo, 'Create novena_groups table', " subject_pronoun VARCHAR(10) NULL, subject_dates VARCHAR(150) NULL, photo_path VARCHAR(500) NULL, + photo_focal_x FLOAT NOT NULL DEFAULT 50, + photo_focal_y FLOAT NOT NULL DEFAULT 50, + photo_zoom FLOAT NOT NULL DEFAULT 1, is_pinned TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP diff --git a/novena_public.php b/novena_public.php index 0f2938e..ec91cc8 100644 --- a/novena_public.php +++ b/novena_public.php @@ -6,6 +6,7 @@ require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/donate.php'; +require_once __DIR__ . '/includes/photo.php'; _auth_start(); $pdo = get_pdo(); @@ -93,7 +94,8 @@ $photo_src = $group['photo_path'] ? ('/' . ltrim($group['photo_path'], '/')) : '
- +
diff --git a/profile.php b/profile.php index c49e39c..db2b423 100644 --- a/profile.php +++ b/profile.php @@ -5,6 +5,7 @@ */ require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/includes/auth.php'; +require_once __DIR__ . '/includes/photo.php'; _auth_start(); $pdo = get_pdo(); @@ -35,7 +36,8 @@ $initials = strtoupper(mb_substr($disp_name, 0, 1)); // Load public sessions $sessions = $pdo->prepare(" - SELECT id, name, occasion, mystery_set, subject_name, photo_path, slug, created_at + SELECT id, name, occasion, mystery_set, subject_name, photo_path, + photo_focal_x, photo_focal_y, photo_zoom, slug, created_at FROM sessions WHERE user_id = ? AND is_public = 1 AND occasion != 'novena_deceased' ORDER BY created_at DESC @@ -45,7 +47,8 @@ $sessions = $sessions->fetchAll(); // Load public novena groups $novenas = $pdo->prepare(" - SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, ng.created_at, + SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, + ng.photo_focal_x, ng.photo_focal_y, ng.photo_zoom, ng.slug, ng.created_at, COUNT(s.id) AS day_count FROM novena_groups ng LEFT JOIN sessions s ON s.novena_group_id = ng.id @@ -127,7 +130,7 @@ $occasion_labels = [ + alt="" style="">
diff --git a/schema.sql b/schema.sql index 7fd9d64..4bb756a 100644 --- a/schema.sql +++ b/schema.sql @@ -14,8 +14,10 @@ -- For an EXISTING production install that already ran the old migrate_v2..v6 -- scripts, applying this file is a no-op EXCEPT for the new -- users.failed_login_attempts / users.locked_until columns added for login --- rate-limiting -- see README.md "Upgrading an Existing Install" for the one --- manual ALTER TABLE needed there. +-- rate-limiting, and the sessions/novena_groups.photo_focal_x/photo_focal_y/ +-- photo_zoom columns added for the photo reposition tool -- see README.md +-- "Upgrading an Existing Install" for the manual ALTER TABLE statements +-- needed there. CREATE TABLE IF NOT EXISTS sessions ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -31,6 +33,9 @@ CREATE TABLE IF NOT EXISTS sessions ( subject_pronoun VARCHAR(10) NULL, subject_dates VARCHAR(150) NULL, photo_path VARCHAR(500) NULL, + photo_focal_x FLOAT NOT NULL DEFAULT 50, + photo_focal_y FLOAT NOT NULL DEFAULT 50, + photo_zoom FLOAT NOT NULL DEFAULT 1, is_pinned TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP @@ -47,6 +52,9 @@ CREATE TABLE IF NOT EXISTS novena_groups ( subject_pronoun VARCHAR(10) NULL, subject_dates VARCHAR(150) NULL, photo_path VARCHAR(500) NULL, + photo_focal_x FLOAT NOT NULL DEFAULT 50, + photo_focal_y FLOAT NOT NULL DEFAULT 50, + photo_zoom FLOAT NOT NULL DEFAULT 1, is_pinned TINYINT(1) NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP