Files
pguzman 3a1433b71e 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>
2026-09-15 15:10:43 -07:00

337 lines
18 KiB
PHP

<?php
/**
* install.php — Full database installer.
* Creates all tables (matching schema.sql — the canonical structure reference)
* and seeds site_settings defaults, the superadmin account, and the standard
* prayer library. Run once in browser, then DELETE this file.
*/
require_once __DIR__ . '/config/db.php';
$pdo = get_pdo();
$log = [];
$errors = [];
function inst_sql(PDO $pdo, string $label, string $sql, array &$log, array &$errors): void {
try {
$pdo->exec($sql);
$log[] = ['ok', $label];
} catch (PDOException $e) {
if (in_array($e->errorInfo[1], [1060, 1061, 1050], true)) {
$log[] = ['skip', $label . ' (already exists, skipped)'];
} else {
$errors[] = $label . ': ' . $e->getMessage();
$log[] = ['err', $label . ': ' . $e->getMessage()];
}
}
}
// ── 1. Tables (kept identical to schema.sql) ───────────────────────────────────
inst_sql($pdo, 'Create sessions table', "
CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
slug VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
occasion VARCHAR(50) NOT NULL,
mystery_set VARCHAR(50) NOT NULL,
novena_day TINYINT NULL,
novena_group_id INT NULL,
subject_name VARCHAR(255) NULL,
subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) NULL,
photo_focal_x FLOAT NOT NULL DEFAULT 50,
photo_focal_y FLOAT NOT NULL DEFAULT 50,
photo_zoom FLOAT NOT NULL DEFAULT 1,
is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
inst_sql($pdo, 'Create novena_groups table', "
CREATE TABLE IF NOT EXISTS novena_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 1,
slug VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
mystery_set VARCHAR(50) NOT NULL DEFAULT 'sorrowful',
subject_name VARCHAR(255) NULL,
subject_pronoun VARCHAR(10) NULL,
subject_dates VARCHAR(150) NULL,
photo_path VARCHAR(500) NULL,
photo_focal_x FLOAT NOT NULL DEFAULT 50,
photo_focal_y FLOAT NOT NULL DEFAULT 50,
photo_zoom FLOAT NOT NULL DEFAULT 1,
is_pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
inst_sql($pdo, 'Create users table', "
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NULL,
role ENUM('superadmin','admin','superuser','user') NOT NULL DEFAULT 'user',
rosary_limit INT NOT NULL DEFAULT 1,
email_confirmed TINYINT(1) NOT NULL DEFAULT 0,
confirm_token VARCHAR(64) NULL,
reset_token VARCHAR(64) NULL,
reset_expires DATETIME NULL,
failed_login_attempts INT NOT NULL DEFAULT 0,
locked_until DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
inst_sql($pdo, 'Create site_settings table', "
CREATE TABLE IF NOT EXISTS site_settings (
key_name VARCHAR(100) PRIMARY KEY,
val TEXT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
inst_sql($pdo, 'Create custom_prayers table', "
CREATE TABLE IF NOT EXISTS custom_prayers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
leader_text TEXT,
all_text TEXT,
default_bead_type ENUM('small','large','crucifix') NULL,
is_global TINYINT(1) NOT NULL DEFAULT 0,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_global (is_global),
INDEX idx_created_by (created_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
inst_sql($pdo, 'Create builder_steps table', "
CREATE TABLE IF NOT EXISTS builder_steps (
id INT AUTO_INCREMENT PRIMARY KEY,
session_id INT NOT NULL,
step_type ENUM('prayer','bead') NOT NULL DEFAULT 'prayer',
bead_type ENUM('small','large','crucifix') NULL,
step_order INT NOT NULL DEFAULT 0,
prayer_id INT NULL,
attribution ENUM('leader_all','leader_only','all_only','none') NOT NULL DEFAULT 'leader_all',
INDEX idx_session (session_id),
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
", $log, $errors);
// ── 2. Seed site_settings ────────────────────────────────────────────────────
$defaults = [
'smtp_host' => '',
'smtp_port' => '587',
'smtp_user' => '',
'smtp_pass' => '',
'smtp_from' => '',
'smtp_from_name' => 'Rosary Presenter',
'site_name' => 'Rosary Presenter',
'site_url' => '',
'recaptcha_enabled' => '0',
'recaptcha_site_key' => '',
'recaptcha_secret_key' => '',
];
$ins_setting = $pdo->prepare('INSERT IGNORE INTO site_settings (key_name, val) VALUES (?, ?)');
foreach ($defaults as $k => $v) {
try {
$ins_setting->execute([$k, $v]);
$log[] = ['ok', "Seeded site_settings: {$k}"];
} catch (PDOException $e) {
$errors[] = "site_settings {$k}: " . $e->getMessage();
}
}
// ── 3. Seed superadmin ───────────────────────────────────────────────────────
$hash = password_hash('supadmin', PASSWORD_BCRYPT);
try {
$pdo->prepare("
INSERT IGNORE INTO users
(username, email, password_hash, display_name, role, rosary_limit, email_confirmed)
VALUES ('supadmin', 'admin@example.com', ?, 'Super Admin', 'superadmin', -1, 1)
")->execute([$hash]);
$log[] = ['ok', 'Seeded superadmin account (username: supadmin)'];
} catch (PDOException $e) {
$errors[] = 'Seed superadmin: ' . $e->getMessage();
}
// ── 4. Seed standard global prayer library ──────────────────────────────────
$existing_prayers = 0;
try {
$existing_prayers = (int)$pdo->query("SELECT COUNT(*) FROM custom_prayers WHERE is_global=1")->fetchColumn();
} catch (PDOException $e) {
$errors[] = 'Check existing prayers: ' . $e->getMessage();
}
if ($existing_prayers > 0) {
$log[] = ['skip', "Standard prayers already seeded ({$existing_prayers} found)"];
} else {
$sa = $pdo->query("SELECT id FROM users WHERE role='superadmin' LIMIT 1")->fetch();
$creator_id = $sa ? (int)$sa['id'] : 1;
$prayers = [
['name' => 'Sign of the Cross', 'bead' => 'crucifix',
'leader' => "In the name of the Father,\nand of the Son,\nand of the Holy Spirit.",
'all' => 'Amen.'],
['name' => 'Apostles\' Creed', 'bead' => null,
'leader' => "I believe in God, the Father Almighty,\nCreator of Heaven and earth;\nand in Jesus Christ, His only Son, Our Lord,\nWho was conceived by the Holy Spirit,\nborn of the Virgin Mary,\nsuffered under Pontius Pilate,\nwas crucified, died, and was buried.\nHe descended into Hell;\nthe third day He rose again from the dead;\nHe ascended into Heaven,\nand sitteth at the right hand of God, the Father Almighty;\nfrom thence He shall come to judge the living and the dead.",
'all' => "I believe in the Holy Spirit,\nthe Holy Catholic Church,\nthe communion of saints,\nthe forgiveness of sins,\nthe resurrection of the body\nand life everlasting. Amen."],
['name' => 'Our Father', 'bead' => 'large',
'leader' => "Our Father, Who art in Heaven,\nhallowed be Thy name;\nThy kingdom come,\nThy will be done on earth as it is in Heaven.",
'all' => "Give us this day our daily bread,\nand forgive us our trespasses,\nas we forgive those who trespass against us;\nand lead us not into temptation,\nbut deliver us from evil. Amen."],
['name' => 'Hail Mary', 'bead' => 'small',
'leader' => "Hail Mary, full of grace, the Lord is with thee;\nblessed art thou amongst women,\nand blessed is the fruit of thy womb, Jesus.",
'all' => "Holy Mary, Mother of God,\npray for us sinners,\nnow and at the hour of our death. Amen."],
['name' => 'Glory Be', 'bead' => null,
'leader' => "Glory be to the Father, and to the Son,\nand to the Holy Spirit,",
'all' => "as it was in the beginning, is now,\nand ever shall be, world without end. Amen."],
['name' => 'Fatima Prayer', 'bead' => null,
'leader' => "O my Jesus, forgive us our sins,\nsave us from the fires of hell,",
'all' => "lead all souls to Heaven,\nespecially those who are in most need of Thy mercy."],
['name' => 'Hail Holy Queen', 'bead' => null,
'leader' => "Hail, Holy Queen, Mother of Mercy,\nour life, our sweetness and our hope.\nTo thee do we cry,\npoor banished children of Eve.\nTo thee do we send up our sighs,\nmourning and weeping in this valley of tears.\nTurn then, most gracious advocate,\nthine eyes of mercy toward us,\nand after this our exile\nshow unto us the blessed fruit of thy womb, Jesus.\nO clement, O loving,\nO sweet Virgin Mary.",
'all' => "Pray for us, O holy Mother of God,\nthat we may be made worthy of the promises of Christ."],
['name' => 'Eternal Rest', 'bead' => null,
'leader' => "Eternal rest grant unto {pronoun_obj}, O Lord,",
'all' => "and let perpetual light shine upon {pronoun_obj}.\nMay {pronoun_poss} soul and the souls of all the faithful departed,\nthrough the mercy of God, rest in peace. Amen."],
['name' => 'The Memorare', 'bead' => null,
'leader' => "Remember, O most gracious Virgin Mary,\nthat never was it known\nthat anyone who fled to thy protection,\nimplored thy help, or sought thy intercession,\nwas left unaided.\nInspired by this confidence,\nI fly unto thee, O Virgin of virgins, my mother;\nto thee do I come,\nbefore thee I stand, sinful and sorrowful.\nO Mother of the Word Incarnate,\ndespise not my petitions,\nbut in thy mercy hear and answer me.",
'all' => 'Amen.'],
['name' => 'Act of Contrition', 'bead' => null,
'leader' => "O my God, I am heartily sorry for having offended Thee,\nand I detest all my sins\nbecause of thy just punishments,\nbut most of all because they offend Thee, my God,\nwho art all good and deserving of all my love.\nI firmly resolve, with the help of Thy grace,\nto sin no more and to avoid the near occasions of sin.",
'all' => 'Amen.'],
['name' => 'O Blood and Water', 'bead' => null,
'leader' => "O Blood and Water,\nwhich gushed forth from the Heart of Jesus\nas a fount of mercy for us,",
'all' => 'I trust in You.'],
['name' => 'Eternal Father (Divine Mercy)', 'bead' => 'large',
'leader' => "Eternal Father, I offer You the Body and Blood,\nSoul and Divinity of Your dearly beloved Son,\nOur Lord Jesus Christ,",
'all' => "in atonement for our sins and those of the whole world."],
['name' => 'For the Sake of His Sorrowful Passion', 'bead' => 'small',
'leader' => "For the sake of His sorrowful Passion,",
'all' => "have mercy on us and on the whole world."],
['name' => 'Holy God (Divine Mercy Closing)', 'bead' => null,
'leader' => "Holy God, Holy Mighty One, Holy Immortal One,",
'all' => "have mercy on us and on the whole world."],
['name' => 'Prayer to St. Michael the Archangel', 'bead' => null,
'leader' => "Saint Michael the Archangel,\ndefend us in battle.\nBe our defense against the wickedness and snares of the Devil.\nMay God rebuke him, we humbly pray,\nand do thou, O Prince of the heavenly hosts,\nby the power of God, thrust into hell Satan,\nand all the evil spirits,\nwho prowl about the world seeking the ruin of souls.",
'all' => 'Amen.'],
['name' => 'Rosary Closing Prayer', 'bead' => null,
'leader' => "Let us pray.\n\nO God, whose only-begotten Son,\nby His life, death, and resurrection,\nhas purchased for us the rewards of eternal life,\ngrant, we beseech Thee,\nthat meditating upon these mysteries\nof the Most Holy Rosary of the Blessed Virgin Mary,\nwe may imitate what they contain\nand obtain what they promise,\nthrough the same Christ Our Lord.",
'all' => 'Amen.'],
];
try {
$stmt = $pdo->prepare(
"INSERT INTO custom_prayers (name, leader_text, all_text, default_bead_type, is_global, created_by)
VALUES (?, ?, ?, ?, 1, ?)"
);
foreach ($prayers as $p) {
$stmt->execute([$p['name'], $p['leader'], $p['all'], $p['bead'], $creator_id]);
}
$log[] = ['ok', 'Seeded ' . count($prayers) . ' standard global prayers'];
} catch (PDOException $e) {
$errors[] = 'Seed prayer library: ' . $e->getMessage();
}
}
$overall_ok = empty($errors);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Install — <?= APP_NAME ?></title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:#f4f4f5;margin:0;padding:32px 16px}
.wrap{max-width:720px;margin:0 auto}
h1{font-size:26px;margin-bottom:4px}
.banner{border-radius:8px;padding:20px 24px;margin-bottom:24px;font-size:15px}
.banner.ok{background:#d1fae5;border:1px solid #6ee7b7;color:#065f46}
.banner.err{background:#fee2e2;border:1px solid #fca5a5;color:#991b1b}
.warn{background:#fef3c7;border:1px solid #fcd34d;color:#92400e;border-radius:8px;padding:16px 20px;margin-bottom:24px;font-weight:600}
.card{background:#fff;border-radius:8px;padding:24px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.07)}
.cred{background:#1e3a5f;color:#e0f2fe;border-radius:6px;padding:16px 20px;font-family:monospace;font-size:15px;line-height:1.9}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{text-align:left;padding:6px 10px;border-bottom:1px solid #e5e7eb}
th{background:#f9fafb;font-weight:600}
.ok{color:#15803d}.err{color:#b91c1c}.skip{color:#d97706}
</style>
</head>
<body>
<div class="wrap">
<h1>&#x271D; <?= APP_NAME ?> — Installer</h1>
<?php if ($overall_ok): ?>
<div class="banner ok">
<strong>Installation complete!</strong> All tables created and seeded successfully.
</div>
<?php else: ?>
<div class="banner err">
<strong>Installation finished with errors.</strong>
Review the log below. Check your credentials in <code>config/db.php</code> and try again.
</div>
<?php endif; ?>
<div class="warn">
&#9888; DELETE <code>install.php</code> from your server immediately after reviewing this page.
</div>
<div class="card">
<h2 style="margin-top:0">Superadmin Credentials</h2>
<div class="cred">
Username: supadmin<br>
Password: supadmin<br>
Role:&nbsp;&nbsp;&nbsp;&nbsp;superadmin (unlimited rosaries)
</div>
<p style="color:#b91c1c;font-weight:600;margin-top:12px">
Change the password immediately — go to
<a href="<?= BASE_URL ?>/admin/profile">/admin/profile</a> after logging in.<br>
Also update the email from <code>admin@example.com</code> to your real address.
</p>
</div>
<div class="card">
<h2 style="margin-top:0">Installation Log</h2>
<table>
<thead><tr><th>Status</th><th>Step</th></tr></thead>
<tbody>
<?php foreach ($log as [$status, $msg]): ?>
<tr>
<td class="<?= $status ?>">
<?= $status === 'ok' ? '&#x2713; OK' : ($status === 'skip' ? '&#8212; SKIP' : '&#x2717; ERROR') ?>
</td>
<td><?= htmlspecialchars($msg) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php if ($overall_ok): ?>
<div class="card">
<h2 style="margin-top:0">Next Steps</h2>
<ol style="line-height:2">
<li>Delete <code>install.php</code> from your server.</li>
<li>Go to <a href="<?= BASE_URL ?>/login">/login</a> — sign in with <strong>supadmin / supadmin</strong>.</li>
<li>Go to <a href="<?= BASE_URL ?>/admin/profile">/admin/profile</a> — change your password and email.</li>
<li>Go to <a href="<?= BASE_URL ?>/admin/settings">/admin/settings</a> — set your site URL and configure SMTP.</li>
</ol>
</div>
<?php endif; ?>
</div>
</body>
</html>