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>
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
<?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 →' : 'Pray →';
|
||||
|
||||
// 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">✝</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']): ?>
|
||||
<?= htmlspecialchars($row['subject_name']) ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?> •
|
||||
<?= 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">✝ <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>✝ 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">🔍</span>
|
||||
<input type="search" id="home-search"
|
||||
class="home-search-input"
|
||||
placeholder="Search by rosary name, honoree, or username…"
|
||||
autocomplete="off"
|
||||
aria-label="Search rosaries">
|
||||
<button class="home-search-clear" id="search-clear" aria-label="Clear search">✕</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>📌 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">✝</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">
|
||||
© <?= date('Y') ?> <?= htmlspecialchars($site_name) ?>
|
||||
<?php if (!$logged_in): ?>
|
||||
• <a href="<?= BASE_URL ?>/register" style="color:inherit">Create Account</a>
|
||||
• <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 = '👤 ' + escHtml(userMap[uname]) + '\'s rosaries →';
|
||||
userResultsBox.appendChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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>
|
||||
Reference in New Issue
Block a user