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