Compare commits
2 Commits
1176b98522
...
eb16827fdf
| Author | SHA1 | Date | |
|---|---|---|---|
| eb16827fdf | |||
| 751b6996c4 |
@@ -94,6 +94,13 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
<li>Ort: <?= htmlspecialchars($profile['city'], ENT_QUOTES) ?> <?= htmlspecialchars($profile['zip'], ENT_QUOTES) ?></li>
|
||||
<li>E-Mail: <?= htmlspecialchars($profile['email'], ENT_QUOTES) ?></li>
|
||||
<li>Telefon: <?= htmlspecialchars($profile['contact_phone'], ENT_QUOTES) ?></li>
|
||||
<li>Standortfreigabe: <?=
|
||||
htmlspecialchars(match ($profile['location_tracking_preference'] ?? 'prompt') {
|
||||
'enabled' => 'Immer verwenden',
|
||||
'disabled' => 'Deaktiviert',
|
||||
default => 'Beim nächsten Mal fragen',
|
||||
}, ENT_QUOTES)
|
||||
?></li>
|
||||
<li>Beruf: <?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?></li>
|
||||
<li>Sprachen: <?= htmlspecialchars($profile['languages'], ENT_QUOTES) ?></li>
|
||||
<li>About: <?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></li>
|
||||
@@ -256,6 +263,15 @@ $allowNoKidsChecked = $editEvent ? ((int)$editEvent['allow_kids'] === 0) : false
|
||||
<label class="label" for="pProf">Beruf</label>
|
||||
<input id="pProf" name="profession" class="input" value="<?= htmlspecialchars($profile['profession'], ENT_QUOTES) ?>">
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="pLocationPref">Standortfreigabe</label>
|
||||
<select id="pLocationPref" name="location_tracking_preference" class="select">
|
||||
<option value="disabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'disabled') ? 'selected' : '' ?>>Deaktiviert</option>
|
||||
<option value="prompt" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'prompt') ? 'selected' : '' ?>>Beim nächsten Mal fragen</option>
|
||||
<option value="enabled" <?= (($profile['location_tracking_preference'] ?? 'prompt') === 'enabled') ? 'selected' : '' ?>>Immer verwenden</option>
|
||||
</select>
|
||||
<p class="muted small">Bei aktiver Freigabe wird dein aktueller Standort für passende Treffen in deiner Nähe verwendet. Du kannst die Abfrage hier jederzeit wieder auf Nachfrage oder komplett aus stellen.</p>
|
||||
</div>
|
||||
<div class="stack gap-6">
|
||||
<label class="label" for="pAbout">Kurzvorstellung</label>
|
||||
<textarea id="pAbout" name="about" class="textarea" rows="3"><?= htmlspecialchars($profile['about'], ENT_QUOTES) ?></textarea>
|
||||
|
||||
@@ -7,7 +7,14 @@ $threadsForJs = [];
|
||||
try {
|
||||
$pdo = $app->pdo();
|
||||
if ($pdo) {
|
||||
$stmt = $pdo->prepare('SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at FROM events WHERE starts_at >= NOW() AND status != "cancelled" ORDER BY created_at DESC, starts_at ASC LIMIT 10');
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, title, teaser_public, description, city, region, zip, starts_at, allow_kids, visibility, location_label, lat, lng, created_at
|
||||
FROM events
|
||||
WHERE starts_at >= DATE_SUB(NOW(), INTERVAL 120 DAY)
|
||||
AND status != "cancelled"
|
||||
ORDER BY starts_at ASC
|
||||
LIMIT 60
|
||||
');
|
||||
$stmt->execute();
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
foreach ($rows as $r) {
|
||||
@@ -81,7 +88,7 @@ try {
|
||||
<section class="section alt" id="quicksearch">
|
||||
<div class="container">
|
||||
<div class="section__intro">
|
||||
<h2>Starte direkt mit der Suche nach passenden Treffen.</h2>
|
||||
<h2>Suche nach Treffen in deiner Nähe</h2>
|
||||
</div>
|
||||
<form id="quickSearchForm" class="grid grid-3 quicksearch-form" style="gap: 12px; align-items:flex-end;" action="/search" method="get">
|
||||
<div class="stack gap-6">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<nav class="site-footer__links" aria-label="Footer">
|
||||
<a href="/impressum">Impressum</a>
|
||||
<a href="/ueber-uns">Über uns</a>
|
||||
<button class="site-footer__link-button" type="button" data-consent-open>Cookie-Einstellungen</button>
|
||||
</nav>
|
||||
<p class="site-footer__copy">© Copyright <?= date('Y') ?> - Lars Gebhardt-Kusche</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,18 @@ if (!in_array($childGender, ['male', 'female', 'mixed'], true)) {
|
||||
}
|
||||
|
||||
$debugEnabled = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
$locationTrackingPreference = 'prompt';
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
try {
|
||||
$pdo = $app->pdo();
|
||||
if ($pdo) {
|
||||
$profileSettings = new \App\ProfileSettings($pdo);
|
||||
$locationTrackingPreference = $profileSettings->getLocationTrackingPreference((int)$_SESSION['user_id']);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$locationTrackingPreference = 'prompt';
|
||||
}
|
||||
}
|
||||
$debugFiles = [];
|
||||
if ($debugEnabled) {
|
||||
$debugDir = __DIR__ . '/../../debug';
|
||||
@@ -57,9 +69,94 @@ if ($debugEnabled) {
|
||||
<?php asset_styles(); ?>
|
||||
<?php asset_scripts('header'); ?>
|
||||
</head>
|
||||
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>">
|
||||
<body data-auth="<?= isset($_SESSION['user_id']) ? '1' : '0' ?>" data-child-gender="<?= htmlspecialchars($childGender, ENT_QUOTES) ?>" data-location-preference="<?= htmlspecialchars($locationTrackingPreference, ENT_QUOTES) ?>">
|
||||
<script>
|
||||
(function () {
|
||||
const storageKey = 'pkt_cookie_consent';
|
||||
const read = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
window.PKTConsent = window.PKTConsent || {
|
||||
storageKey,
|
||||
get() {
|
||||
return read();
|
||||
},
|
||||
has(category) {
|
||||
if (category === 'necessary') return true;
|
||||
const consent = read();
|
||||
return Boolean(consent && consent[category] === true);
|
||||
},
|
||||
openPreferences() {
|
||||
document.dispatchEvent(new CustomEvent('pkt:open-consent'));
|
||||
},
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<?php tpl('matomo', 'structure'); ?>
|
||||
<?php tpl('nav', 'structure'); ?>
|
||||
<div class="cookie-consent" id="cookieConsentBanner" hidden>
|
||||
<div class="cookie-consent__inner">
|
||||
<div class="cookie-consent__copy">
|
||||
<strong>Cookies, Analyse und externe Dienste</strong>
|
||||
<p>Notwendige Cookies für Sitzung und Login sind immer aktiv. Analyse mit Matomo sowie Karten-, Standort- und Geocoding-Funktionen werden erst nach deiner Auswahl geladen.</p>
|
||||
</div>
|
||||
<div class="cookie-consent__actions">
|
||||
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
|
||||
<button class="btn ghost" type="button" data-consent-open>Auswahl</button>
|
||||
<button class="btn" type="button" data-consent-accept-all>Alle akzeptieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" id="cookieConsentModal">
|
||||
<div class="panel">
|
||||
<div class="head flex between center-y">
|
||||
<h3 style="margin:0;">Cookie- und Diensteinstellungen</h3>
|
||||
<button class="btn ghost" type="button" data-consent-close>✕</button>
|
||||
</div>
|
||||
<div class="stack gap-12" style="margin-top:12px;">
|
||||
<p class="muted" style="margin:0;">Du kannst nicht notwendige Funktionen freiwillig aktivieren oder später wieder widerrufen.</p>
|
||||
<div class="card">
|
||||
<strong>Notwendig</strong>
|
||||
<p class="muted small">Sitzung, Login und Sicherheitsfunktionen. Immer aktiv.</p>
|
||||
</div>
|
||||
<label class="card" style="display:block; cursor:pointer;">
|
||||
<input type="checkbox" id="consentAnalytics" style="margin-right:8px;">
|
||||
<strong>Analyse mit Matomo</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Reichweitenmessung und Nutzungsanalyse.</p>
|
||||
</label>
|
||||
<label class="card" style="display:block; cursor:pointer;">
|
||||
<input type="checkbox" id="consentExternalServices" style="margin-right:8px;">
|
||||
<strong>Externe Dienste, Karten und Standort</strong>
|
||||
<p class="muted small" style="margin:8px 0 0;">Browser-Geolocation, lokale Standortspeicherung, Leaflet von `unpkg.com` und Geocoding/Reverse-Geocoding über OpenStreetMap Nominatim.</p>
|
||||
</label>
|
||||
<div class="flex gap-12" style="flex-wrap:wrap;">
|
||||
<button class="btn ghost" type="button" data-consent-necessary>Nur notwendige</button>
|
||||
<button class="btn" type="button" data-consent-save>Auswahl speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal" id="locationPreferenceModal">
|
||||
<div class="panel">
|
||||
<div class="head flex between center-y">
|
||||
<h3 style="margin:0;">Standortfreigabe</h3>
|
||||
<button class="btn ghost" type="button" data-location-pref-close>✕</button>
|
||||
</div>
|
||||
<div class="stack gap-12" style="margin-top:12px;">
|
||||
<p class="muted" style="margin:0;">Damit wir dir passende Treffen in deiner Nähe zeigen können, dürfen wir deinen Standort verwenden. Wie soll das gehandhabt werden?</p>
|
||||
<div class="stack gap-10">
|
||||
<button class="btn" type="button" data-location-pref-choice="session">Nur für diesen Besuch</button>
|
||||
<button class="btn ghost" type="button" data-location-pref-choice="enabled">Immer erlauben</button>
|
||||
<button class="btn ghost" type="button" data-location-pref-choice="disabled">Niemals</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($debugEnabled): ?>
|
||||
<style>
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
<?php if (!defined('MATOMO_SITE_ID')) return; ?>
|
||||
<?php if (!defined('MATOMO_ENABLED') || !MATOMO_ENABLED) return; ?>
|
||||
<?php
|
||||
$primaryDomain = app_primary_domain();
|
||||
$primaryDomain = app_primary_domain();
|
||||
$fakecheckDomain = app_fakecheck_domain();
|
||||
|
||||
// Build allowed domains for tracking (hosts only, no paths)
|
||||
$matomoDomains = array_values(array_unique(array_filter([
|
||||
$primaryDomain ? '*.' . $primaryDomain : null,
|
||||
($fakecheckDomain && $fakecheckDomain !== $primaryDomain) ? '*.' . $fakecheckDomain : null,
|
||||
])));
|
||||
?>
|
||||
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
_paq.push(["setDomains", <?= json_encode($matomoDomains, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>]);
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
|
||||
(function() {
|
||||
var u = "<?= rtrim(MATOMO_URL, '/') ?>/";
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '<?= MATOMO_SITE_ID ?>']);
|
||||
|
||||
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||
g.async=true;
|
||||
g.src=u + 'matomo.js';
|
||||
s.parentNode.insertBefore(g,s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<noscript>
|
||||
<p>
|
||||
<img referrerpolicy="no-referrer-when-downgrade"
|
||||
src="<?= rtrim(MATOMO_URL,'/') ?>/matomo.php?idsite=<?= MATOMO_SITE_ID ?>&rec=1"
|
||||
style="border:0;" alt="" />
|
||||
</p>
|
||||
</noscript>
|
||||
<!-- End Matomo -->
|
||||
<script type="application/json" id="matomoConfig"><?= json_encode([
|
||||
'url' => rtrim(MATOMO_URL, '/') . '/',
|
||||
'siteId' => (string) MATOMO_SITE_ID,
|
||||
'domains' => $matomoDomains,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
|
||||
|
||||
@@ -15,7 +15,7 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
<nav class="nav-links" aria-label="Hauptmenü">
|
||||
<a href="/">Home</a>
|
||||
<a href="/search">Suche</a>
|
||||
<a href="/#events">Termine</a>
|
||||
<a href="/#events" data-nav-events>Termine</a>
|
||||
<a href="/community">Community</a>
|
||||
</nav>
|
||||
<div class="nav-actions">
|
||||
@@ -35,7 +35,7 @@ $isDebug = defined('APP_DEBUG') && APP_DEBUG === true;
|
||||
<div class="mobile-menu" id="mobileMenu">
|
||||
<a href="/">Home</a>
|
||||
<a href="/search">Suche</a>
|
||||
<a href="/#events">Termine</a>
|
||||
<a href="/#events" data-nav-events>Termine</a>
|
||||
<a href="/community">Community</a>
|
||||
<?php if ($isLoggedIn): ?>
|
||||
<a class="btn ghost" href="/dashboard">Dashboard</a>
|
||||
|
||||
@@ -137,7 +137,14 @@ body {
|
||||
.site-footer__inner { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px 30px; }
|
||||
.site-footer__links { display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; font-weight: 600; }
|
||||
.site-footer__links a:hover { color: var(--color-primary); }
|
||||
.site-footer__link-button { border: 0; background: transparent; padding: 0; font: inherit; color: var(--color-text); cursor: pointer; }
|
||||
.site-footer__link-button:hover { color: var(--color-primary); }
|
||||
.site-footer__copy { margin: 0; color: var(--color-muted); font-size: 14px; text-align: center; }
|
||||
.cookie-consent { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 220; }
|
||||
.cookie-consent[hidden] { display: none; }
|
||||
.cookie-consent__inner { max-width: 1120px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px 20px; background: rgba(255,255,255,0.98); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: 0 18px 40px rgba(0,0,0,0.16); backdrop-filter: blur(10px); }
|
||||
.cookie-consent__copy p { margin: 6px 0 0; color: var(--color-muted); }
|
||||
.cookie-consent__actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.legal-page { min-height: 40vh; }
|
||||
.content-card { background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 24px; box-shadow: var(--shadow-card); }
|
||||
.content-card--narrow { max-width: 860px; margin: 0 auto; }
|
||||
@@ -279,6 +286,8 @@ body {
|
||||
.forum-admin-item { padding-left: 16px; padding-right: 16px; }
|
||||
.forum-post__body,
|
||||
.forum-reply-form { padding: 18px 16px; }
|
||||
.cookie-consent__inner { grid-template-columns: 1fr; }
|
||||
.cookie-consent__actions { justify-content: stretch; }
|
||||
}
|
||||
|
||||
/* Auth & Dashboard */
|
||||
|
||||
@@ -2,6 +2,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const body = document.body;
|
||||
const isLoggedIn = body.dataset.auth === '1';
|
||||
const childGender = body.dataset.childGender || '';
|
||||
const locationPreference = body.dataset.locationPreference || 'prompt';
|
||||
const header = document.querySelector('.site-header');
|
||||
const headerSentinel = document.getElementById('headerSentinel');
|
||||
|
||||
@@ -52,6 +53,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Events from backend (injected on page); fallback to empty
|
||||
const events = Array.isArray(window.__events) ? window.__events : [];
|
||||
const threads = Array.isArray(window.__threads) ? window.__threads : [];
|
||||
const EVENTS_RADIUS_KM = 20;
|
||||
const LOCATION_STORAGE_KEY = 'pkt_user_location';
|
||||
const LOCATION_SESSION_STORAGE_KEY = 'pkt_user_location_session';
|
||||
const LOCATION_SESSION_PREFERENCE_KEY = 'pkt_user_location_session_preference';
|
||||
|
||||
const el = {
|
||||
sliderTrack: document.getElementById('eventSlider'),
|
||||
@@ -71,43 +76,211 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
quickLat: document.getElementById('qsLat'),
|
||||
quickLng: document.getElementById('qsLng'),
|
||||
quickGeo: document.getElementById('quickGeo'),
|
||||
eventsSection: document.getElementById('events'),
|
||||
locationPreferenceModal: document.getElementById('locationPreferenceModal'),
|
||||
};
|
||||
let effectiveLocationPreference = locationPreference;
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const now = () => new Date();
|
||||
|
||||
const setCookie = (name, value, days = 30) => {
|
||||
const expires = new Date(Date.now() + (days * 86400000)).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
const parseCookie = (name) => {
|
||||
const prefix = `${name}=`;
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map(item => item.trim())
|
||||
.find(item => item.startsWith(prefix))
|
||||
?.slice(prefix.length) || '';
|
||||
};
|
||||
|
||||
const persistLocation = (location, mode = 'persistent') => {
|
||||
if (!location || !Number.isFinite(location.lat) || !Number.isFinite(location.lng)) return;
|
||||
const payload = {
|
||||
lat: Number(location.lat.toFixed(6)),
|
||||
lng: Number(location.lng.toFixed(6)),
|
||||
label: location.label || 'Mein Standort',
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
if (mode === 'session') {
|
||||
try {
|
||||
window.sessionStorage.setItem(LOCATION_SESSION_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
window.localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setCookie('pkt_user_location', JSON.stringify(payload), 30);
|
||||
}
|
||||
if (el.quickLat) el.quickLat.value = payload.lat.toFixed(6);
|
||||
if (el.quickLng) el.quickLng.value = payload.lng.toFixed(6);
|
||||
if (el.quickLoc && (!el.quickLoc.value || el.quickLoc.value === 'Mein Standort')) {
|
||||
el.quickLoc.value = payload.label;
|
||||
}
|
||||
};
|
||||
|
||||
const clearStoredLocation = () => {
|
||||
try {
|
||||
window.localStorage.removeItem(LOCATION_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.removeItem(LOCATION_SESSION_STORAGE_KEY);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
document.cookie = 'pkt_user_location=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax';
|
||||
if (el.quickLat) el.quickLat.value = '';
|
||||
if (el.quickLng) el.quickLng.value = '';
|
||||
if (el.quickLoc) el.quickLoc.value = '';
|
||||
};
|
||||
|
||||
const getStoredLocation = () => {
|
||||
const candidates = [];
|
||||
try {
|
||||
const session = window.sessionStorage.getItem(LOCATION_SESSION_STORAGE_KEY);
|
||||
if (session) candidates.push(session);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const local = window.localStorage.getItem(LOCATION_STORAGE_KEY);
|
||||
if (local) candidates.push(local);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
const cookieValue = parseCookie('pkt_user_location');
|
||||
if (cookieValue) candidates.push(decodeURIComponent(cookieValue));
|
||||
|
||||
for (const entry of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(entry);
|
||||
if (parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lng)) {
|
||||
return {
|
||||
lat: Number(parsed.lat),
|
||||
lng: Number(parsed.lng),
|
||||
label: parsed.label || 'Mein Standort',
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore invalid payloads
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const haversineKm = (lat1, lng1, lat2, lng2) => {
|
||||
const toRad = (value) => value * Math.PI / 180;
|
||||
const earthRadiusKm = 6371;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
};
|
||||
|
||||
const smallTag = (label) => `<span class="badge">${label}</span>`;
|
||||
|
||||
const getRankedEvents = (sourceEvents, location) => {
|
||||
const normalized = sourceEvents.map(item => {
|
||||
const startsAtDate = new Date(item.startsAt);
|
||||
const isFuture = startsAtDate >= now();
|
||||
const hasGeo = Number.isFinite(item.lat) && Number.isFinite(item.lng);
|
||||
const distanceKm = (location && hasGeo)
|
||||
? haversineKm(location.lat, location.lng, item.lat, item.lng)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
startsAtDate,
|
||||
isFuture,
|
||||
distanceKm,
|
||||
};
|
||||
});
|
||||
|
||||
if (!location) {
|
||||
return normalized
|
||||
.filter(item => item.isFuture)
|
||||
.sort((a, b) => a.startsAtDate - b.startsAtDate)
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
const nearby = normalized.filter(item => item.distanceKm !== null && item.distanceKm <= EVENTS_RADIUS_KM);
|
||||
const byDistanceThenDate = (a, b) => {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
|
||||
return a.startsAtDate - b.startsAtDate;
|
||||
};
|
||||
|
||||
const upcomingNearby = nearby
|
||||
.filter(item => item.isFuture)
|
||||
.sort(byDistanceThenDate);
|
||||
|
||||
if (upcomingNearby.length) {
|
||||
return upcomingNearby.slice(0, 10);
|
||||
}
|
||||
|
||||
const missedNearby = nearby
|
||||
.filter(item => !item.isFuture)
|
||||
.sort((a, b) => {
|
||||
if (a.distanceKm !== b.distanceKm) return a.distanceKm - b.distanceKm;
|
||||
return b.startsAtDate - a.startsAtDate;
|
||||
})
|
||||
.map(item => ({ ...item, missed: true }));
|
||||
|
||||
if (missedNearby.length) {
|
||||
return missedNearby.slice(0, 10);
|
||||
}
|
||||
|
||||
return normalized
|
||||
.filter(item => item.isFuture)
|
||||
.sort((a, b) => a.startsAtDate - b.startsAtDate)
|
||||
.slice(0, 10);
|
||||
};
|
||||
|
||||
const renderCard = (item) => {
|
||||
const guest = !isLoggedIn;
|
||||
const kids = item.allowKids ? 'Mit Kindern' : 'Ohne Kinder';
|
||||
const distanceTag = Number.isFinite(item.distanceKm) ? smallTag(`${item.distanceKm.toFixed(1)} km`) : '';
|
||||
const missedTag = item.missed ? smallTag('Das hast du verpasst') : '';
|
||||
return `
|
||||
<article class="card event-card-small">
|
||||
<div class="muted small">${fmtDate(item.startsAt)}</div>
|
||||
<h3>${item.title}</h3>
|
||||
<p class="muted">${item.teaser}</p>
|
||||
<div class="muted small">📍 ${item.city || item.region || ''}</div>
|
||||
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')}</div>
|
||||
<div class="event__tags">${smallTag(kids)} ${item.visibility === 'public' ? smallTag('Öffentlich') : smallTag('Mitglieder')} ${distanceTag} ${missedTag}</div>
|
||||
<div class="flex gap-8" style="margin-top:8px;">
|
||||
<button class="btn ghost" data-event-detail="${item.id}">Details</button>
|
||||
${guest ? '' : '<button class="btn">Teilnehmen</button>'}
|
||||
${guest || item.missed ? '' : '<button class="btn">Teilnehmen</button>'}
|
||||
</div>
|
||||
</article>`;
|
||||
};
|
||||
|
||||
const renderSlider = () => {
|
||||
const renderSlider = (location = null) => {
|
||||
if (!el.sliderTrack) return;
|
||||
if (!events.length) {
|
||||
const rankedEvents = getRankedEvents(events, location);
|
||||
if (!rankedEvents.length) {
|
||||
el.sliderTrack.innerHTML = '<p class="muted">Keine Events vorhanden.</p>';
|
||||
return;
|
||||
}
|
||||
el.sliderTrack.innerHTML = events.slice(0, 10).map(renderCard).join('');
|
||||
el.sliderTrack.innerHTML = rankedEvents.map(renderCard).join('');
|
||||
el.sliderTrack.querySelectorAll('[data-event-detail]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = parseInt(btn.getAttribute('data-event-detail'), 10);
|
||||
const ev = events.find(e => e.id === id);
|
||||
const ev = rankedEvents.find(e => e.id === id);
|
||||
if (!ev || !el.modal || !el.modalBody || !el.modalTitle) return;
|
||||
el.modalTitle.textContent = ev.title;
|
||||
el.modalBody.innerHTML = `
|
||||
@@ -115,6 +288,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<p class="muted">${ev.teaser}</p>
|
||||
<p>${ev.description}</p>
|
||||
${ev.locationLabel ? `<p><strong>Ort:</strong> ${ev.locationLabel}</p>` : ''}
|
||||
${Number.isFinite(ev.distanceKm) ? `<p><strong>Entfernung:</strong> ${ev.distanceKm.toFixed(1)} km</p>` : ''}
|
||||
${ev.missed ? '<p><strong>Hinweis:</strong> Das hast du verpasst.</p>' : ''}
|
||||
<p><strong>Kinder:</strong> ${ev.allowKids ? 'Mit Kindern' : 'Ohne Kinder'}</p>
|
||||
<p><strong>Sichtbarkeit:</strong> ${ev.visibility === 'public' ? 'Öffentlich' : 'Nur Mitglieder'}</p>
|
||||
`;
|
||||
@@ -160,6 +335,170 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
el.threadSliderPrev?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'prev'));
|
||||
el.threadSliderNext?.addEventListener('click', () => scrollSlider(el.threadSliderViewport, 'next'));
|
||||
|
||||
const applyStoredLocationToForm = () => {
|
||||
const stored = getStoredLocation();
|
||||
if (!stored) return null;
|
||||
if (el.quickLat) el.quickLat.value = stored.lat.toFixed(6);
|
||||
if (el.quickLng) el.quickLng.value = stored.lng.toFixed(6);
|
||||
if (el.quickLoc && !el.quickLoc.value) el.quickLoc.value = stored.label || 'Mein Standort';
|
||||
return stored;
|
||||
};
|
||||
|
||||
const requestLocation = (options = {}) => new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const location = {
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
label: 'Mein Standort',
|
||||
};
|
||||
persistLocation(location, options.persistMode || 'persistent');
|
||||
resolve(location);
|
||||
},
|
||||
() => resolve(null),
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: options.timeout || 10000,
|
||||
maximumAge: options.maximumAge || 300000,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const closeLocationPreferenceModal = () => {
|
||||
el.locationPreferenceModal?.classList.remove('open');
|
||||
};
|
||||
|
||||
document.querySelectorAll('[data-location-pref-close]').forEach(btn => {
|
||||
btn.addEventListener('click', closeLocationPreferenceModal);
|
||||
});
|
||||
el.locationPreferenceModal?.addEventListener('click', (event) => {
|
||||
if (event.target === el.locationPreferenceModal) {
|
||||
closeLocationPreferenceModal();
|
||||
}
|
||||
});
|
||||
|
||||
const saveLocationPreference = async (preference) => {
|
||||
effectiveLocationPreference = preference;
|
||||
if (!isLoggedIn) {
|
||||
try {
|
||||
window.localStorage.setItem('pkt_location_preference_guest', preference);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({ preference });
|
||||
const response = await fetch('/api/location-preference', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionPreference = () => {
|
||||
try {
|
||||
return window.sessionStorage.getItem(LOCATION_SESSION_PREFERENCE_KEY);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setSessionPreference = (value) => {
|
||||
try {
|
||||
if (!value) {
|
||||
window.sessionStorage.removeItem(LOCATION_SESSION_PREFERENCE_KEY);
|
||||
} else {
|
||||
window.sessionStorage.setItem(LOCATION_SESSION_PREFERENCE_KEY, value);
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePromptChoice = () => new Promise((resolve) => {
|
||||
if (!el.locationPreferenceModal) {
|
||||
resolve('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const buttons = el.locationPreferenceModal.querySelectorAll('[data-location-pref-choice]');
|
||||
const cleanup = () => {
|
||||
buttons.forEach(button => button.removeEventListener('click', handleClick));
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
const finish = (value) => {
|
||||
cleanup();
|
||||
closeLocationPreferenceModal();
|
||||
resolve(value);
|
||||
};
|
||||
const handleClick = (event) => {
|
||||
const value = event.currentTarget.getAttribute('data-location-pref-choice') || 'disabled';
|
||||
finish(value);
|
||||
};
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
finish('disabled');
|
||||
}
|
||||
};
|
||||
|
||||
buttons.forEach(button => button.addEventListener('click', handleClick));
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
el.locationPreferenceModal.classList.add('open');
|
||||
});
|
||||
|
||||
const ensureEventsLocation = async () => {
|
||||
const sessionPreference = getSessionPreference();
|
||||
if (effectiveLocationPreference === 'disabled') {
|
||||
clearStoredLocation();
|
||||
renderSlider(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const stored = applyStoredLocationToForm();
|
||||
if (stored && effectiveLocationPreference !== 'enabled' && sessionPreference !== 'session') {
|
||||
renderSlider(stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (effectiveLocationPreference === 'prompt') {
|
||||
const choice = sessionPreference || await resolvePromptChoice();
|
||||
if (choice === 'session') {
|
||||
setSessionPreference('session');
|
||||
const location = await requestLocation({ persistMode: 'session' });
|
||||
renderSlider(location || stored);
|
||||
return location;
|
||||
}
|
||||
if (choice === 'disabled') {
|
||||
await saveLocationPreference('disabled');
|
||||
clearStoredLocation();
|
||||
renderSlider(null);
|
||||
return null;
|
||||
}
|
||||
if (choice === 'enabled') {
|
||||
await saveLocationPreference('enabled');
|
||||
effectiveLocationPreference = 'enabled';
|
||||
}
|
||||
}
|
||||
|
||||
const location = await requestLocation({
|
||||
persistMode: effectiveLocationPreference === 'enabled' ? 'persistent' : 'session',
|
||||
});
|
||||
renderSlider(location || stored);
|
||||
return location;
|
||||
};
|
||||
|
||||
// Quick search with geocoding
|
||||
const geocode = async (query) => {
|
||||
const url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' + encodeURIComponent(query);
|
||||
@@ -178,11 +517,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
if (el.quickLat && el.quickLng) {
|
||||
el.quickLat.value = pos.coords.latitude.toFixed(6);
|
||||
el.quickLng.value = pos.coords.longitude.toFixed(6);
|
||||
if (el.quickLoc) el.quickLoc.value = 'Mein Standort';
|
||||
}
|
||||
persistLocation({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
label: 'Mein Standort',
|
||||
}, effectiveLocationPreference === 'enabled' ? 'persistent' : 'session');
|
||||
renderSlider(getStoredLocation());
|
||||
},
|
||||
() => alert('Standort konnte nicht ermittelt werden.')
|
||||
);
|
||||
@@ -215,6 +555,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
el.modal?.addEventListener('click', (e) => { if (e.target === el.modal) el.modal.classList.remove('open'); });
|
||||
|
||||
renderSlider();
|
||||
document.querySelectorAll('[data-nav-events]').forEach(link => {
|
||||
link.addEventListener('click', async () => {
|
||||
if (window.location.pathname === '/' && window.location.hash === '#events') {
|
||||
await ensureEventsLocation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
if (!isLoggedIn) {
|
||||
const guestPreference = window.localStorage.getItem('pkt_location_preference_guest');
|
||||
if (guestPreference && ['disabled', 'prompt', 'enabled'].includes(guestPreference)) {
|
||||
effectiveLocationPreference = guestPreference;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const initialLocation = effectiveLocationPreference === 'disabled' ? null : applyStoredLocationToForm();
|
||||
if (effectiveLocationPreference === 'disabled') {
|
||||
clearStoredLocation();
|
||||
}
|
||||
renderSlider(initialLocation);
|
||||
renderThreadSlider();
|
||||
|
||||
if (effectiveLocationPreference === 'enabled' && window.location.pathname === '/') {
|
||||
ensureEventsLocation();
|
||||
} else if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
|
||||
ensureEventsLocation();
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (window.location.hash === '#events' || window.location.hash === '#quicksearch') {
|
||||
ensureEventsLocation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,9 @@ $targetReal = realpath($target);
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) {
|
||||
$skipLayout = true;
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// Ausgabe
|
||||
|
||||
38
public/page/api/location-preference.php
Normal file
38
public/page/api/location-preference.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['ok' => false, 'error' => 'method_not_allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user_id'])) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['ok' => false, 'error' => 'unauthorized']);
|
||||
return;
|
||||
}
|
||||
|
||||
$preference = (string)($_POST['preference'] ?? '');
|
||||
if (!in_array($preference, ['disabled', 'prompt', 'enabled'], true)) {
|
||||
http_response_code(422);
|
||||
echo json_encode(['ok' => false, 'error' => 'invalid_preference']);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = app()->pdo();
|
||||
if (!$pdo) {
|
||||
throw new RuntimeException('db_unavailable');
|
||||
}
|
||||
|
||||
$settings = new \App\ProfileSettings($pdo);
|
||||
$settings->updateLocationTrackingPreference((int)$_SESSION['user_id'], $preference);
|
||||
|
||||
echo json_encode(['ok' => true, 'preference' => $preference]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['ok' => false, 'error' => 'save_failed']);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ CREATE TABLE user_profiles (
|
||||
region VARCHAR(120) NULL,
|
||||
lat DECIMAL(10,7) NULL,
|
||||
lng DECIMAL(10,7) NULL,
|
||||
location_tracking_preference ENUM('disabled','prompt','enabled') NOT NULL DEFAULT 'prompt',
|
||||
contact_phone VARBINARY(512) NULL,
|
||||
contact_email VARBINARY(512) NULL,
|
||||
profession VARBINARY(512) NULL,
|
||||
|
||||
@@ -158,6 +158,7 @@ final class AccountPages
|
||||
$community = $pdo ? new Community($pdo, $communityConfig) : null;
|
||||
$communityAccess = $pdo ? new CommunityAccess($pdo, $communityConfig) : null;
|
||||
$communityMigration = $pdo ? new CommunityMigration($pdo) : null;
|
||||
$profileSettings = $pdo ? new ProfileSettings($pdo) : null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
@@ -168,7 +169,11 @@ final class AccountPages
|
||||
$languages = implode(', ', array_map('trim', $languages));
|
||||
}
|
||||
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
|
||||
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
|
||||
$locationPreference = (string)($_POST['location_tracking_preference'] ?? 'prompt');
|
||||
if ($profileSettings) {
|
||||
$profileSettings->ensureSchema();
|
||||
}
|
||||
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, location_tracking_preference=:locationPref, updated_at=NOW() WHERE user_id=:id');
|
||||
$stmt?->execute([
|
||||
'name' => trim((string)$_POST['display_name']),
|
||||
'fname' => trim((string)$_POST['first_name']),
|
||||
@@ -179,6 +184,7 @@ final class AccountPages
|
||||
'langs' => trim((string)$languages),
|
||||
'about' => trim((string)$_POST['about']),
|
||||
'phone' => $phoneEnc,
|
||||
'locationPref' => in_array($locationPreference, ['disabled', 'prompt', 'enabled'], true) ? $locationPreference : 'prompt',
|
||||
'id' => $userId,
|
||||
]);
|
||||
$info = 'Profil gespeichert.';
|
||||
@@ -321,8 +327,12 @@ final class AccountPages
|
||||
'about' => '',
|
||||
'email' => '',
|
||||
'contact_phone' => '',
|
||||
'location_tracking_preference' => 'prompt',
|
||||
];
|
||||
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
|
||||
if ($profileSettings) {
|
||||
$profileSettings->ensureSchema();
|
||||
}
|
||||
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone, p.location_tracking_preference FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
|
||||
$stmt?->execute(['id' => $userId]);
|
||||
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
|
||||
83
src/App/ProfileSettings.php
Normal file
83
src/App/ProfileSettings.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class ProfileSettings
|
||||
{
|
||||
private bool $schemaEnsured = false;
|
||||
private array $columnCache = [];
|
||||
|
||||
public function __construct(private \PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function ensureSchema(): void
|
||||
{
|
||||
if ($this->schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->hasColumn('user_profiles', 'location_tracking_preference')) {
|
||||
$this->pdo->exec(
|
||||
"ALTER TABLE user_profiles
|
||||
ADD COLUMN location_tracking_preference ENUM('disabled','prompt','enabled')
|
||||
NOT NULL DEFAULT 'prompt'
|
||||
AFTER lng"
|
||||
);
|
||||
$this->columnCache['user_profiles.location_tracking_preference'] = true;
|
||||
}
|
||||
|
||||
$this->schemaEnsured = true;
|
||||
}
|
||||
|
||||
public function getLocationTrackingPreference(int $userId): string
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return 'prompt';
|
||||
}
|
||||
|
||||
$this->ensureSchema();
|
||||
$stmt = $this->pdo->prepare('SELECT location_tracking_preference FROM user_profiles WHERE user_id = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $userId]);
|
||||
$value = (string)($stmt->fetchColumn() ?: 'prompt');
|
||||
return in_array($value, ['disabled', 'prompt', 'enabled'], true) ? $value : 'prompt';
|
||||
}
|
||||
|
||||
public function updateLocationTrackingPreference(int $userId, string $preference): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ensureSchema();
|
||||
$preference = in_array($preference, ['disabled', 'prompt', 'enabled'], true) ? $preference : 'prompt';
|
||||
$stmt = $this->pdo->prepare('UPDATE user_profiles SET location_tracking_preference = :pref, updated_at = NOW() WHERE user_id = :id');
|
||||
$stmt->execute([
|
||||
'pref' => $preference,
|
||||
'id' => $userId,
|
||||
]);
|
||||
}
|
||||
|
||||
private function hasColumn(string $table, string $column): bool
|
||||
{
|
||||
$cacheKey = $table . '.' . $column;
|
||||
if (array_key_exists($cacheKey, $this->columnCache)) {
|
||||
return $this->columnCache[$cacheKey];
|
||||
}
|
||||
|
||||
$stmt = $this->pdo->prepare("
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = :tableName
|
||||
AND COLUMN_NAME = :columnName
|
||||
");
|
||||
$stmt->execute([
|
||||
'tableName' => $table,
|
||||
'columnName' => $column,
|
||||
]);
|
||||
|
||||
return $this->columnCache[$cacheKey] = ((int)$stmt->fetchColumn() > 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user