Files
Rosary/index.php
T
pguzman 663fde3909 Initial commit — Rosary Presenter App
Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:44:08 -07:00

431 lines
16 KiB
PHP

<?php
/**
* index.php — Public home page. Shows all public rosary sessions.
* No auth required.
*
* MIGRATION (run once on existing installs):
* ALTER TABLE sessions ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0;
* ALTER TABLE novena_groups ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0;
*/
require_once __DIR__ . '/config/db.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/donate.php';
_auth_start();
$pdo = get_pdo();
$site_name = get_setting('site_name', APP_NAME);
$logged_in = !empty($_SESSION['user_id']);
$username = $_SESSION['username'] ?? '';
$is_admin = $logged_in && has_role('admin');
// ---------------------------------------------------------------------------
// Queries — pinned and regular items are fetched separately
// ---------------------------------------------------------------------------
// Pinned sessions
$pinned_sessions = $pdo->query("
SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug,
s.is_pinned, s.created_at, u.username, u.display_name
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.is_public = 1
AND s.occasion NOT IN ('novena_deceased', 'divine_mercy_novena')
AND s.is_pinned = 1
ORDER BY s.created_at DESC
LIMIT 20
")->fetchAll();
// Pinned novena groups
$pinned_novenas = $pdo->query("
SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug,
ng.is_pinned, ng.created_at, u.username, u.display_name,
COUNT(s.id) AS day_count
FROM novena_groups ng
JOIN users u ON u.id = ng.user_id
LEFT JOIN sessions s ON s.novena_group_id = ng.id
WHERE ng.is_public = 1
AND ng.is_pinned = 1
GROUP BY ng.id
ORDER BY ng.created_at DESC
LIMIT 20
")->fetchAll();
// Regular (unpinned) sessions
$sessions = $pdo->query("
SELECT s.id, s.name, s.occasion, s.mystery_set, s.subject_name, s.photo_path, s.slug,
s.is_pinned, s.created_at, u.username, u.display_name
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.is_public = 1
AND s.occasion NOT IN ('novena_deceased', 'divine_mercy_novena')
AND s.is_pinned = 0
ORDER BY s.created_at DESC
LIMIT 60
")->fetchAll();
// Regular (unpinned) novena groups
$novenas = $pdo->query("
SELECT ng.id, ng.name, ng.mystery_set, ng.subject_name, ng.photo_path, ng.slug,
ng.is_pinned, ng.created_at, u.username, u.display_name,
COUNT(s.id) AS day_count
FROM novena_groups ng
JOIN users u ON u.id = ng.user_id
LEFT JOIN sessions s ON s.novena_group_id = ng.id
WHERE ng.is_public = 1
AND ng.is_pinned = 0
GROUP BY ng.id
ORDER BY ng.created_at DESC
LIMIT 30
")->fetchAll();
// Public users list (for search user-pill links)
$public_users_rows = $pdo->query("
SELECT DISTINCT u.username,
COALESCE(NULLIF(u.display_name,''), u.username) AS display_name
FROM users u
WHERE u.id IN (
SELECT user_id FROM sessions WHERE is_public = 1
UNION
SELECT user_id FROM novena_groups WHERE is_public = 1
)
ORDER BY u.username
LIMIT 300
")->fetchAll(PDO::FETCH_ASSOC);
// Merge and sort each group newest-first
function merge_and_sort(array $sessions, array $novenas): array {
$all = [];
foreach ($sessions as $r) { $r['_type'] = 'session'; $all[] = $r; }
foreach ($novenas as $r) { $r['_type'] = 'novena'; $all[] = $r; }
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
return $all;
}
$pinned = merge_and_sort($pinned_sessions, $pinned_novenas);
$regular = merge_and_sort($sessions, $novenas);
// ---------------------------------------------------------------------------
// Label maps
// ---------------------------------------------------------------------------
$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',
];
$occasion_labels = [
'general_rosary' => 'General Rosary',
'memorial' => 'Memorial',
'novena_deceased' => 'Novena for Deceased',
'divine_mercy_novena'=> 'Divine Mercy Novena',
];
// ---------------------------------------------------------------------------
// Card renderer (shared by pinned + regular sections)
// ---------------------------------------------------------------------------
function render_card(array $row, bool $is_admin, array $mystery_labels, array $occasion_labels): void {
$disp_name = $row['display_name'] ?: $row['username'];
$slug = $row['slug'] ?? '';
$is_novena = ($row['_type'] === 'novena');
$is_pinned = !empty($row['is_pinned']);
$url = $slug
? (BASE_URL . '/' . rawurlencode($row['username']) . '/' . rawurlencode($slug))
: '#';
$link_text = $is_novena ? 'Select a Day &rarr;' : 'Pray &rarr;';
// For the Divine Mercy Novena badge (mystery_set = 'chaplet')
$is_dm = ($is_novena && ($row['mystery_set'] ?? '') === 'chaplet');
$card_class = 'rosary-card' . ($is_pinned ? ' is-pinned' : '');
// Data attributes for client-side search
$d_name = htmlspecialchars($row['name'], ENT_QUOTES);
$d_subject = htmlspecialchars($row['subject_name'] ?? '', ENT_QUOTES);
$d_uname = htmlspecialchars($row['username'], ENT_QUOTES);
$d_display = htmlspecialchars($disp_name, ENT_QUOTES);
$type_str = $is_novena ? 'novena' : 'session';
$row_id = (int)$row['id'];
?>
<div class="<?= $card_class ?>"
data-name="<?= $d_name ?>"
data-subject="<?= $d_subject ?>"
data-username="<?= $d_uname ?>"
data-display="<?= $d_display ?>">
<?php if ($is_admin): ?>
<button class="pin-btn <?= $is_pinned ? 'is-pinned' : '' ?>"
data-type="<?= $type_str ?>"
data-id="<?= $row_id ?>"
title="<?= $is_pinned ? 'Unpin from Featured' : 'Pin to Featured' ?>">
<?= $is_pinned ? '📌' : '📍' ?>
</button>
<?php endif; ?>
<?php if (!empty($row['photo_path'])): ?>
<img class="rosary-card-photo"
src="<?= htmlspecialchars('/' . ltrim($row['photo_path'], '/')) ?>"
alt="">
<?php else: ?>
<div class="rosary-card-photo-placeholder">&#x271D;</div>
<?php endif; ?>
<div class="rosary-card-body">
<div class="rosary-card-title"><?= htmlspecialchars($row['name']) ?></div>
<div class="rosary-card-meta">
<?php if ($is_novena): ?>
<?php if ($is_dm): ?>
<span class="badge-divine-mercy">Divine Mercy</span>
<?php else: ?>
<span class="badge-novena">9-Day Novena</span>
<?php if ($row['subject_name']): ?>
&nbsp;<?= htmlspecialchars($row['subject_name']) ?>
<?php endif; ?>
<?php endif; ?>
<?php else: ?>
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> &bull;
<?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?>
<?php endif; ?>
</div>
<div class="rosary-card-footer">
<span class="rosary-card-by">By <?= htmlspecialchars($disp_name) ?></span>
<a href="<?= htmlspecialchars($url) ?>" class="rosary-card-link"><?= $link_text ?></a>
</div>
</div>
</div>
<?php
}
?>
<!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($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/public.css">
</head>
<body>
<nav class="pub-nav">
<a href="<?= BASE_URL ?>/" class="pub-nav-brand">&#x271D; <span><?= htmlspecialchars($site_name) ?></span></a>
<div class="pub-nav-links">
<?php if ($logged_in): ?>
<a href="<?= BASE_URL ?>/admin/">Dashboard</a>
<a href="<?= BASE_URL ?>/logout">Logout</a>
<?php else: ?>
<a href="<?= BASE_URL ?>/login">Sign In</a>
<a href="<?= BASE_URL ?>/register" class="btn-nav">Get Started</a>
<?php endif; ?>
</div>
</nav>
<div class="pub-hero">
<h1>&#x271D; Pray the Rosary Together</h1>
<p>A shared presentation for families and communities praying the Holy Rosary.</p>
</div>
<!-- Search bar -->
<div class="search-wrap">
<div class="home-search-row">
<span class="home-search-icon">&#128269;</span>
<input type="search" id="home-search"
class="home-search-input"
placeholder="Search by rosary name, honoree, or username&hellip;"
autocomplete="off"
aria-label="Search rosaries">
<button class="home-search-clear" id="search-clear" aria-label="Clear search">&#10005;</button>
</div>
<div class="search-user-results" id="search-user-results" aria-live="polite"></div>
</div>
<!-- ── Featured / Pinned section ── -->
<?php if (!empty($pinned)): ?>
<div class="pinned-section" id="pinned-section">
<div class="pub-section">
<h2>&#128204; Featured</h2>
<div class="card-grid" id="pinned-grid">
<?php foreach ($pinned as $row): render_card($row, $is_admin, $mystery_labels, $occasion_labels); endforeach; ?>
</div>
<p class="search-no-results" id="pinned-no-results">No featured rosaries match your search.</p>
</div>
</div>
<?php endif; ?>
<!-- ── All public rosaries ── -->
<div class="pub-section" id="regular-section">
<h2>Public Rosaries</h2>
<?php if (empty($regular) && empty($pinned)): ?>
<div class="pub-empty">
<span class="cross">&#x271D;</span>
<p>No public rosary sessions yet. <a href="<?= BASE_URL ?>/register">Create an account</a> to get started.</p>
</div>
<?php elseif (empty($regular)): ?>
<p class="search-no-results" style="display:block;padding:0 0 24px;text-align:left;color:var(--muted)">
All rosaries are currently featured above.
</p>
<?php else: ?>
<div class="card-grid" id="regular-grid">
<?php foreach ($regular as $row): render_card($row, $is_admin, $mystery_labels, $occasion_labels); endforeach; ?>
</div>
<p class="search-no-results" id="regular-no-results">No rosaries match your search.</p>
<?php endif; ?>
</div>
<?php render_donate_strip(); ?>
<footer class="pub-footer">
&copy; <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
<?php if (!$logged_in): ?>
&bull; <a href="<?= BASE_URL ?>/register" style="color:inherit">Create Account</a>
&bull; <a href="<?= BASE_URL ?>/login" style="color:inherit">Sign In</a>
<?php endif; ?>
</footer>
<script>
var BASE_URL = <?= json_encode(BASE_URL) ?>;
var IS_ADMIN = <?= $is_admin ? 'true' : 'false' ?>;
var PUBLIC_USERS = <?= json_encode(array_values($public_users_rows), JSON_HEX_TAG | JSON_HEX_AMP) ?>;
(function () {
'use strict';
// ------------------------------------------------------------------
// Search
// ------------------------------------------------------------------
var searchInput = document.getElementById('home-search');
var clearBtn = document.getElementById('search-clear');
var userResultsBox = document.getElementById('search-user-results');
var pinnedSection = document.getElementById('pinned-section');
var pinnedGrid = document.getElementById('pinned-grid');
var regularGrid = document.getElementById('regular-grid');
var pinnedNoRes = document.getElementById('pinned-no-results');
var regularNoRes = document.getElementById('regular-no-results');
// All cards across both grids
var allCards = document.querySelectorAll('.rosary-card[data-name]');
// Build a map of username → display_name from PUBLIC_USERS
// (includes users who may not appear in any visible card, so full search covers them)
var userMap = {};
PUBLIC_USERS.forEach(function (u) { userMap[u.username] = u.display_name; });
var debounceTimer = null;
if (searchInput) {
searchInput.addEventListener('input', function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () { applySearch(searchInput.value.trim()); }, 120);
clearBtn.classList.toggle('visible', searchInput.value.length > 0);
});
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
searchInput.value = '';
clearBtn.classList.remove('visible');
applySearch('');
searchInput.focus();
});
}
function applySearch(raw) {
var q = raw.toLowerCase();
if (!q) {
// Reset: show everything
allCards.forEach(function (c) { c.style.display = ''; });
if (userResultsBox) userResultsBox.innerHTML = '';
toggleSectionVisibility(true, true);
return;
}
var pinnedVisible = 0;
var regularVisible = 0;
allCards.forEach(function (card) {
var inPinned = pinnedGrid && pinnedGrid.contains(card);
var match =
(card.dataset.name || '').toLowerCase().includes(q) ||
(card.dataset.subject || '').toLowerCase().includes(q) ||
(card.dataset.username|| '').toLowerCase().includes(q) ||
(card.dataset.display || '').toLowerCase().includes(q);
card.style.display = match ? '' : 'none';
if (match) {
if (inPinned) pinnedVisible++;
else regularVisible++;
}
});
toggleSectionVisibility(pinnedVisible > 0, regularVisible > 0);
renderUserLinks(q);
}
function toggleSectionVisibility(showPinned, showRegular) {
if (pinnedSection) {
pinnedSection.style.display = showPinned ? '' : 'none';
if (pinnedNoRes) pinnedNoRes.style.display = showPinned ? 'none' : 'block';
}
if (regularNoRes) {
regularNoRes.style.display = (regularGrid && !showRegular) ? 'block' : 'none';
}
}
function renderUserLinks(q) {
if (!userResultsBox) return;
userResultsBox.innerHTML = '';
if (!q) return;
var matches = Object.keys(userMap).filter(function (uname) {
return uname.toLowerCase().includes(q) ||
userMap[uname].toLowerCase().includes(q);
});
matches.forEach(function (uname) {
var a = document.createElement('a');
a.href = BASE_URL + '/' + encodeURIComponent(uname);
a.className = 'search-user-pill';
a.innerHTML = '&#128100; ' + escHtml(userMap[uname]) + '\'s rosaries &rarr;';
userResultsBox.appendChild(a);
});
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ------------------------------------------------------------------
// Pin / Unpin toggle (admin only)
// ------------------------------------------------------------------
if (IS_ADMIN) {
document.querySelectorAll('.pin-btn').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var type = btn.dataset.type;
var id = btn.dataset.id;
btn.disabled = true;
var fd = new FormData();
fd.append('type', type);
fd.append('id', id);
fetch(BASE_URL + '/api/toggle_pin.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function () { window.location.reload(); })
.catch(function () {
btn.disabled = false;
alert('Could not toggle pin. Please try again.');
});
});
});
}
})();
</script>
</body>
</html>