Add drag-to-pan + zoom photo repositioning for card/avatar crops

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 15:10:43 -07:00
parent 524860df1f
commit 3a1433b71e
16 changed files with 655 additions and 42 deletions
+14
View File
@@ -88,6 +88,20 @@ ALTER TABLE users
ADD COLUMN locked_until DATETIME NULL; 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 ## Deployment Checklist
- [ ] `config/db.php` filled in with production credentials - [ ] `config/db.php` filled in with production credentials
+33
View File
@@ -7,6 +7,7 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php'; require_once __DIR__ . '/../includes/csrf.php';
require_once __DIR__ . '/../includes/photo.php';
require_role('superuser'); require_role('superuser');
@@ -73,6 +74,7 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/builder.css?v=1">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/photo-crop.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>"> <meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
</head> </head>
<body> <body>
@@ -148,6 +150,36 @@ $page_title = $session ? 'Edit: ' . htmlspecialchars($session['name']) : 'Rosary
</div> </div>
</details> </details>
<!-- Title page photo -->
<details class="builder-subject" open>
<summary>Title page photo (optional — shown on the cover slide and in listings)</summary>
<div class="builder-subject-fields" id="photo-group">
<?php if ($session && $session['photo_path']): ?>
<div class="photo-preview-wrap">
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($session['photo_path'], '/')) ?>"
alt="Current photo" class="photo-preview" style="<?= htmlspecialchars(photo_crop_style($session)) ?>">
</div>
<?php else: ?>
<div class="photo-preview-wrap" style="display:none">
<img id="photo-preview" src="" alt="" class="photo-preview">
</div>
<?php endif; ?>
<input type="hidden" id="photo-path" value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>">
<input type="hidden" id="photo-focal-x" value="<?= htmlspecialchars($session['photo_focal_x'] ?? 50) ?>">
<input type="hidden" id="photo-focal-y" value="<?= htmlspecialchars($session['photo_focal_y'] ?? 50) ?>">
<input type="hidden" id="photo-zoom" value="<?= htmlspecialchars($session['photo_zoom'] ?? 1) ?>">
<label class="btn btn-secondary btn-upload">
<?= ($session && $session['photo_path']) ? 'Replace Photo' : 'Upload Photo' ?>
<input type="file" id="photo-file" accept="image/*" style="display:none">
</label>
<button type="button" id="photo-reposition" class="btn btn-secondary"
style="<?= ($session && $session['photo_path']) ? '' : 'display:none' ?>">
Reposition
</button>
<span id="photo-status" class="form-help"></span>
</div>
</details>
<!-- Two-panel body --> <!-- Two-panel body -->
<div class="builder-body"> <div class="builder-body">
@@ -276,6 +308,7 @@ var EXISTING_STEPS = <?= json_encode(array_map(fn($s) => [
'attribution' => $s['attribution'] ?? 'leader_all', 'attribution' => $s['attribution'] ?? 'leader_all',
], $edit_steps)) ?>; ], $edit_steps)) ?>;
</script> </script>
<script src="<?= BASE_URL ?>/assets/js/photo-crop.js"></script>
<script src="<?= BASE_URL ?>/assets/js/builder.js?v=1"></script> <script src="<?= BASE_URL ?>/assets/js/builder.js?v=1"></script>
</body> </body>
</html> </html>
+48 -3
View File
@@ -5,6 +5,7 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php'; require_once __DIR__ . '/../includes/csrf.php';
require_once __DIR__ . '/../includes/photo.php';
require_auth(); require_auth();
@@ -61,6 +62,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
csrf_verify(); csrf_verify();
$g_name = trim($_POST['g_name'] ?? ''); $g_name = trim($_POST['g_name'] ?? '');
$g_photo = trim($_POST['g_photo'] ?? '') ?: null; $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; $g_public = isset($_POST['is_public']) ? 1 : 0;
if ($is_dm) { if ($is_dm) {
@@ -88,19 +92,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
SET name = ?, mystery_set = ?, subject_name = ?, SET name = ?, mystery_set = ?, subject_name = ?,
subject_pronoun = ?, subject_dates = ?, subject_pronoun = ?, subject_dates = ?,
photo_path = COALESCE(?, photo_path), photo_path = COALESCE(?, photo_path),
photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?,
is_public = ? is_public = ?
WHERE id = ? 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(' $pdo->prepare('
UPDATE sessions UPDATE sessions
SET mystery_set = ?, subject_name = ?, SET mystery_set = ?, subject_name = ?,
subject_pronoun = ?, subject_dates = ?, subject_pronoun = ?, subject_dates = ?,
photo_path = COALESCE(?, photo_path), photo_path = COALESCE(?, photo_path),
photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?,
name = CONCAT(?, CONCAT(\' — Day \', novena_day)), name = CONCAT(?, CONCAT(\' — Day \', novena_day)),
is_public = ? is_public = ?
WHERE novena_group_id = ? 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; $save_success = true;
// Reload group // Reload group
@@ -131,6 +137,7 @@ $mystery_labels = [
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/photo-crop.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>"> <meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
<script>var BASE_URL = '<?= BASE_URL ?>';</script> <script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head> </head>
@@ -234,7 +241,7 @@ $mystery_labels = [
<?php if ($group['photo_path']): ?> <?php if ($group['photo_path']): ?>
<div class="photo-preview-wrap"> <div class="photo-preview-wrap">
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>" <img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>"
alt="Current photo" class="photo-preview"> alt="Current photo" class="photo-preview" style="<?= htmlspecialchars(photo_crop_style($group)) ?>">
</div> </div>
<?php else: ?> <?php else: ?>
<div class="photo-preview-wrap" style="display:none"> <div class="photo-preview-wrap" style="display:none">
@@ -243,10 +250,16 @@ $mystery_labels = [
<?php endif; ?> <?php endif; ?>
<input type="hidden" id="g_photo" name="g_photo" <input type="hidden" id="g_photo" name="g_photo"
value="<?= htmlspecialchars($group['photo_path'] ?? '') ?>"> value="<?= htmlspecialchars($group['photo_path'] ?? '') ?>">
<input type="hidden" id="g_photo_focal_x" name="g_photo_focal_x" value="<?= htmlspecialchars($group['photo_focal_x'] ?? 50) ?>">
<input type="hidden" id="g_photo_focal_y" name="g_photo_focal_y" value="<?= htmlspecialchars($group['photo_focal_y'] ?? 50) ?>">
<input type="hidden" id="g_photo_zoom" name="g_photo_zoom" value="<?= htmlspecialchars($group['photo_zoom'] ?? 1) ?>">
<label class="btn btn-secondary btn-upload" style="margin-top:8px"> <label class="btn btn-secondary btn-upload" style="margin-top:8px">
<?= $group['photo_path'] ? 'Replace Photo' : 'Upload Photo' ?> <?= $group['photo_path'] ? 'Replace Photo' : 'Upload Photo' ?>
<input type="file" id="photo-file" accept="image/*" style="display:none"> <input type="file" id="photo-file" accept="image/*" style="display:none">
</label> </label>
<button type="button" id="photo-reposition" class="btn btn-secondary" style="margin-top:8px;<?= $group['photo_path'] ? '' : 'display:none' ?>">
Reposition
</button>
<span id="photo-status" class="form-help"></span> <span id="photo-status" class="form-help"></span>
</div> </div>
@@ -311,16 +324,27 @@ $mystery_labels = [
</main> </main>
</div> </div>
<script src="<?= BASE_URL ?>/assets/js/photo-crop.js"></script>
<script> <script>
(function () { (function () {
var fileInput = document.getElementById('photo-file'); var fileInput = document.getElementById('photo-file');
var photoHidden = document.getElementById('g_photo'); var photoHidden = document.getElementById('g_photo');
var focalXHidden = document.getElementById('g_photo_focal_x');
var focalYHidden = document.getElementById('g_photo_focal_y');
var zoomHidden = document.getElementById('g_photo_zoom');
var photoStatus = document.getElementById('photo-status'); var photoStatus = document.getElementById('photo-status');
var previewWrap = document.querySelector('.photo-preview-wrap'); var previewWrap = document.querySelector('.photo-preview-wrap');
var previewImg = document.getElementById('photo-preview'); var previewImg = document.getElementById('photo-preview');
var repositionBtn = document.getElementById('photo-reposition');
if (!fileInput) return; if (!fileInput) return;
function applyCropStyle() {
var fx = focalXHidden.value, fy = focalYHidden.value, zoom = zoomHidden.value;
previewImg.style.cssText =
'object-position:' + fx + '% ' + fy + '%;transform:scale(' + zoom + ');transform-origin:' + fx + '% ' + fy + '%;';
}
fileInput.addEventListener('change', function () { fileInput.addEventListener('change', function () {
var file = fileInput.files[0]; var file = fileInput.files[0];
if (!file) return; if (!file) return;
@@ -334,8 +358,14 @@ $mystery_labels = [
.then(function (data) { .then(function (data) {
if (data.path) { if (data.path) {
photoHidden.value = data.path; photoHidden.value = data.path;
// A newly uploaded photo starts centered/unzoomed.
focalXHidden.value = 50;
focalYHidden.value = 50;
zoomHidden.value = 1;
previewImg.src = '/' + data.path.replace(/^\//, ''); previewImg.src = '/' + data.path.replace(/^\//, '');
applyCropStyle();
previewWrap.style.display = ''; previewWrap.style.display = '';
repositionBtn.style.display = '';
photoStatus.textContent = 'Photo ready.'; photoStatus.textContent = 'Photo ready.';
} else { } else {
photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error'); photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error');
@@ -343,6 +373,21 @@ $mystery_labels = [
}) })
.catch(function () { photoStatus.textContent = 'Upload failed.'; }); .catch(function () { photoStatus.textContent = 'Upload failed.'; });
}); });
repositionBtn.addEventListener('click', function () {
PhotoCrop.open({
imageUrl: previewImg.src,
focalX: parseFloat(focalXHidden.value),
focalY: parseFloat(focalYHidden.value),
zoom: parseFloat(zoomHidden.value),
onApply: function (fx, fy, zoom) {
focalXHidden.value = fx;
focalYHidden.value = fy;
zoomHidden.value = zoom;
applyCropStyle();
}
});
});
}()); }());
</script> </script>
</body> </body>
+14 -6
View File
@@ -5,6 +5,7 @@
require_once __DIR__ . '/../config/db.php'; require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php'; require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/csrf.php'; require_once __DIR__ . '/../includes/csrf.php';
require_once __DIR__ . '/../includes/photo.php';
require_auth(); require_auth();
@@ -47,6 +48,7 @@ $page_title = $session ? 'Edit Session' : 'New Session';
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg"> <link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title> <title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css"> <link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/photo-crop.css">
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>"> <meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
<script>var BASE_URL = '<?= BASE_URL ?>';</script> <script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head> </head>
@@ -213,17 +215,22 @@ $page_title = $session ? 'Edit Session' : 'New Session';
<!-- Photo --> <!-- Photo -->
<div class="form-group" id="field-photo"> <div class="form-group" id="field-photo">
<label for="photo">Photo (optional)</label> <label for="photo">Photo (optional)</label>
<?php if (!empty($session['photo_path'])): ?> <div class="photo-preview-wrap" style="<?= empty($session['photo_path']) ? 'display:none' : '' ?>">
<div class="photo-preview"> <img id="photo-preview"
<img src="<?= htmlspecialchars('/' . ltrim($session['photo_path'], '/')) ?>" src="<?= !empty($session['photo_path']) ? htmlspecialchars('/' . ltrim($session['photo_path'], '/')) : '' ?>"
alt="Current photo" style="max-height:120px"> alt="Current photo" class="photo-preview"
<p class="help-text">Current photo. Upload a new one to replace it.</p> style="<?= !empty($session['photo_path']) ? htmlspecialchars(photo_crop_style($session)) : '' ?>">
</div> </div>
<?php endif; ?>
<input type="file" id="photo" name="photo" accept="image/*"> <input type="file" id="photo" name="photo" accept="image/*">
<p class="help-text">JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.</p> <p class="help-text">JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.</p>
<input type="hidden" id="photo_path" name="photo_path" <input type="hidden" id="photo_path" name="photo_path"
value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>"> value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>">
<input type="hidden" id="photo_focal_x" name="photo_focal_x" value="<?= htmlspecialchars($session['photo_focal_x'] ?? 50) ?>">
<input type="hidden" id="photo_focal_y" name="photo_focal_y" value="<?= htmlspecialchars($session['photo_focal_y'] ?? 50) ?>">
<input type="hidden" id="photo_zoom" name="photo_zoom" value="<?= htmlspecialchars($session['photo_zoom'] ?? 1) ?>">
<button type="button" id="photo-reposition" class="btn btn-secondary" style="margin-top:8px;<?= empty($session['photo_path']) ? 'display:none' : '' ?>">
Reposition
</button>
<div id="upload-status" style="display:none" class="upload-status"></div> <div id="upload-status" style="display:none" class="upload-status"></div>
</div> </div>
@@ -252,6 +259,7 @@ $page_title = $session ? 'Edit Session' : 'New Session';
</main> </main>
</div> </div>
<script src="<?= BASE_URL ?>/assets/js/photo-crop.js"></script>
<script src="<?= BASE_URL ?>/assets/js/setup.js?v=5"></script> <script src="<?= BASE_URL ?>/assets/js/setup.js?v=5"></script>
</body> </body>
</html> </html>
+10 -5
View File
@@ -45,6 +45,10 @@ $is_public = (int)!empty($body['is_public']);
$subject_name = trim($body['subject_name'] ?? ''); $subject_name = trim($body['subject_name'] ?? '');
$subject_pronoun = in_array($body['subject_pronoun'] ?? '', ['he','she']) ? $body['subject_pronoun'] : 'he'; $subject_pronoun = in_array($body['subject_pronoun'] ?? '', ['he','she']) ? $body['subject_pronoun'] : 'he';
$subject_dates = trim($body['subject_dates'] ?? ''); $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'] ?? []; $steps = $body['steps'] ?? [];
$session_id = (int)($body['id'] ?? 0); $session_id = (int)($body['id'] ?? 0);
@@ -105,9 +109,9 @@ try {
$pdo->prepare(" $pdo->prepare("
UPDATE sessions UPDATE sessions
SET name=?, is_public=?, subject_name=?, subject_pronoun=?, subject_dates=?, 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=? 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 // Replace all steps
$pdo->prepare("DELETE FROM builder_steps WHERE session_id = ?")->execute([$session_id]); $pdo->prepare("DELETE FROM builder_steps WHERE session_id = ?")->execute([$session_id]);
@@ -120,9 +124,10 @@ try {
$pdo->prepare(" $pdo->prepare("
INSERT INTO sessions INSERT INTO sessions
(user_id, is_public, slug, name, occasion, mystery_set, (user_id, is_public, slug, name, occasion, mystery_set,
subject_name, subject_pronoun, subject_dates) subject_name, subject_pronoun, subject_dates, photo_path,
VALUES (?, ?, ?, ?, 'custom', 'custom', ?, ?, ?) photo_focal_x, photo_focal_y, photo_zoom)
")->execute([$uid, $is_public, $slug, $name, $subject_name ?: null, $subject_pronoun, $subject_dates ?: null]); 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(); $session_id = (int)$pdo->lastInsertId();
} }
+26 -14
View File
@@ -37,6 +37,9 @@ $subject_name = trim($_POST['subject_name'] ?? '') ?: null;
$subject_pronoun = trim($_POST['subject_pronoun'] ?? '') ?: null; $subject_pronoun = trim($_POST['subject_pronoun'] ?? '') ?: null;
$subject_dates = trim($_POST['subject_dates'] ?? '') ?: null; $subject_dates = trim($_POST['subject_dates'] ?? '') ?: null;
$photo_path = trim($_POST['photo_path'] ?? '') ?: 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; $is_public = isset($_POST['is_public']) ? 1 : 0;
// For novena sessions, mystery_set is determined by novena_mystery_mode // For novena sessions, mystery_set is determined by novena_mystery_mode
@@ -102,6 +105,7 @@ try {
SET name = ?, occasion = ?, mystery_set = ?, SET name = ?, occasion = ?, mystery_set = ?,
subject_name = ?, subject_pronoun = ?, subject_dates = ?, subject_name = ?, subject_pronoun = ?, subject_dates = ?,
photo_path = COALESCE(?, photo_path), photo_path = COALESCE(?, photo_path),
photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?,
is_public = ?' . is_public = ?' .
($new_slug !== null ? ', slug = ?' : '') . ' ($new_slug !== null ? ', slug = ?' : '') . '
WHERE id = ? WHERE id = ?
@@ -110,7 +114,7 @@ try {
$params = [ $params = [
$name, $occasion, $mystery_set, $name, $occasion, $mystery_set,
$subject_name, $subject_pronoun, $subject_dates, $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; if ($new_slug !== null) $params[] = $new_slug;
$params[] = $id; $params[] = $id;
@@ -137,18 +141,20 @@ try {
$grp = $pdo->prepare(' $grp = $pdo->prepare('
INSERT INTO novena_groups INSERT INTO novena_groups
(name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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(); $group_id = (int)$pdo->lastInsertId();
$insert = $pdo->prepare(' $insert = $pdo->prepare('
INSERT INTO sessions INSERT INTO sessions
(name, occasion, mystery_set, novena_day, (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) user_id, is_public, slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'); ');
$created_ids = []; $created_ids = [];
for ($day = 1; $day <= 9; $day++) { for ($day = 1; $day <= 9; $day++) {
@@ -156,7 +162,8 @@ try {
$day_slug = unique_slug($day_name, $uid, 'sessions'); $day_slug = unique_slug($day_name, $uid, 'sessions');
$insert->execute([ $insert->execute([
$day_name, $occasion, $mystery_set, $day, $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, $uid, $is_public, $day_slug,
]); ]);
$created_ids[] = (int)$pdo->lastInsertId(); $created_ids[] = (int)$pdo->lastInsertId();
@@ -170,18 +177,20 @@ try {
$grp = $pdo->prepare(' $grp = $pdo->prepare('
INSERT INTO novena_groups INSERT INTO novena_groups
(name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path, user_id, is_public, slug) (name, mystery_set, subject_name, subject_pronoun, subject_dates, photo_path,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 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(); $group_id = (int)$pdo->lastInsertId();
$insert = $pdo->prepare(' $insert = $pdo->prepare('
INSERT INTO sessions INSERT INTO sessions
(name, occasion, mystery_set, novena_day, (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) user_id, is_public, slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'); ');
$created_ids = []; $created_ids = [];
for ($day = 1; $day <= 9; $day++) { for ($day = 1; $day <= 9; $day++) {
@@ -189,7 +198,8 @@ try {
$day_slug = unique_slug($day_name, $uid, 'sessions'); $day_slug = unique_slug($day_name, $uid, 'sessions');
$insert->execute([ $insert->execute([
$day_name, $occasion, $mystery_set, $day, $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, $uid, $is_public, $day_slug,
]); ]);
$created_ids[] = (int)$pdo->lastInsertId(); $created_ids[] = (int)$pdo->lastInsertId();
@@ -207,12 +217,14 @@ try {
INSERT INTO sessions INSERT INTO sessions
(name, occasion, mystery_set, novena_day, (name, occasion, mystery_set, novena_day,
subject_name, subject_pronoun, subject_dates, photo_path, subject_name, subject_pronoun, subject_dates, photo_path,
photo_focal_x, photo_focal_y, photo_zoom,
user_id, is_public, slug) user_id, is_public, slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'); ');
$stmt->execute([ $stmt->execute([
$name, $occasion, $mystery_set, $novena_day, $name, $occasion, $mystery_set, $novena_day,
$subject_name, $subject_pronoun, $subject_dates, $photo_path, $subject_name, $subject_pronoun, $subject_dates, $photo_path,
$photo_focal_x, $photo_focal_y, $photo_zoom,
$uid, $is_public, $slug, $uid, $is_public, $slug,
]); ]);
echo json_encode(['id' => (int)$pdo->lastInsertId()]); echo json_encode(['id' => (int)$pdo->lastInsertId()]);
+118
View File
@@ -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%; }
}
+77
View File
@@ -97,6 +97,79 @@
// Save session // Save session
document.getElementById('btn-save').addEventListener('click', saveSession); 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_name: document.getElementById('subject-name').value.trim(),
subject_pronoun: document.getElementById('subject-pronoun').value, subject_pronoun: document.getElementById('subject-pronoun').value,
subject_dates: document.getElementById('subject-dates').value.trim(), 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' steps: STEPS.map(s => s.step_type === 'bead'
? { step_type: 'bead', bead_type: s.bead_type } ? { 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 }), : { step_type: 'prayer', prayer_id: s.prayer_id, attribution: s.attribution, bead_type: s.bead_type || null }),
+213
View File
@@ -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 =
'<div class="photo-crop-box">' +
' <h3>Reposition Photo</h3>' +
' <div class="photo-crop-main">' +
' <div class="photo-crop-frame" style="width:' + FRAME_DEFAULT_W + 'px;height:' + FRAME_DEFAULT_H + 'px">' +
' <img class="photo-crop-frame-img" alt="">' +
' </div>' +
' <div class="photo-crop-ref" style="width:' + REF_DEFAULT_W + 'px;height:' + REF_DEFAULT_H + 'px">' +
' <img class="photo-crop-ref-img" alt="">' +
' <div class="photo-crop-ref-rect"></div>' +
' </div>' +
' </div>' +
' <div class="photo-crop-zoom-row">' +
' <label>Zoom</label>' +
' <input type="range" class="photo-crop-zoom-slider" min="' + ZOOM_MIN + '" max="' + ZOOM_MAX + '" step="0.05">' +
' </div>' +
' <p class="photo-crop-hint">Drag the photo to reposition it. Scroll or use the slider to zoom.</p>' +
' <div class="photo-crop-actions">' +
' <button type="button" class="btn btn-ghost photo-crop-reset">Reset</button>' +
' <div style="flex:1"></div>' +
' <button type="button" class="btn btn-ghost photo-crop-cancel">Cancel</button>' +
' <button type="button" class="btn btn-primary photo-crop-apply">Apply</button>' +
' </div>' +
'</div>';
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 };
})();
+38
View File
@@ -89,6 +89,34 @@
// ------------------------------------------------------------------ // ------------------------------------------------------------------
var photoUploading = false; 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) { if (photoInput) {
photoInput.addEventListener('change', async function () { photoInput.addEventListener('change', async function () {
if (!this.files || !this.files[0]) return; if (!this.files || !this.files[0]) return;
@@ -115,6 +143,16 @@
uploadStatus.textContent = 'Upload failed: ' + data.error; uploadStatus.textContent = 'Upload failed: ' + data.error;
} else { } else {
document.getElementById('photo_path').value = data.path; 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 // Clear the file input so the file is not re-sent with the main form
photoInput.value = ''; photoInput.value = '';
uploadStatus.className = 'upload-status success'; uploadStatus.className = 'upload-status success';
+26
View File
@@ -0,0 +1,26 @@
<?php
/**
* includes/photo.php — shared helper for the photo reposition/zoom feature.
*/
/**
* Build the inline style="" value that applies a session/novena_group row's
* stored crop (photo_focal_x/photo_focal_y/photo_zoom) to an <img> 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
);
}
+6 -1
View File
@@ -7,6 +7,7 @@ require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/donate.php'; require_once __DIR__ . '/includes/donate.php';
require_once __DIR__ . '/includes/csrf.php'; require_once __DIR__ . '/includes/csrf.php';
require_once __DIR__ . '/includes/photo.php';
_auth_start(); _auth_start();
$pdo = get_pdo(); $pdo = get_pdo();
@@ -22,6 +23,7 @@ $is_admin = $logged_in && has_role('admin');
// Pinned sessions // Pinned sessions
$pinned_sessions = $pdo->query(" $pinned_sessions = $pdo->query("
SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, 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 s.is_pinned, s.created_at, u.username, u.display_name
FROM sessions s FROM sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
@@ -35,6 +37,7 @@ $pinned_sessions = $pdo->query("
// Pinned novena groups // Pinned novena groups
$pinned_novenas = $pdo->query(" $pinned_novenas = $pdo->query("
SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, 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, ng.is_pinned, ng.created_at, u.username, u.display_name,
COUNT(s.id) AS day_count COUNT(s.id) AS day_count
FROM novena_groups ng FROM novena_groups ng
@@ -50,6 +53,7 @@ $pinned_novenas = $pdo->query("
// Regular (unpinned) sessions // Regular (unpinned) sessions
$sessions = $pdo->query(" $sessions = $pdo->query("
SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug, 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 s.is_pinned, s.created_at, u.username, u.display_name
FROM sessions s FROM sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
@@ -63,6 +67,7 @@ $sessions = $pdo->query("
// Regular (unpinned) novena groups // Regular (unpinned) novena groups
$novenas = $pdo->query(" $novenas = $pdo->query("
SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug, 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, ng.is_pinned, ng.created_at, u.username, u.display_name,
COUNT(s.id) AS day_count COUNT(s.id) AS day_count
FROM novena_groups ng FROM novena_groups ng
@@ -163,7 +168,7 @@ function render_card(array $row, bool $is_admin, array $mystery_labels, array $o
<?php if (!empty($row['photo_path'])): ?> <?php if (!empty($row['photo_path'])): ?>
<img class="rosary-card-photo" <img class="rosary-card-photo"
src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>" src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>"
alt=""> alt="" style="<?= htmlspecialchars(photo_crop_style($row)) ?>">
<?php else: ?> <?php else: ?>
<div class="rosary-card-photo-placeholder">&#x271D;</div> <div class="rosary-card-photo-placeholder">&#x271D;</div>
<?php endif; ?> <?php endif; ?>
+6
View File
@@ -41,6 +41,9 @@ inst_sql($pdo, 'Create sessions table', "
subject_pronoun VARCHAR(10) NULL, subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL, subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) 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, is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL, subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) 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, is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+3 -1
View File
@@ -6,6 +6,7 @@
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/donate.php'; require_once __DIR__ . '/includes/donate.php';
require_once __DIR__ . '/includes/photo.php';
_auth_start(); _auth_start();
$pdo = get_pdo(); $pdo = get_pdo();
@@ -93,7 +94,8 @@ $photo_src = $group['photo_path'] ? ('/' . ltrim($group['photo_path'], '/')) : '
<div class="novena-hero"> <div class="novena-hero">
<?php if ($photo_src): ?> <?php if ($photo_src): ?>
<div class="novena-hero-photo-wrap"> <div class="novena-hero-photo-wrap">
<img class="novena-hero-photo" src="<?= htmlspecialchars($photo_src) ?>" alt=""> <img class="novena-hero-photo" src="<?= htmlspecialchars($photo_src) ?>" alt=""
style="<?= htmlspecialchars(photo_crop_style($group)) ?>">
</div> </div>
<?php else: ?> <?php else: ?>
<div class="novena-hero-cross">&#x271D;</div> <div class="novena-hero-cross">&#x271D;</div>
+6 -3
View File
@@ -5,6 +5,7 @@
*/ */
require_once __DIR__ . '/config/db.php'; require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php'; require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/photo.php';
_auth_start(); _auth_start();
$pdo = get_pdo(); $pdo = get_pdo();
@@ -35,7 +36,8 @@ $initials = strtoupper(mb_substr($disp_name, 0, 1));
// Load public sessions // Load public sessions
$sessions = $pdo->prepare(" $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 FROM sessions
WHERE user_id = ? AND is_public = 1 AND occasion != 'novena_deceased' WHERE user_id = ? AND is_public = 1 AND occasion != 'novena_deceased'
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -45,7 +47,8 @@ $sessions = $sessions->fetchAll();
// Load public novena groups // Load public novena groups
$novenas = $pdo->prepare(" $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 COUNT(s.id) AS day_count
FROM novena_groups ng FROM novena_groups ng
LEFT JOIN sessions s ON s.novena_group_id = ng.id LEFT JOIN sessions s ON s.novena_group_id = ng.id
@@ -127,7 +130,7 @@ $occasion_labels = [
<?php if (!empty($row['photo_path'])): ?> <?php if (!empty($row['photo_path'])): ?>
<img class="rosary-card-photo" <img class="rosary-card-photo"
src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>" src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>"
alt=""> alt="" style="<?= htmlspecialchars(photo_crop_style($row)) ?>">
<?php else: ?> <?php else: ?>
<div class="rosary-card-photo-placeholder">&#x271D;</div> <div class="rosary-card-photo-placeholder">&#x271D;</div>
<?php endif; ?> <?php endif; ?>
+10 -2
View File
@@ -14,8 +14,10 @@
-- For an EXISTING production install that already ran the old migrate_v2..v6 -- 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 -- scripts, applying this file is a no-op EXCEPT for the new
-- users.failed_login_attempts / users.locked_until columns added for login -- users.failed_login_attempts / users.locked_until columns added for login
-- rate-limiting -- see README.md "Upgrading an Existing Install" for the one -- rate-limiting, and the sessions/novena_groups.photo_focal_x/photo_focal_y/
-- manual ALTER TABLE needed there. -- 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 ( CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -31,6 +33,9 @@ CREATE TABLE IF NOT EXISTS sessions (
subject_pronoun VARCHAR(10) NULL, subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL, subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) 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, is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL, subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) 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, is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP