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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()]);
|
||||||
|
|||||||
@@ -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%; }
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">✝</div>
|
<div class="rosary-card-photo-placeholder">✝</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -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
@@ -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">✝</div>
|
<div class="novena-hero-cross">✝</div>
|
||||||
|
|||||||
+6
-3
@@ -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">✝</div>
|
<div class="rosary-card-photo-placeholder">✝</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
+10
-2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user