3a1433b71e
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>
395 lines
18 KiB
PHP
395 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* admin/novena_group.php — View and manage one novena group.
|
||
*/
|
||
require_once __DIR__ . '/../config/db.php';
|
||
require_once __DIR__ . '/../includes/auth.php';
|
||
require_once __DIR__ . '/../includes/csrf.php';
|
||
require_once __DIR__ . '/../includes/photo.php';
|
||
|
||
require_auth();
|
||
|
||
$pdo = get_pdo();
|
||
$user = current_user();
|
||
$uid = (int)$user['id'];
|
||
$gid = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||
$site_name = get_setting('site_name', APP_NAME);
|
||
|
||
if ($gid < 1) {
|
||
header('Location: ' . BASE_URL . '/admin/');
|
||
exit;
|
||
}
|
||
|
||
// Load group and verify ownership
|
||
$group = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
|
||
$group->execute([$gid]);
|
||
$group = $group->fetch();
|
||
|
||
if (!$group) {
|
||
header('Location: ' . BASE_URL . '/admin/');
|
||
exit;
|
||
}
|
||
|
||
// Ownership check
|
||
if (!has_role('admin') && (int)$group['user_id'] !== $uid) {
|
||
header('Location: ' . BASE_URL . '/admin/');
|
||
exit;
|
||
}
|
||
|
||
$is_dm = ($group['mystery_set'] === 'chaplet');
|
||
|
||
// Handle delete of a single day session
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) {
|
||
csrf_verify();
|
||
$did = (int)$_POST['delete_day_id'];
|
||
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]);
|
||
|
||
$remaining = (int)$pdo->query("SELECT COUNT(*) FROM sessions WHERE novena_group_id = {$gid}")->fetchColumn();
|
||
if ($remaining < 1) {
|
||
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
|
||
header('Location: ' . BASE_URL . '/admin/');
|
||
exit;
|
||
}
|
||
header('Location: ' . BASE_URL . '/admin/novena_group.php?id=' . $gid . '&deleted=1');
|
||
exit;
|
||
}
|
||
|
||
// Handle group-level metadata edit
|
||
$save_error = '';
|
||
$save_success = false;
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
|
||
csrf_verify();
|
||
$g_name = trim($_POST['g_name'] ?? '');
|
||
$g_photo = trim($_POST['g_photo'] ?? '') ?: null;
|
||
$g_focal_x = max(0, min(100, (float)($_POST['g_photo_focal_x'] ?? 50)));
|
||
$g_focal_y = max(0, min(100, (float)($_POST['g_photo_focal_y'] ?? 50)));
|
||
$g_zoom = max(1, min(3, (float)($_POST['g_photo_zoom'] ?? 1)));
|
||
$g_public = isset($_POST['is_public']) ? 1 : 0;
|
||
|
||
if ($is_dm) {
|
||
// Divine Mercy: keep mystery_set as 'chaplet', no subject fields
|
||
$g_mystery = 'chaplet';
|
||
$g_subject = null;
|
||
$g_pronoun = null;
|
||
$g_dates = null;
|
||
} else {
|
||
$g_mystery = trim($_POST['g_mystery'] ?? '');
|
||
$g_subject = trim($_POST['g_subject'] ?? '') ?: null;
|
||
$g_pronoun = trim($_POST['g_pronoun'] ?? '') ?: null;
|
||
$g_dates = trim($_POST['g_dates'] ?? '') ?: null;
|
||
}
|
||
|
||
$valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet'];
|
||
|
||
if ($g_name === '') {
|
||
$save_error = 'Name is required.';
|
||
} elseif (!in_array($g_mystery, $valid_mysteries, true)) {
|
||
$save_error = 'Invalid mystery set.';
|
||
} else {
|
||
$pdo->prepare('
|
||
UPDATE novena_groups
|
||
SET name = ?, mystery_set = ?, subject_name = ?,
|
||
subject_pronoun = ?, subject_dates = ?,
|
||
photo_path = COALESCE(?, photo_path),
|
||
photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?,
|
||
is_public = ?
|
||
WHERE id = ?
|
||
')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_focal_x, $g_focal_y, $g_zoom, $g_public, $gid]);
|
||
|
||
$pdo->prepare('
|
||
UPDATE sessions
|
||
SET mystery_set = ?, subject_name = ?,
|
||
subject_pronoun = ?, subject_dates = ?,
|
||
photo_path = COALESCE(?, photo_path),
|
||
photo_focal_x = ?, photo_focal_y = ?, photo_zoom = ?,
|
||
name = CONCAT(?, CONCAT(\' — Day \', novena_day)),
|
||
is_public = ?
|
||
WHERE novena_group_id = ?
|
||
')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_focal_x, $g_focal_y, $g_zoom, $g_name, $g_public, $gid]);
|
||
|
||
$save_success = true;
|
||
// Reload group
|
||
$grp_stmt = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
|
||
$grp_stmt->execute([$gid]);
|
||
$group = $grp_stmt->fetch();
|
||
}
|
||
}
|
||
|
||
$days = $pdo->prepare('SELECT * FROM sessions WHERE novena_group_id = ? ORDER BY novena_day');
|
||
$days->execute([$gid]);
|
||
$days = $days->fetchAll();
|
||
|
||
$mystery_labels = [
|
||
'sorrowful' => 'Sorrowful Mysteries',
|
||
'joyful' => 'Joyful Mysteries',
|
||
'glorious' => 'Glorious Mysteries',
|
||
'luminous' => 'Luminous Mysteries',
|
||
'by_day_of_week' => 'By Day of Week',
|
||
'chaplet' => 'Chaplet of Divine Mercy',
|
||
];
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
|
||
<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/photo-crop.css">
|
||
<meta name="csrf-token" content="<?= htmlspecialchars(csrf_token()) ?>">
|
||
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
|
||
</head>
|
||
<body>
|
||
<div class="admin-container">
|
||
|
||
<header class="admin-header">
|
||
<h1>✝ <?= htmlspecialchars($site_name) ?></h1>
|
||
<div class="header-actions">
|
||
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">← View Site</a>
|
||
<?php if (has_role('superuser')): ?>
|
||
<a href="<?= BASE_URL ?>/admin/builder.php" class="btn btn-ghost">✒ Builder</a>
|
||
<?php endif; ?>
|
||
<?php if (has_role('admin')): ?>
|
||
<a href="<?= BASE_URL ?>/admin/prayers.php" class="btn btn-ghost">Prayers</a>
|
||
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
|
||
<?php endif; ?>
|
||
<?php if (has_role('superadmin')): ?>
|
||
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
|
||
<?php endif; ?>
|
||
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
|
||
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">← All Sessions</a>
|
||
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<nav class="breadcrumb">
|
||
<a href="<?= BASE_URL ?>/admin/">Sessions</a>
|
||
<span class="bc-sep">/</span>
|
||
<span><?= htmlspecialchars($group['name']) ?></span>
|
||
</nav>
|
||
|
||
<?php if (isset($_GET['deleted'])): ?>
|
||
<div class="alert alert-success">Day deleted successfully.</div>
|
||
<?php endif; ?>
|
||
<?php if ($save_success): ?>
|
||
<div class="alert alert-success">✓ Novena updated. Changes applied to all 9 day sessions.</div>
|
||
<?php elseif ($save_error): ?>
|
||
<div class="alert alert-error"><?= htmlspecialchars($save_error) ?></div>
|
||
<?php endif; ?>
|
||
|
||
<section class="card" style="margin-bottom:32px">
|
||
<h2 class="card-title">Novena Details</h2>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="save_group" value="1">
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label class="form-label" for="g_name">Novena Name</label>
|
||
<input type="text" id="g_name" name="g_name" class="form-input"
|
||
value="<?= htmlspecialchars($group['name']) ?>" required>
|
||
<p class="form-help">Used as the base name for all 9 days.</p>
|
||
</div>
|
||
|
||
<?php if (!$is_dm): ?>
|
||
<div class="form-group">
|
||
<label class="form-label" for="g_mystery">Mysteries</label>
|
||
<select id="g_mystery" name="g_mystery" class="form-input">
|
||
<?php foreach ([
|
||
'sorrowful' => 'All Sorrowful Mysteries',
|
||
'by_day_of_week' => 'By Day of Week',
|
||
'joyful' => 'All Joyful Mysteries',
|
||
'glorious' => 'All Glorious Mysteries',
|
||
'luminous' => 'All Luminous Mysteries',
|
||
] as $val => $label): ?>
|
||
<option value="<?= $val ?>" <?= $group['mystery_set'] === $val ? 'selected' : '' ?>>
|
||
<?= $label ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="g_subject">Name of Deceased</label>
|
||
<input type="text" id="g_subject" name="g_subject" class="form-input"
|
||
value="<?= htmlspecialchars($group['subject_name'] ?? '') ?>">
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label">Pronoun</label>
|
||
<div class="radio-row">
|
||
<label><input type="radio" name="g_pronoun" value="he"
|
||
<?= ($group['subject_pronoun'] ?? 'he') === 'he' ? 'checked' : '' ?>> He / Him</label>
|
||
<label><input type="radio" name="g_pronoun" value="she"
|
||
<?= ($group['subject_pronoun'] ?? '') === 'she' ? 'checked' : '' ?>> She / Her</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label class="form-label" for="g_dates">Life Dates</label>
|
||
<input type="text" id="g_dates" name="g_dates" class="form-input"
|
||
placeholder="e.g., March 15, 1942 – June 3, 2025"
|
||
value="<?= htmlspecialchars($group['subject_dates'] ?? '') ?>">
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="form-group" id="photo-group">
|
||
<label class="form-label">Photo</label>
|
||
<?php if ($group['photo_path']): ?>
|
||
<div class="photo-preview-wrap">
|
||
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>"
|
||
alt="Current photo" class="photo-preview" style="<?= htmlspecialchars(photo_crop_style($group)) ?>">
|
||
</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="g_photo" name="g_photo"
|
||
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">
|
||
<?= $group['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="margin-top:8px;<?= $group['photo_path'] ? '' : 'display:none' ?>">
|
||
Reposition
|
||
</button>
|
||
<span id="photo-status" class="form-help"></span>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
|
||
<input type="checkbox" name="is_public" value="1"
|
||
<?= $group['is_public'] ? 'checked' : '' ?>
|
||
style="width:18px;height:18px">
|
||
<span>Public (visible on your profile)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary">Save Changes to All Days</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<section>
|
||
<h2 style="margin-bottom:16px">Days (<?= count($days) ?> of 9)</h2>
|
||
<div class="sessions-table-wrap">
|
||
<table class="sessions-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Day</th>
|
||
<th>Mysteries</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php for ($d = 1; $d <= 9; $d++):
|
||
$ses = null;
|
||
foreach ($days as $day_row) {
|
||
if ((int)$day_row['novena_day'] === $d) { $ses = $day_row; break; }
|
||
}
|
||
?>
|
||
<tr <?= !$ses ? 'class="day-missing"' : '' ?>>
|
||
<td><strong>Day <?= $d ?></strong></td>
|
||
<td><?= $ses ? htmlspecialchars($mystery_labels[$ses['mystery_set']] ?? $ses['mystery_set']) : '<em>missing</em>' ?></td>
|
||
<td class="actions">
|
||
<?php if ($ses): ?>
|
||
<a href="<?= BASE_URL ?>/present.php?id=<?= $ses['id'] ?>"
|
||
target="_blank"
|
||
class="btn btn-sm btn-primary">Present</a>
|
||
<form method="post" style="display:inline"
|
||
onsubmit="return confirm('Delete Day <?= $d ?>?')">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>">
|
||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||
</form>
|
||
<?php else: ?>
|
||
<span class="text-muted">—</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
</tr>
|
||
<?php endfor; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
|
||
<script src="<?= BASE_URL ?>/assets/js/photo-crop.js"></script>
|
||
<script>
|
||
(function () {
|
||
var fileInput = document.getElementById('photo-file');
|
||
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 previewWrap = document.querySelector('.photo-preview-wrap');
|
||
var previewImg = document.getElementById('photo-preview');
|
||
var repositionBtn = document.getElementById('photo-reposition');
|
||
|
||
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 () {
|
||
var file = fileInput.files[0];
|
||
if (!file) return;
|
||
var fd = new FormData();
|
||
fd.append('photo', file);
|
||
fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]').content);
|
||
photoStatus.textContent = 'Uploading\u2026';
|
||
|
||
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.
|
||
focalXHidden.value = 50;
|
||
focalYHidden.value = 50;
|
||
zoomHidden.value = 1;
|
||
previewImg.src = '/' + data.path.replace(/^\//, '');
|
||
applyCropStyle();
|
||
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.'; });
|
||
});
|
||
|
||
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>
|
||
</body>
|
||
</html>
|