663fde3909
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>
431 lines
16 KiB
PHP
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 →' : '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>
|