Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 354bb4255d | |||
| d83b62b79c | |||
| 25327e3302 | |||
| 2ce3eec65d |
@@ -1,3 +1,5 @@
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
RewriteEngine On
|
||||
RewriteRule ^forum/?$ forum.php [L,QSA]
|
||||
RewriteRule ^forum/([^/]+)/?$ forum.php?topic=$1 [L,QSA]
|
||||
+2
-2
@@ -136,7 +136,7 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="sec-rule"></div>
|
||||
</div>
|
||||
<?php foreach($upevts as $ev): ?>
|
||||
<div class="evt-card">
|
||||
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
|
||||
<div class="evt-date-box">
|
||||
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
|
||||
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
|
||||
@@ -151,7 +151,7 @@ include __DIR__.'/includes/header.php';
|
||||
</div>
|
||||
<div class="evt-desc"><?=e(substr($ev['description'],0,180)).(strlen($ev['description'])>180?'…':'')?></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<div style="text-align:center;margin-top:1.5rem">
|
||||
<a href="/events.php" class="btn btn-outline">All Events</a>
|
||||
|
||||
@@ -69,11 +69,24 @@ php -S localhost:8080
|
||||
- **Alerts** — post/manage business announcements
|
||||
- **Attractions** — full CRUD for attractions
|
||||
- **Events** — approve/reject submissions, add events, feature events
|
||||
- **Event Imports** — review Facebook event candidates staged by the CLI importer
|
||||
- **Users** — add/edit/delete users, reset passwords, suspend accounts
|
||||
- **Assign Owners** — link users to businesses for self-management
|
||||
- **Reports** — review and close user-submitted listing corrections
|
||||
- **Settings** — toggle registration on/off, event approval, change admin password
|
||||
|
||||
### Facebook Event Import Workflow
|
||||
|
||||
Stage Facebook event candidates from the official Facebook Graph API, then review and import them from Admin -> Event Imports:
|
||||
|
||||
```bash
|
||||
FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20
|
||||
php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345
|
||||
php scripts/facebook-events-import.php --access-token=... --area="Keyser, WV" --center=39.4364,-78.9762 --distance=25000 --query="Keyser" --discover-pages
|
||||
```
|
||||
|
||||
The importer does not scrape Facebook, log in, or bypass access controls. Your Meta app/token must have permission to read the requested Page/Event data through Graph API.
|
||||
|
||||
---
|
||||
|
||||
## FILE STRUCTURE
|
||||
@@ -86,6 +99,7 @@ keyser-wv/
|
||||
├── menu.php # Owner menu manager
|
||||
├── attractions.php # Attractions page
|
||||
├── events.php # Events calendar + submission
|
||||
├── event.php # Event detail page
|
||||
├── about.php # About Keyser WV history
|
||||
├── login.php # Login
|
||||
├── register.php # Registration
|
||||
@@ -95,6 +109,7 @@ keyser-wv/
|
||||
├── includes/
|
||||
│ ├── boot.php # Bootstrap (session + DB init)
|
||||
│ ├── db.php # Database, schema, seed data
|
||||
│ ├── facebook_event_importer.php # Facebook event parser/import helpers
|
||||
│ ├── functions.php # Auth, helpers, CSRF, stars
|
||||
│ ├── header.php # HTML header + navbar
|
||||
│ └── footer.php # Footer + JS
|
||||
@@ -107,10 +122,13 @@ keyser-wv/
|
||||
│ ├── alerts.php # Manage alerts
|
||||
│ ├── attractions.php # Manage attractions
|
||||
│ ├── events.php # Manage events
|
||||
│ ├── event_imports.php # Review staged Facebook event imports
|
||||
│ ├── users.php # Manage users
|
||||
│ ├── owners.php # Assign owners to businesses
|
||||
│ ├── reports.php # Handle listing reports
|
||||
│ └── settings.php # Site settings + password
|
||||
├── scripts/
|
||||
│ └── facebook-events-import.php # CLI Facebook event stager
|
||||
├── assets/
|
||||
│ ├── css/style.css # Complete dark WV theme
|
||||
│ └── js/app.js # Nav, dropdowns, confirm dialogs
|
||||
|
||||
@@ -7,6 +7,7 @@ include dirname(__DIR__).'/includes/header.php';
|
||||
// Pending owner edit count for badge
|
||||
$_pendingEdits = (int)qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'");
|
||||
$_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'");
|
||||
$_pendingEventImports = (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'");
|
||||
?>
|
||||
<div class="admin-wrap">
|
||||
<aside class="admin-side">
|
||||
@@ -25,7 +26,14 @@ $_pendingClaims = (int)qval("SELECT COUNT(*) FROM business_claim_requests WHERE
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<div class="admin-side-title" style="margin-top:1rem">COMMUNITY</div>
|
||||
<a class="asl" href="/admin/forum.php">💬 Forum</a>
|
||||
<a class="asl" href="/admin/events.php">📅 Events</a>
|
||||
<a class="asl" href="/admin/event_imports.php" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span>Event Imports</span>
|
||||
<?php if ($_pendingEventImports): ?>
|
||||
<span style="background:var(--gold);color:var(--bg0);font-family:'Oswald',sans-serif;font-size:.65rem;padding:.1rem .42rem;border-radius:10px;font-weight:600"><?=$_pendingEventImports?></span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<a class="asl" href="/admin/attractions.php">🗺️ Attractions</a>
|
||||
<a class="asl" href="/admin/reports.php">🚩 Reports</a>
|
||||
<div class="admin-side-title" style="margin-top:1rem">USERS</div>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
|
||||
requireAdmin();
|
||||
$adminTitle = 'Event Imports';
|
||||
|
||||
function eventImportRowsFromPost(): array {
|
||||
$rows = p('rows', []);
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
function eventImportSelectedIds(): array {
|
||||
$ids = p('ids', []);
|
||||
if (!is_array($ids)) $ids = [];
|
||||
return array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
}
|
||||
|
||||
function eventImportSavePostedRows(array $onlyIds = []): void {
|
||||
$wanted = array_fill_keys($onlyIds, true);
|
||||
foreach (eventImportRowsFromPost() as $id => $row) {
|
||||
$id = (int)$id;
|
||||
if ($id <= 0) continue;
|
||||
if ($onlyIds && !isset($wanted[$id])) continue;
|
||||
if (!is_array($row)) continue;
|
||||
facebookEventSaveCandidateFields($id, $row);
|
||||
}
|
||||
}
|
||||
|
||||
$filter = gs('filter') ?: p('filter', 'staged');
|
||||
if (!in_array($filter, ['staged', 'imported', 'skipped', 'all'], true)) $filter = 'staged';
|
||||
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
$eventStatus = in_array(p('event_status', 'pending'), ['pending', 'approved'], true) ? p('event_status', 'pending') : 'pending';
|
||||
$allowDuplicate = p('allow_duplicates', '0') === '1';
|
||||
|
||||
if (($saveId = (int)p('save_id', 0)) > 0) {
|
||||
eventImportSavePostedRows([$saveId]);
|
||||
flash('Import candidate saved.', 'success');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if (($importId = (int)p('import_id', 0)) > 0) {
|
||||
eventImportSavePostedRows([$importId]);
|
||||
$result = facebookEventImportCandidate($importId, uid(), $eventStatus, $allowDuplicate);
|
||||
flash($result['ok'] ? 'Event imported.'.(!empty($result['warning']) ? ' '.$result['warning'] : '') : $result['error'], $result['ok'] ? 'success' : 'error');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if (($skipId = (int)p('skip_id', 0)) > 0) {
|
||||
qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$skipId]);
|
||||
flash('Import candidate skipped.', 'success');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if (($restoreId = (int)p('restore_id', 0)) > 0) {
|
||||
qrun("UPDATE event_imports SET status='staged',updated_at=datetime('now') WHERE id=? AND status='skipped'", [$restoreId]);
|
||||
flash('Import candidate restored.', 'success');
|
||||
go('/admin/event_imports.php?filter=staged');
|
||||
}
|
||||
|
||||
if (($deleteId = (int)p('delete_id', 0)) > 0) {
|
||||
qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$deleteId]);
|
||||
flash('Import candidate deleted.', 'success');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
$act = p('_act', '');
|
||||
if ($act === 'save_all') {
|
||||
eventImportSavePostedRows();
|
||||
flash('Import candidates saved.', 'success');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if ($act === 'bulk_import') {
|
||||
$ids = eventImportSelectedIds();
|
||||
eventImportSavePostedRows($ids);
|
||||
$ok = 0; $errors = [];
|
||||
foreach ($ids as $id) {
|
||||
$result = facebookEventImportCandidate($id, uid(), $eventStatus, $allowDuplicate);
|
||||
if ($result['ok']) $ok++;
|
||||
else $errors[] = '#'.$id.': '.$result['error'];
|
||||
}
|
||||
if ($ok) flash($ok.' event'.($ok === 1 ? '' : 's').' imported.', 'success');
|
||||
if ($errors) flash(implode(' ', $errors), 'error');
|
||||
if (!$ids) flash('Select at least one staged import first.', 'warning');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if ($act === 'bulk_skip') {
|
||||
$ids = eventImportSelectedIds();
|
||||
foreach ($ids as $id) qrun("UPDATE event_imports SET status='skipped',updated_at=datetime('now') WHERE id=? AND status!='imported'", [$id]);
|
||||
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' skipped.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
|
||||
if ($act === 'bulk_delete') {
|
||||
$ids = eventImportSelectedIds();
|
||||
foreach ($ids as $id) qrun("DELETE FROM event_imports WHERE id=? AND status!='imported'", [$id]);
|
||||
flash($ids ? count($ids).' import candidate'.(count($ids) === 1 ? '' : 's').' deleted.' : 'Select at least one staged import first.', $ids ? 'success' : 'warning');
|
||||
go('/admin/event_imports.php?filter='.$filter);
|
||||
}
|
||||
}
|
||||
|
||||
$where = match ($filter) {
|
||||
'staged' => "WHERE status='staged'",
|
||||
'imported' => "WHERE status='imported'",
|
||||
'skipped' => "WHERE status='skipped'",
|
||||
default => '',
|
||||
};
|
||||
$imports = qall("SELECT * FROM event_imports $where ORDER BY CASE status WHEN 'staged' THEN 0 WHEN 'skipped' THEN 1 ELSE 2 END, created_at DESC LIMIT 200");
|
||||
$counts = [
|
||||
'staged' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='staged'"),
|
||||
'imported' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='imported'"),
|
||||
'skipped' => (int)qval("SELECT COUNT(*) FROM event_imports WHERE status='skipped'"),
|
||||
'all' => (int)qval("SELECT COUNT(*) FROM event_imports"),
|
||||
];
|
||||
include __DIR__.'/admin_header.php';
|
||||
?>
|
||||
<?php adminTitle('Facebook Event Imports', 'Review staged Facebook event candidates, correct any fields, then import selected rows into the normal My Keyser events workflow.') ?>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">CLI STAGING</div>
|
||||
<p style="color:var(--text-m);font-size:.9rem;line-height:1.65;margin-bottom:.8rem">
|
||||
Stage Facebook event candidates through the official Graph API, then return here to verify titles, dates, locations, duplicates, and photos before publishing or sending them to pending review.
|
||||
</p>
|
||||
<code style="display:block;white-space:normal;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);padding:.8rem;color:var(--text-m)">FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area="Keyser, WV" --page-id=123456789 --limit=20</code>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem">
|
||||
<div class="flex-row" style="gap:.4rem">
|
||||
<?php foreach(['staged'=>'Staged','imported'=>'Imported','skipped'=>'Skipped','all'=>'All'] as $key => $label): ?>
|
||||
<a href="/admin/event_imports.php?filter=<?=$key?>" class="btn btn-xs<?=$filter===$key?' btn-primary':' btn-outline'?>"><?=$label?> (<?=$counts[$key]?>)</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<a href="/admin/events.php" class="btn btn-xs btn-outline">Manage Events</a>
|
||||
</div>
|
||||
|
||||
<?php if (!$imports): ?>
|
||||
<div class="empty"><div class="empty-ico">+</div><h3>No event imports here</h3><p style="margin-top:.5rem">Run the Graph API CLI importer to stage Facebook event candidates for review.</p></div>
|
||||
<?php else: ?>
|
||||
<form method="POST">
|
||||
<?=csrfField()?>
|
||||
<input type="hidden" name="filter" value="<?=e($filter)?>">
|
||||
<div class="ibox" style="position:sticky;top:76px;z-index:2">
|
||||
<div class="flex-row" style="justify-content:space-between;align-items:flex-end">
|
||||
<div class="flex-row">
|
||||
<div class="fg" style="margin-bottom:0;min-width:180px">
|
||||
<label class="flabel">IMPORT AS</label>
|
||||
<select name="event_status" class="fselect">
|
||||
<option value="pending">Pending review</option>
|
||||
<option value="approved">Approved public event</option>
|
||||
</select>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-top:1.25rem">
|
||||
<input type="checkbox" name="allow_duplicates" value="1"> Allow duplicate title/date
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-row" style="gap:.4rem">
|
||||
<button type="submit" name="_act" value="save_all" class="btn btn-xs btn-outline">Save Edits</button>
|
||||
<button type="submit" name="_act" value="bulk_import" class="btn btn-xs btn-success">Import Selected</button>
|
||||
<button type="submit" name="_act" value="bulk_skip" class="btn btn-xs btn-secondary">Skip Selected</button>
|
||||
<button type="submit" name="_act" value="bulk_delete" class="btn btn-xs btn-danger" data-confirm="Delete selected import candidates?">Delete Selected</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:1rem">
|
||||
<?php foreach ($imports as $imp): ?>
|
||||
<?php
|
||||
$id = (int)$imp['id'];
|
||||
$issues = facebookEventCandidateIssues($imp);
|
||||
$duplicateId = facebookEventDuplicateId($imp);
|
||||
$statusClass = ['staged' => 'badge-gold', 'imported' => 'badge-green', 'skipped' => 'badge-gray'][$imp['status']] ?? 'badge-gray';
|
||||
$preview = $imp['image_path'] ?: $imp['image_url'];
|
||||
$canEdit = $imp['status'] !== 'imported';
|
||||
?>
|
||||
<div class="ibox" style="margin-bottom:0">
|
||||
<div class="event-import-grid">
|
||||
<div>
|
||||
<?php if ($imp['status'] === 'staged'): ?>
|
||||
<label style="display:flex;align-items:center;gap:.45rem;color:var(--text-m);font-size:.86rem;margin-bottom:.55rem">
|
||||
<input type="checkbox" name="ids[]" value="<?=$id?>"> Select
|
||||
</label>
|
||||
<?php endif; ?>
|
||||
<div class="event-import-preview">
|
||||
<?php if ($preview): ?>
|
||||
<img src="<?=e($preview)?>" alt="<?=e($imp['title'] ?: 'Event image')?>">
|
||||
<?php else: ?>
|
||||
No image
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="flex-row" style="gap:.35rem;margin-top:.65rem">
|
||||
<span class="badge <?=$statusClass?>"><?=strtoupper(e($imp['status']))?></span>
|
||||
<?php if (!$issues && !$duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-green">READY</span><?php endif; ?>
|
||||
<?php if ($issues): ?><span class="badge badge-red">NEEDS FIX</span><?php endif; ?>
|
||||
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><span class="badge badge-gold">DUPLICATE?</span><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($imp['source_url']): ?><p style="font-size:.78rem;margin-top:.6rem"><a href="<?=e($imp['source_url'])?>" target="_blank">View source</a></p><?php endif; ?>
|
||||
<?php if ($imp['imported_event_id']): ?><p style="font-size:.78rem;margin-top:.25rem"><a href="/admin/events.php?edit=<?=(int)$imp['imported_event_id']?>">Edit imported event #<?=(int)$imp['imported_event_id']?></a></p><?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="min-width:0">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="rows[<?=$id?>][title]" class="finput" value="<?=e($imp['title'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
<div class="fg"><label class="flabel">SOURCE AREA</label><input type="text" class="finput" value="<?=e($imp['source_area'])?>" disabled></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="rows[<?=$id?>][description]" class="ftextarea" style="min-height:76px" <?=$canEdit?'':'disabled'?>><?=e($imp['description'])?></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">VENUE</label><input type="text" name="rows[<?=$id?>][venue]" class="finput" value="<?=e($imp['venue'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
<div class="fg"><label class="flabel">ADDRESS</label><input type="text" name="rows[<?=$id?>][address]" class="finput" value="<?=e($imp['address'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">START DATE *</label><input type="date" name="rows[<?=$id?>][event_date]" class="finput" value="<?=e($imp['event_date'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
<div class="fg"><label class="flabel">START TIME</label><input type="text" name="rows[<?=$id?>][event_time]" class="finput" value="<?=e($imp['event_time'])?>" placeholder="e.g. 7:00 PM" <?=$canEdit?'':'disabled'?>></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">END DATE</label><input type="date" name="rows[<?=$id?>][end_date]" class="finput" value="<?=e($imp['end_date'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
<div class="fg"><label class="flabel">END TIME</label><input type="text" name="rows[<?=$id?>][end_time]" class="finput" value="<?=e($imp['end_time'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">COST</label><input type="text" name="rows[<?=$id?>][cost]" class="finput" value="<?=e($imp['cost'] ?: 'Free')?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="rows[<?=$id?>][website]" class="finput" value="<?=e($imp['website'])?>" <?=$canEdit?'':'disabled'?>></div>
|
||||
</div>
|
||||
<input type="hidden" name="rows[<?=$id?>][image_path]" value="<?=e($imp['image_path'])?>">
|
||||
<?php if ($issues || $duplicateId || $imp['status_note']): ?>
|
||||
<div style="background:rgba(201,168,76,.08);border:1px solid rgba(201,168,76,.2);border-radius:var(--r);padding:.75rem;margin:.4rem 0 1rem;color:var(--text-m);font-size:.84rem;line-height:1.55">
|
||||
<?php if ($issues): ?><div><strong>Needs review:</strong> <?=e(implode(', ', $issues))?></div><?php endif; ?>
|
||||
<?php if ($duplicateId && $imp['status'] === 'staged'): ?><div><strong>Possible duplicate:</strong> existing event #<?=$duplicateId?> has the same title and start date.</div><?php endif; ?>
|
||||
<?php if ($imp['status_note']): ?><div><strong>Note:</strong> <?=e($imp['status_note'])?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="flex-row" style="gap:.4rem">
|
||||
<?php if ($canEdit): ?><button type="submit" name="save_id" value="<?=$id?>" class="btn btn-xs btn-outline">Save</button><?php endif; ?>
|
||||
<?php if ($imp['status'] === 'staged'): ?><button type="submit" name="import_id" value="<?=$id?>" class="btn btn-xs btn-success">Import</button><button type="submit" name="skip_id" value="<?=$id?>" class="btn btn-xs btn-secondary">Skip</button><?php endif; ?>
|
||||
<?php if ($imp['status'] === 'skipped'): ?><button type="submit" name="restore_id" value="<?=$id?>" class="btn btn-xs btn-primary">Restore</button><?php endif; ?>
|
||||
<?php if ($imp['status'] !== 'imported'): ?><button type="submit" name="delete_id" value="<?=$id?>" class="btn btn-xs btn-danger" data-confirm="Delete this import candidate?">Delete</button><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php include __DIR__.'/admin_footer.php'; ?>
|
||||
+40
-7
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
requireAdmin();
|
||||
$adminTitle = 'Events';
|
||||
include __DIR__.'/admin_header.php';
|
||||
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
@@ -12,18 +13,36 @@ if (isPost()) {
|
||||
if ($act === 'feature') { qrun("UPDATE events SET is_featured=1-is_featured WHERE id=?",[(int)p('id')]); go('/admin/events.php'); }
|
||||
|
||||
if ($act === 'add' || $act === 'edit') {
|
||||
$id = (int)p('id');
|
||||
$existing = $act === 'edit' ? qone("SELECT image_path FROM events WHERE id=?", [$id]) : null;
|
||||
$imagePath = (string)($existing['image_path'] ?? '');
|
||||
if (p('remove_image','0') === '1') $imagePath = '';
|
||||
$upload = storeSingleUpload(
|
||||
'event_image',
|
||||
'events',
|
||||
imageExts(),
|
||||
mbToBytes(setting('event_upload_max_mb','5'), 5),
|
||||
[
|
||||
'image_only' => true,
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width','1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if (!$upload['ok']) { flash($upload['error'], 'error'); go('/admin/events.php'.($id ? '?edit='.$id : '')); }
|
||||
if (!empty($upload['path'])) $imagePath = (string)$upload['path'];
|
||||
|
||||
$f=['title'=>ps('title'),'description'=>ps('description'),'venue'=>ps('venue'),'address'=>ps('address'),
|
||||
'event_date'=>ps('event_date'),'event_time'=>ps('event_time'),'end_date'=>ps('end_date')?:null,
|
||||
'end_time'=>ps('end_time'),'cost'=>ps('cost'),'website'=>ps('website'),'contact_name'=>ps('contact_name'),
|
||||
'contact_email'=>ps('contact_email'),'contact_phone'=>ps('contact_phone'),
|
||||
'contact_email'=>ps('contact_email'),'contact_phone'=>ps('contact_phone'),'image_path'=>$imagePath,
|
||||
'status'=>p('status','approved'),'is_featured'=>(int)p('is_featured',0)];
|
||||
if (!$f['title'] || !$f['event_date']) { flash('Title and date required.','error'); go('/admin/events.php'); }
|
||||
if ($act === 'add') {
|
||||
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,status,is_featured,submitted_by)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",array_merge(array_values($f),[uid()]));
|
||||
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,status,is_featured,submitted_by)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",array_merge(array_values($f),[uid()]));
|
||||
flash('Event added!','success');
|
||||
} else {
|
||||
$id = (int)p('id');
|
||||
qrun("UPDATE events SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,contact_name=?,contact_email=?,contact_phone=?,status=?,is_featured=? WHERE id=?",array_merge(array_values($f),[$id]));
|
||||
qrun("UPDATE events SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,contact_name=?,contact_email=?,contact_phone=?,image_path=?,status=?,is_featured=? WHERE id=?",array_merge(array_values($f),[$id]));
|
||||
flash('Event updated!','success');
|
||||
}
|
||||
go('/admin/events.php');
|
||||
@@ -35,12 +54,13 @@ $editId = iget('edit');
|
||||
$editing = $editId ? qone("SELECT * FROM events WHERE id=?",[$editId]) : null;
|
||||
$where = match($filter) { 'pending'=>"WHERE status='pending'",'approved'=>"WHERE status='approved'", default=>"" };
|
||||
$events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.submitted_by $where ORDER BY CASE status WHEN 'pending' THEN 0 ELSE 1 END,event_date DESC");
|
||||
include __DIR__.'/admin_header.php';
|
||||
?>
|
||||
<?php adminTitle('Manage Events') ?>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d);margin-bottom:2rem">
|
||||
<div class="ibox-title"><?=$editing ? '✏️ EDIT EVENT: '.strtoupper(e($editing['title'])) : '➕ ADD EVENT'?></div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="<?=$editing?'edit':'add'?>">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="<?=$editing?'edit':'add'?>">
|
||||
<?php if($editing): ?><input type="hidden" name="id" value="<?=$editing['id']?>"><?php endif; ?>
|
||||
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($editing['title']??'')?>" required></div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:80px"><?=e($editing['description']??'')?></textarea></div>
|
||||
@@ -60,6 +80,19 @@ $events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.
|
||||
<div class="fg"><label class="flabel">COST</label><input type="text" name="cost" class="finput" value="<?=e($editing['cost']??'Free')?>"></div>
|
||||
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($editing['website']??'')?>"></div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel">EVENT IMAGE</label>
|
||||
<?php if(!empty($editing['image_path'])): ?>
|
||||
<div class="event-admin-image">
|
||||
<img src="<?=e($editing['image_path'])?>" alt="<?=e($editing['title'] ?? 'Event image')?>">
|
||||
<label style="display:flex;align-items:center;gap:.45rem;font-size:.85rem;color:var(--text-m);cursor:pointer">
|
||||
<input type="checkbox" name="remove_image" value="1"> Remove current image
|
||||
</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="event_image" class="finput" accept="image/*">
|
||||
<div class="fhint">Optional JPG, PNG, GIF, or WebP. Max <?=e(setting('event_upload_max_mb','5'))?> MB.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($editing['contact_name']??'')?>"></div>
|
||||
<div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($editing['contact_phone']??'')?>"></div>
|
||||
@@ -90,7 +123,7 @@ $events = qall("SELECT e.*,u.username FROM events e LEFT JOIN users u ON u.id=e.
|
||||
<tbody>
|
||||
<?php foreach($events as $ev): ?>
|
||||
<tr>
|
||||
<td class="td-name" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?=e($ev['title'])?></td>
|
||||
<td class="td-name" style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?=e($ev['title'])?><?=!empty($ev['image_path'])?' <span class="badge badge-blue">Image</span>':''?></td>
|
||||
<td style="font-size:.8rem"><?=fdate($ev['event_date'])?></td>
|
||||
<td style="font-size:.8rem"><?=e($ev['username']??'Admin')?></td>
|
||||
<td><?php $sc=['approved'=>'badge-green','pending'=>'badge-gold','rejected'=>'badge-red']; ?><span class="badge <?=$sc[$ev['status']]??"badge-gray"?>"><?=strtoupper($ev['status'])?></span></td>
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
requireAdmin();
|
||||
|
||||
function forumCategorySlugForAdmin(string $name, int $id = 0): string {
|
||||
$base = makeSlug($name) ?: 'category';
|
||||
$slug = $base;
|
||||
$i = 1;
|
||||
while (qval("SELECT id FROM forum_categories WHERE slug=? AND id!=?", [$slug, $id])) {
|
||||
$slug = $base.'-'.$i++;
|
||||
}
|
||||
return $slug;
|
||||
}
|
||||
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
$act = p('_act');
|
||||
|
||||
if ($act === 'settings') {
|
||||
setSetting('forum_enabled', p('forum_enabled','0') === '1' ? '1' : '0');
|
||||
setSetting('forum_upload_max_mb', (string)max(1, (float)p('forum_upload_max_mb','5')));
|
||||
$allowed = strtolower(ps('forum_allowed_filetypes'));
|
||||
$allowed = implode(',', uploadAllowedExts($allowed, ['jpg','jpeg','png','gif','webp','pdf','txt','doc','docx']));
|
||||
setSetting('forum_allowed_filetypes', $allowed);
|
||||
setSetting('forum_image_resize_threshold_mb', (string)max(0.5, (float)p('forum_image_resize_threshold_mb','1')));
|
||||
setSetting('forum_image_max_width', (string)max(640, min(3200, (int)p('forum_image_max_width','1600'))));
|
||||
setSetting('forum_image_quality', (string)max(40, min(95, (int)p('forum_image_quality','82'))));
|
||||
flash('Forum settings saved.', 'success');
|
||||
go('/admin/forum.php');
|
||||
}
|
||||
|
||||
if ($act === 'add_category') {
|
||||
$name = ps('name');
|
||||
if ($name !== '') {
|
||||
qrun("INSERT INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,?)",
|
||||
[$name, forumCategorySlugForAdmin($name), ps('description'), (int)p('sort_order',0), (int)p('is_active',1)]);
|
||||
flash('Category added.', 'success');
|
||||
}
|
||||
go('/admin/forum.php#categories');
|
||||
}
|
||||
|
||||
if ($act === 'edit_category') {
|
||||
$id = (int)p('id');
|
||||
$name = ps('name');
|
||||
if ($id && $name !== '') {
|
||||
qrun("UPDATE forum_categories SET name=?,slug=?,description=?,sort_order=?,is_active=? WHERE id=?",
|
||||
[$name, forumCategorySlugForAdmin($name, $id), ps('description'), (int)p('sort_order',0), (int)p('is_active',1), $id]);
|
||||
flash('Category updated.', 'success');
|
||||
}
|
||||
go('/admin/forum.php#categories');
|
||||
}
|
||||
|
||||
if ($act === 'delete_category') {
|
||||
$id = (int)p('id');
|
||||
$topicCount = (int)qval("SELECT COUNT(*) FROM forum_topics WHERE category_id=?", [$id]);
|
||||
if ($topicCount > 0) {
|
||||
qrun("UPDATE forum_categories SET is_active=0 WHERE id=?", [$id]);
|
||||
flash('Category has topics, so it was deactivated instead of deleted.', 'warning');
|
||||
} else {
|
||||
qrun("DELETE FROM forum_categories WHERE id=?", [$id]);
|
||||
flash('Category deleted.', 'success');
|
||||
}
|
||||
go('/admin/forum.php#categories');
|
||||
}
|
||||
|
||||
if (in_array($act, ['toggle_pin','toggle_lock','delete_topic'], true)) {
|
||||
$id = (int)p('id');
|
||||
if ($act === 'toggle_pin') {
|
||||
qrun("UPDATE forum_topics SET is_pinned=1-is_pinned WHERE id=?", [$id]);
|
||||
flash('Topic pin status updated.', 'success');
|
||||
} elseif ($act === 'toggle_lock') {
|
||||
qrun("UPDATE forum_topics SET is_locked=1-is_locked WHERE id=?", [$id]);
|
||||
flash('Topic lock status updated.', 'success');
|
||||
} else {
|
||||
qrun("DELETE FROM forum_topics WHERE id=?", [$id]);
|
||||
flash('Topic deleted.', 'success');
|
||||
}
|
||||
go('/admin/forum.php#topics');
|
||||
}
|
||||
}
|
||||
|
||||
$adminTitle = 'Forum';
|
||||
include __DIR__.'/admin_header.php';
|
||||
|
||||
$forumEnabled = setting('forum_enabled','1');
|
||||
$forumUploadMax = setting('forum_upload_max_mb','5');
|
||||
$forumAllowed = setting('forum_allowed_filetypes','jpg,jpeg,png,gif,webp,pdf,txt,doc,docx');
|
||||
$resizeThreshold = setting('forum_image_resize_threshold_mb','1');
|
||||
$resizeMaxWidth = setting('forum_image_max_width','1600');
|
||||
$resizeQuality = setting('forum_image_quality','82');
|
||||
$counts = [
|
||||
'Topics' => qval("SELECT COUNT(*) FROM forum_topics"),
|
||||
'Replies' => qval("SELECT COUNT(*) FROM forum_replies"),
|
||||
'Categories' => qval("SELECT COUNT(*) FROM forum_categories"),
|
||||
'Attachments' => qval("SELECT COUNT(*) FROM forum_attachments"),
|
||||
];
|
||||
$categories = qall("
|
||||
SELECT fc.*, COUNT(ft.id) topic_count
|
||||
FROM forum_categories fc
|
||||
LEFT JOIN forum_topics ft ON ft.category_id=fc.id
|
||||
GROUP BY fc.id
|
||||
ORDER BY fc.sort_order, fc.name
|
||||
");
|
||||
$topics = qall("
|
||||
SELECT ft.*, fc.name category_name, u.username,
|
||||
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) reply_count
|
||||
FROM forum_topics ft
|
||||
JOIN forum_categories fc ON fc.id=ft.category_id
|
||||
JOIN users u ON u.id=ft.user_id
|
||||
ORDER BY ft.is_pinned DESC, datetime(ft.updated_at) DESC
|
||||
LIMIT 60
|
||||
");
|
||||
?>
|
||||
<?php adminTitle('Forum Controls', 'Enable the forum, manage upload rules, categories, and recent topics.') ?>
|
||||
|
||||
<div class="stat-cards">
|
||||
<?php foreach($counts as $label=>$value): ?>
|
||||
<div class="stat-card"><div class="sc-n"><?=$value?></div><div class="sc-l"><?=e($label)?></div></div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="g2" style="gap:1.5rem">
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">FORUM SETTINGS</div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
|
||||
<div class="fg">
|
||||
<label class="flabel">FORUM VISIBILITY</label>
|
||||
<div style="display:flex;gap:1rem;flex-wrap:wrap">
|
||||
<label style="display:flex;gap:.45rem;align-items:center;color:var(--green-l);cursor:pointer"><input type="radio" name="forum_enabled" value="1"<?=$forumEnabled==='1'?' checked':''?>> Enabled for members</label>
|
||||
<label style="display:flex;gap:.45rem;align-items:center;color:var(--red-l);cursor:pointer"><input type="radio" name="forum_enabled" value="0"<?=$forumEnabled==='0'?' checked':''?>> Disabled for non-admins</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">FILE MAX MB</label><input type="number" name="forum_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($forumUploadMax)?>"></div>
|
||||
<div class="fg"><label class="flabel">ALLOWED FILE TYPES</label><input type="text" name="forum_allowed_filetypes" class="finput" value="<?=e($forumAllowed)?>"><div class="fhint">Comma-separated extensions.</div></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">RESIZE IMAGES OVER MB</label><input type="number" name="forum_image_resize_threshold_mb" class="finput" min="0.5" step="0.5" value="<?=e($resizeThreshold)?>"></div>
|
||||
<div class="fg"><label class="flabel">MAX IMAGE WIDTH</label><input type="number" name="forum_image_max_width" class="finput" min="640" max="3200" step="80" value="<?=e($resizeMaxWidth)?>"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">IMAGE QUALITY</label><input type="number" name="forum_image_quality" class="finput" min="40" max="95" value="<?=e($resizeQuality)?>"></div>
|
||||
<button class="btn btn-primary">Save Forum Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ibox" id="categories" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">ADD CATEGORY</div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_category">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">NAME</label><input type="text" name="name" class="finput" required></div>
|
||||
<div class="fg"><label class="flabel">SORT ORDER</label><input type="number" name="sort_order" class="finput" value="0"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="description" class="finput"></div>
|
||||
<div class="fg"><label class="flabel">STATUS</label><select name="is_active" class="fselect"><option value="1">Active</option><option value="0">Inactive</option></select></div>
|
||||
<button class="btn btn-primary btn-sm">Add Category</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-sec-title" style="margin-top:1.5rem">CATEGORIES</div>
|
||||
<div class="tbl-wrap" style="margin-bottom:2rem">
|
||||
<table class="dtbl">
|
||||
<thead><tr><th>Name</th><th>Description</th><th>Topics</th><th>Status</th><th>Order</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($categories as $cat): ?>
|
||||
<tr>
|
||||
<td class="td-name"><?=e($cat['name'])?></td>
|
||||
<td style="font-size:.8rem"><?=e($cat['description'])?></td>
|
||||
<td><?=$cat['topic_count']?></td>
|
||||
<td><?=$cat['is_active']?'<span class="badge badge-green">Active</span>':'<span class="badge badge-gray">Inactive</span>'?></td>
|
||||
<td><?=$cat['sort_order']?></td>
|
||||
<td>
|
||||
<details>
|
||||
<summary class="btn btn-xs btn-outline" style="display:inline-flex">Edit</summary>
|
||||
<form method="POST" class="admin-inline-editor">
|
||||
<?=csrfField()?><input type="hidden" name="_act" value="edit_category"><input type="hidden" name="id" value="<?=$cat['id']?>">
|
||||
<div class="form-row">
|
||||
<input type="text" name="name" class="finput" value="<?=e($cat['name'])?>" required>
|
||||
<input type="number" name="sort_order" class="finput" value="<?=$cat['sort_order']?>">
|
||||
</div>
|
||||
<input type="text" name="description" class="finput" value="<?=e($cat['description'])?>">
|
||||
<select name="is_active" class="fselect"><option value="1"<?=$cat['is_active']?' selected':''?>>Active</option><option value="0"<?=!$cat['is_active']?' selected':''?>>Inactive</option></select>
|
||||
<button class="btn btn-success btn-xs">Save</button>
|
||||
</form>
|
||||
</details>
|
||||
<form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="delete_category"><input type="hidden" name="id" value="<?=$cat['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this category?">Delete</button></form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-sec-title" id="topics">RECENT TOPICS</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="dtbl">
|
||||
<thead><tr><th>Topic</th><th>Category</th><th>By</th><th>Replies</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($topics as $topic): ?>
|
||||
<tr>
|
||||
<td><a class="td-name" href="<?=e(forumTopicUrl($topic))?>" target="_blank"><?=e($topic['title'])?></a><div style="font-size:.75rem;color:var(--text-d)"><?=ago($topic['updated_at'])?></div></td>
|
||||
<td><?=e($topic['category_name'])?></td>
|
||||
<td><?=e($topic['username'])?></td>
|
||||
<td><?=$topic['reply_count']?></td>
|
||||
<td>
|
||||
<?php if($topic['is_pinned']): ?><span class="badge badge-gold">Pinned</span><?php endif; ?>
|
||||
<?php if($topic['is_locked']): ?><span class="badge badge-gray">Locked</span><?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex-row" style="gap:.3rem">
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="toggle_pin"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-outline"><?=$topic['is_pinned']?'Unpin':'Pin'?></button></form>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="toggle_lock"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-secondary"><?=$topic['is_locked']?'Unlock':'Lock'?></button></form>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="delete_topic"><input type="hidden" name="id" value="<?=$topic['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete this topic and replies?">Delete</button></form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php if(!$topics): ?><tr><td colspan="6" style="text-align:center;color:var(--text-d)">No forum topics yet.</td></tr><?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__.'/admin_footer.php'; ?>
|
||||
@@ -9,6 +9,7 @@ $stats = [
|
||||
'Pending Events' => qval("SELECT COUNT(*) FROM events WHERE status='pending'"),
|
||||
'Pending Claims' => qval("SELECT COUNT(*) FROM business_claim_requests WHERE status='pending'"),
|
||||
'Pending Edits' => qval("SELECT COUNT(*) FROM business_edit_requests WHERE status='pending'"),
|
||||
'Forum Topics' => qval("SELECT COUNT(*) FROM forum_topics"),
|
||||
'Open Reports' => qval("SELECT COUNT(*) FROM reports WHERE status='open'"),
|
||||
'Active Alerts' => qval("SELECT COUNT(*) FROM alerts WHERE is_active=1"),
|
||||
];
|
||||
@@ -34,6 +35,15 @@ $pendingEdits = qall("
|
||||
ORDER BY ber.created_at DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$recentForumTopics = qall("
|
||||
SELECT ft.*, fc.name category_name, u.username,
|
||||
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) reply_count
|
||||
FROM forum_topics ft
|
||||
JOIN forum_categories fc ON fc.id=ft.category_id
|
||||
JOIN users u ON u.id=ft.user_id
|
||||
ORDER BY datetime(ft.updated_at) DESC
|
||||
LIMIT 5
|
||||
");
|
||||
?>
|
||||
<?php adminTitle('Site Overview','Welcome to the Discover Keyser WV administration panel.') ?>
|
||||
|
||||
@@ -156,6 +166,28 @@ $pendingEdits = qall("
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if($recentForumTopics): ?>
|
||||
<div style="margin-bottom:2rem">
|
||||
<div class="admin-sec-title">FORUM ACTIVITY</div>
|
||||
<div class="tbl-wrap">
|
||||
<table class="dtbl">
|
||||
<thead><tr><th>TOPIC</th><th>CATEGORY</th><th>BY</th><th>REPLIES</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($recentForumTopics as $topic): ?>
|
||||
<tr>
|
||||
<td><a href="<?=e(forumTopicUrl($topic))?>" target="_blank" class="td-name"><?=e($topic['title'])?></a><div style="font-size:.75rem;color:var(--text-d)"><?=ago($topic['updated_at'])?></div></td>
|
||||
<td><?=e($topic['category_name'])?></td>
|
||||
<td><?=e($topic['username'])?></td>
|
||||
<td><?=$topic['reply_count']?></td>
|
||||
<td><a href="/admin/forum.php#topics" class="btn btn-xs btn-primary">Moderate</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="g2">
|
||||
<div>
|
||||
<div class="admin-sec-title">🏢 RECENT BUSINESSES</div>
|
||||
|
||||
+62
-6
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
requireAdmin();
|
||||
$adminTitle = 'Menus';
|
||||
include __DIR__.'/admin_header.php';
|
||||
|
||||
$bizId = iget('biz_id');
|
||||
$biz = $bizId ? qone("SELECT * FROM businesses WHERE id=?",[$bizId]) : null;
|
||||
$menuImagesEnabled = max(0, min(1, (int)setting('menu_item_image_limit','1'))) > 0;
|
||||
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
@@ -31,15 +33,51 @@ if (isPost()) {
|
||||
$sid = (int)p('section_id');
|
||||
$name = ps('item_name');
|
||||
if ($sid && $name) {
|
||||
$imagePath = '';
|
||||
if ($menuImagesEnabled) {
|
||||
$upload = storeSingleUpload(
|
||||
'item_image',
|
||||
'menus',
|
||||
imageExts(),
|
||||
mbToBytes(setting('menu_upload_max_mb','5'), 5),
|
||||
[
|
||||
'image_only' => true,
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width','1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if (!$upload['ok']) { flash($upload['error'], 'error'); go("/admin/menus.php?biz_id=$bizId"); }
|
||||
$imagePath = (string)($upload['path'] ?? '');
|
||||
}
|
||||
$ord = (int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]);
|
||||
qrun("INSERT INTO menu_items(section_id,name,description,price,is_available,sort_order)VALUES(?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),$ord]);
|
||||
qrun("INSERT INTO menu_items(section_id,name,description,price,image_path,is_available,sort_order)VALUES(?,?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),$ord]);
|
||||
flash('Item added!','success');
|
||||
}
|
||||
go("/admin/menus.php?biz_id=$bizId");
|
||||
}
|
||||
if ($act === 'edit_item') {
|
||||
$iid = (int)p('iid');
|
||||
qrun("UPDATE menu_items SET name=?,description=?,price=?,is_available=?,sort_order=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),(int)p('item_order'),$iid]);
|
||||
$item = qone("SELECT * FROM menu_items WHERE id=?", [$iid]);
|
||||
$imagePath = (string)($item['image_path'] ?? '');
|
||||
if ($menuImagesEnabled && p('remove_item_image','0') === '1') $imagePath = '';
|
||||
if ($menuImagesEnabled) {
|
||||
$upload = storeSingleUpload(
|
||||
'item_image',
|
||||
'menus',
|
||||
imageExts(),
|
||||
mbToBytes(setting('menu_upload_max_mb','5'), 5),
|
||||
[
|
||||
'image_only' => true,
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width','1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if (!$upload['ok']) { flash($upload['error'], 'error'); go("/admin/menus.php?biz_id=$bizId"); }
|
||||
if (!empty($upload['path'])) $imagePath = (string)$upload['path'];
|
||||
}
|
||||
qrun("UPDATE menu_items SET name=?,description=?,price=?,image_path=?,is_available=?,sort_order=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),(int)p('item_order'),$iid]);
|
||||
flash('Item updated!','success'); go("/admin/menus.php?biz_id=$bizId");
|
||||
}
|
||||
if ($act === 'del_item') {
|
||||
@@ -55,6 +93,7 @@ if ($biz) {
|
||||
$items = [];
|
||||
foreach ($sections as $s) $items[$s['id']] = qall("SELECT * FROM menu_items WHERE section_id=? ORDER BY sort_order",[$s['id']]);
|
||||
}
|
||||
include __DIR__.'/admin_header.php';
|
||||
?>
|
||||
<?php adminTitle('Manage Menus','Edit menus, sections, and items for any business.') ?>
|
||||
|
||||
@@ -117,11 +156,12 @@ if ($biz) {
|
||||
<?php if($items[$sec['id']]): ?>
|
||||
<div class="tbl-wrap" style="margin-bottom:.85rem">
|
||||
<table class="dtbl">
|
||||
<thead><tr><th>ITEM NAME</th><th>DESCRIPTION</th><th>PRICE</th><th>AVAIL.</th><th>ORDER</th><th></th></tr></thead>
|
||||
<thead><tr><th>ITEM NAME</th><th>IMAGE</th><th>DESCRIPTION</th><th>PRICE</th><th>AVAIL.</th><th>ORDER</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach($items[$sec['id']] as $item): ?>
|
||||
<tr>
|
||||
<td class="td-name"><?=e($item['name'])?></td>
|
||||
<td><?php if(!empty($item['image_path'])): ?><img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>" class="admin-menu-thumb"><?php else: ?>—<?php endif; ?></td>
|
||||
<td style="font-size:.8rem;max-width:180px"><?=e(substr($item['description'],0,80))?></td>
|
||||
<td style="color:var(--gold);font-family:'Oswald',sans-serif"><?=$item['price']>0?money($item['price']):'—'?></td>
|
||||
<td><?=$item['is_available']?'<span class="badge badge-green">Yes</span>':'<span class="badge badge-gray">No</span>'?></td>
|
||||
@@ -132,12 +172,25 @@ if ($biz) {
|
||||
<form method="POST" style="display:inline"><?=csrfField()?><input type="hidden" name="_act" value="del_item"><input type="hidden" name="iid" value="<?=$item['id']?>"><button class="btn btn-xs btn-danger" data-confirm="Delete item?">🗑️</button></form>
|
||||
</div>
|
||||
<div id="eif<?=$item['id']?>" class="hidden" style="background:var(--bg2);border:1px solid var(--bdr);border-radius:4px;padding:.75rem;margin-top:.4rem;min-width:320px">
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">NAME *</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div>
|
||||
<div class="fg"><label class="flabel">PRICE</label><input type="number" name="item_price" class="finput" value="<?=$item['price']?>" step="0.01" min="0"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div>
|
||||
<?php if($menuImagesEnabled): ?>
|
||||
<div class="fg">
|
||||
<label class="flabel">ITEM IMAGE</label>
|
||||
<?php if(!empty($item['image_path'])): ?>
|
||||
<div class="menu-admin-image">
|
||||
<img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>">
|
||||
<label style="display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--text-m);cursor:pointer"><input type="checkbox" name="remove_item_image" value="1"> Remove current image</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="item_image" class="finput" accept="image/*">
|
||||
<div class="fhint">One image allowed. Max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No</option></select></div>
|
||||
<div class="fg"><label class="flabel">SORT ORDER</label><input type="number" name="item_order" class="finput" value="<?=$item['sort_order']?>"></div>
|
||||
@@ -156,12 +209,15 @@ if ($biz) {
|
||||
<!-- Add item -->
|
||||
<details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em">➕ Add Item to "<?=e($sec['name'])?>"</summary>
|
||||
<div style="padding:.75rem 0">
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div>
|
||||
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div>
|
||||
<?php if($menuImagesEnabled): ?>
|
||||
<div class="fg"><label class="flabel">ITEM IMAGE</label><input type="file" name="item_image" class="finput" accept="image/*"><div class="fhint">Optional. One image allowed, max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div></div>
|
||||
<?php endif; ?>
|
||||
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1">Yes</option><option value="0">No</option></select></div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add Item</button>
|
||||
</form>
|
||||
|
||||
+55
-1
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
requireAdmin();
|
||||
$adminTitle = 'Site Settings';
|
||||
include __DIR__.'/admin_header.php';
|
||||
|
||||
if (isPost()) {
|
||||
csrfCheck();
|
||||
@@ -13,6 +14,14 @@ if (isPost()) {
|
||||
setSetting('registration_enabled',p('registration_enabled','0'));
|
||||
setSetting('events_require_approval',p('events_require_approval','0'));
|
||||
setSetting('home_random_interval',p('home_random_interval','2hours') === 'day' ? 'day' : '2hours');
|
||||
if (array_key_exists('event_upload_max_mb', $_POST)) {
|
||||
setSetting('event_upload_max_mb', (string)max(1, (float)p('event_upload_max_mb','5')));
|
||||
setSetting('menu_item_image_limit', (string)max(0, min(1, (int)p('menu_item_image_limit','1'))));
|
||||
setSetting('menu_upload_max_mb', (string)max(1, (float)p('menu_upload_max_mb','5')));
|
||||
setSetting('forum_image_resize_threshold_mb', (string)max(0.5, (float)p('forum_image_resize_threshold_mb','1')));
|
||||
setSetting('forum_image_max_width', (string)max(640, min(3200, (int)p('forum_image_max_width','1600'))));
|
||||
setSetting('forum_image_quality', (string)max(40, min(95, (int)p('forum_image_quality','82'))));
|
||||
}
|
||||
if (array_key_exists('mailgun_domain', $_POST)) {
|
||||
setSetting('site_base_url', rtrim(ps('site_base_url'), '/'));
|
||||
setSetting('mailgun_region', p('mailgun_region','us') === 'eu' ? 'eu' : 'us');
|
||||
@@ -55,7 +64,14 @@ $mailgunFromName = setting('mailgun_from_name','Discover Keyser WV');
|
||||
$mailgunFromEmail = setting('mailgun_from_email','');
|
||||
$mailgunSendMode = setting('mailgun_send_mode','html');
|
||||
$mailgunHasKey = setting('mailgun_api_key','') !== '';
|
||||
$eventUploadMax = setting('event_upload_max_mb','5');
|
||||
$menuImageLimit = setting('menu_item_image_limit','1');
|
||||
$menuUploadMax = setting('menu_upload_max_mb','5');
|
||||
$resizeThreshold = setting('forum_image_resize_threshold_mb','1');
|
||||
$resizeMaxWidth = setting('forum_image_max_width','1600');
|
||||
$resizeQuality = setting('forum_image_quality','82');
|
||||
$me = qone("SELECT * FROM users WHERE id=?",[uid()]);
|
||||
include __DIR__.'/admin_header.php';
|
||||
?>
|
||||
<?php adminTitle('Site Settings') ?>
|
||||
|
||||
@@ -119,6 +135,38 @@ $me = qone("SELECT * FROM users WHERE id=?",[uid()]);
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">🖼️ UPLOAD & IMAGE SETTINGS</div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
|
||||
<input type="hidden" name="site_name" value="<?=e($siteName)?>">
|
||||
<input type="hidden" name="site_tagline" value="<?=e($siteTagline)?>">
|
||||
<input type="hidden" name="contact_email" value="<?=e($contactEmail)?>">
|
||||
<input type="hidden" name="registration_enabled" value="<?=e($regEnabled)?>">
|
||||
<input type="hidden" name="events_require_approval" value="<?=e($evtApproval)?>">
|
||||
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">EVENT IMAGE MAX MB</label><input type="number" name="event_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($eventUploadMax)?>"></div>
|
||||
<div class="fg"><label class="flabel">MENU IMAGE MAX MB</label><input type="number" name="menu_upload_max_mb" class="finput" min="1" step="0.5" value="<?=e($menuUploadMax)?>"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg">
|
||||
<label class="flabel">MENU ITEM IMAGE LIMIT</label>
|
||||
<select name="menu_item_image_limit" class="fselect">
|
||||
<option value="1"<?=$menuImageLimit==='1'?' selected':''?>>One image per item</option>
|
||||
<option value="0"<?=$menuImageLimit==='0'?' selected':''?>>No menu item images</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="divider" style="margin:1rem 0">
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">RESIZE IMAGES OVER MB</label><input type="number" name="forum_image_resize_threshold_mb" class="finput" min="0.5" step="0.5" value="<?=e($resizeThreshold)?>"></div>
|
||||
<div class="fg"><label class="flabel">MAX IMAGE WIDTH</label><input type="number" name="forum_image_max_width" class="finput" min="640" max="3200" step="80" value="<?=e($resizeMaxWidth)?>"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">IMAGE QUALITY</label><input type="number" name="forum_image_quality" class="finput" min="40" max="95" value="<?=e($resizeQuality)?>"><div class="fhint">Used when large uploaded images are re-saved.</div></div>
|
||||
<button type="submit" class="btn btn-primary">Save Upload Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">✉️ EMAIL VERIFICATION & MAILGUN</div>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="settings">
|
||||
@@ -128,6 +176,12 @@ $me = qone("SELECT * FROM users WHERE id=?",[uid()]);
|
||||
<input type="hidden" name="registration_enabled" value="<?=e($regEnabled)?>">
|
||||
<input type="hidden" name="events_require_approval" value="<?=e($evtApproval)?>">
|
||||
<input type="hidden" name="home_random_interval" value="<?=e($homeRandomInterval)?>">
|
||||
<input type="hidden" name="event_upload_max_mb" value="<?=e($eventUploadMax)?>">
|
||||
<input type="hidden" name="menu_item_image_limit" value="<?=e($menuImageLimit)?>">
|
||||
<input type="hidden" name="menu_upload_max_mb" value="<?=e($menuUploadMax)?>">
|
||||
<input type="hidden" name="forum_image_resize_threshold_mb" value="<?=e($resizeThreshold)?>">
|
||||
<input type="hidden" name="forum_image_max_width" value="<?=e($resizeMaxWidth)?>">
|
||||
<input type="hidden" name="forum_image_quality" value="<?=e($resizeQuality)?>">
|
||||
|
||||
<div class="fg">
|
||||
<label class="flabel">SITE BASE URL</label>
|
||||
|
||||
+58
-2
@@ -346,6 +346,8 @@ address{font-style:normal}
|
||||
/* ── Events ─────────────────────────────────────────────── */
|
||||
.evt-card{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.5rem;display:flex;gap:1.5rem;transition:all .22s;margin-bottom:1.1rem}
|
||||
.evt-card:hover{border-color:var(--gold-d);box-shadow:var(--sh-sm)}
|
||||
.evt-link-card{color:inherit;text-decoration:none}
|
||||
.evt-link-card:hover{color:inherit;transform:translateY(-1px)}
|
||||
.evt-date-box{flex-shrink:0;text-align:center;background:var(--bg4);border:1px solid var(--bdr-l);border-radius:var(--r);padding:.65rem .85rem;min-width:64px}
|
||||
.evt-mon{font-family:'Oswald',sans-serif;font-size:.68rem;letter-spacing:.18em;color:var(--gold)}
|
||||
.evt-day{font-family:'Playfair Display',serif;font-size:2rem;color:var(--text);line-height:1}
|
||||
@@ -356,6 +358,25 @@ address{font-style:normal}
|
||||
.evt-meta span{display:flex;align-items:center;gap:.3rem}
|
||||
.evt-desc{font-size:.88rem;color:var(--text-m);line-height:1.62}
|
||||
.evt-actions{margin-top:.75rem;display:flex;gap:.5rem;flex-wrap:wrap}
|
||||
.evt-image-wrap{width:180px;min-height:126px;align-self:stretch;flex:0 0 180px;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4)}
|
||||
.evt-image{width:100%;height:100%;object-fit:cover}
|
||||
.event-hero-photo{padding:0;overflow:hidden}
|
||||
.event-photo-btn{display:block;position:relative;width:100%;border:0;background:var(--bg4);padding:0;color:var(--text);text-align:left}
|
||||
.event-photo-btn img{width:100%;max-height:560px;object-fit:contain;background:#000}
|
||||
.event-photo-btn span{position:absolute;right:1rem;bottom:1rem;background:rgba(3,5,11,.82);border:1px solid rgba(255,209,102,.36);border-radius:var(--r);padding:.45rem .75rem;font-family:'Oswald',sans-serif;font-size:.74rem;letter-spacing:.1em;color:var(--gold)}
|
||||
.event-detail-date{display:flex;gap:.85rem;align-items:center;padding-bottom:.85rem;margin-bottom:.5rem;border-bottom:1px solid var(--bdr)}
|
||||
.lightbox{position:fixed;inset:0;z-index:5000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.88);padding:1.5rem}
|
||||
.lightbox.open{display:flex}
|
||||
.lightbox img{max-width:min(1200px,96vw);max-height:88vh;object-fit:contain;border-radius:var(--r);box-shadow:var(--sh)}
|
||||
.lightbox-close{position:absolute;right:1rem;top:1rem;width:42px;height:42px;border:1px solid rgba(255,255,255,.25);border-radius:999px;background:rgba(3,5,11,.75);color:#fff;font-size:1.6rem;line-height:1}
|
||||
.event-strip .evt-card{display:grid;grid-template-columns:auto 1fr;align-items:start}
|
||||
.event-strip .evt-image-wrap{grid-column:1/-1;width:100%;height:150px;min-height:150px}
|
||||
.event-admin-image,.menu-admin-image{display:flex;align-items:center;gap:.85rem;margin-bottom:.65rem;flex-wrap:wrap}
|
||||
.event-admin-image img{width:150px;height:88px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
|
||||
.menu-admin-image img,.admin-menu-thumb{width:72px;height:72px;object-fit:cover;border-radius:var(--r);border:1px solid var(--bdr-l)}
|
||||
.event-import-grid{display:grid;grid-template-columns:180px minmax(0,1fr);gap:1rem;align-items:start}
|
||||
.event-import-preview{width:180px;height:108px;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--text-d);font-size:.8rem}
|
||||
.event-import-preview img{width:100%;height:100%;object-fit:cover}
|
||||
|
||||
/* ── About / Facts ──────────────────────────────────────── */
|
||||
.fact-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:1.1rem}
|
||||
@@ -409,6 +430,41 @@ address{font-style:normal}
|
||||
.text-gold{color:var(--gold)}.text-muted{color:var(--text-m)}.text-dim{color:var(--text-d)}
|
||||
.text-right{text-align:right}.text-center{text-align:center}
|
||||
|
||||
/* ── Reviews ───────────────────────────────────────────── */
|
||||
.rev-user{display:flex;align-items:center;gap:.7rem}
|
||||
|
||||
/* ── Menu images ────────────────────────────────────────── */
|
||||
.menu-item{gap:.9rem}
|
||||
.mi-image{width:86px;height:86px;object-fit:cover;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);flex-shrink:0}
|
||||
.menu-owner-row{display:flex;gap:.75rem;align-items:flex-start;min-width:0}
|
||||
.menu-owner-thumb{width:58px;height:58px;object-fit:cover;border-radius:var(--r);border:1px solid rgba(38,244,255,.22);flex-shrink:0}
|
||||
|
||||
/* ── Forum ──────────────────────────────────────────────── */
|
||||
.forum-layout{display:grid;grid-template-columns:minmax(0,1fr) 380px;gap:2rem;align-items:start}
|
||||
.forum-topic-list{display:grid;gap:.8rem}
|
||||
.forum-topic-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;padding:1rem;background:linear-gradient(180deg,rgba(18,31,56,.86),rgba(9,15,30,.92));border:1px solid rgba(38,244,255,.16);border-radius:var(--r);color:var(--text);transition:all var(--t)}
|
||||
.forum-topic-row:hover{border-color:rgba(38,244,255,.44);box-shadow:var(--sh-sm);transform:translateY(-1px)}
|
||||
.forum-topic-main{min-width:0}
|
||||
.forum-topic-main strong{display:block;color:var(--text);font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.forum-topic-main em{display:block;color:var(--text-d);font-style:normal;font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.forum-topic-count{font-family:'Oswald',sans-serif;letter-spacing:.08em;font-size:.74rem;color:var(--cyan-l);white-space:nowrap}
|
||||
.forum-byline{display:flex;align-items:center;gap:.45rem;color:var(--text-d);font-size:.86rem;flex-wrap:wrap}
|
||||
.forum-thread{max-width:980px}
|
||||
.forum-post,.forum-reply{background:var(--bg3);border:1px solid var(--bdr);border-radius:var(--r);padding:1.25rem;margin-bottom:1rem}
|
||||
.forum-post-body{color:var(--text-m);line-height:1.75;overflow-wrap:anywhere}
|
||||
.forum-attachments{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:.75rem;margin-top:1rem}
|
||||
.forum-attachment-image{display:block;overflow:hidden;border-radius:var(--r);border:1px solid rgba(38,244,255,.2);background:var(--bg4);aspect-ratio:4/3}
|
||||
.forum-attachment-image img{width:100%;height:100%;object-fit:cover;transition:transform var(--t)}
|
||||
.forum-attachment-image:hover img{transform:scale(1.03)}
|
||||
.forum-file{display:flex;justify-content:space-between;gap:.65rem;align-items:center;padding:.75rem .9rem;border:1px solid rgba(38,244,255,.18);border-radius:var(--r);background:rgba(18,31,56,.75);color:var(--text-m);min-width:0}
|
||||
.forum-file span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.forum-file em{font-style:normal;color:var(--text-d);font-size:.74rem;white-space:nowrap}
|
||||
.forum-replies-head{margin:2rem 0 1rem}
|
||||
.forum-reply-meta{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-bottom:.8rem;flex-wrap:wrap}
|
||||
.forum-reply-author{display:flex;align-items:center;gap:.65rem;font-family:'Oswald',sans-serif;letter-spacing:.06em;color:var(--text)}
|
||||
.forum-permalink{font-family:'Oswald',sans-serif;font-size:.72rem;letter-spacing:.08em;color:var(--cyan-l)}
|
||||
.admin-inline-editor{display:grid;gap:.45rem;min-width:320px;background:var(--bg2);border:1px solid var(--bdr);border-radius:var(--r);padding:.75rem;margin-top:.5rem}
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────── */
|
||||
@keyframes fadeUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}
|
||||
@keyframes fadeDown{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
|
||||
@@ -416,13 +472,13 @@ address{font-style:normal}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
@media(max-width:1100px){.footer-grid{grid-template-columns:1fr 1fr}.home-hero-grid{grid-template-columns:1fr}.hero-signal{grid-template-columns:1fr 1fr}.signal-card-main{min-height:auto}.about-split{grid-template-columns:1fr}}
|
||||
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}}
|
||||
@media(max-width:900px){.detail-body{grid-template-columns:1fr}.admin-wrap{grid-template-columns:1fr}.admin-side{position:static;max-height:none;border-right:none;border-bottom:1px solid var(--bdr)}.form-row{grid-template-columns:1fr}.section-head{align-items:flex-start;flex-direction:column}.random-list{grid-template-columns:1fr}.signup-grid,.tier-grid{grid-template-columns:1fr}.home-facts{grid-template-columns:1fr}.forum-layout{grid-template-columns:1fr}.evt-image-wrap{width:140px;flex-basis:140px}.event-import-grid{grid-template-columns:1fr}.event-import-preview{width:100%;height:180px}}
|
||||
@media(max-width:768px){
|
||||
.nav-links{display:none;position:absolute;top:64px;left:0;right:0;background:var(--bg0);border-bottom:1px solid var(--bdr-l);flex-direction:column;padding:.75rem 1rem;gap:.1rem;box-shadow:var(--sh)}
|
||||
.nav-links.open{display:flex}.hamburger{display:flex}
|
||||
.user-wrap{width:100%}.user-btn{width:100%;text-align:left}
|
||||
.user-dd{position:static;box-shadow:none;border:none;background:var(--bg2)}
|
||||
.hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column}
|
||||
.hero{min-height:80vh}.g3,.g4{grid-template-columns:1fr}.evt-card{flex-direction:column}.evt-image-wrap{width:100%;height:190px;flex-basis:auto}.event-strip .evt-card{display:flex}.event-strip .evt-image-wrap{height:190px}
|
||||
.home-hero{min-height:auto;padding:4rem 1rem 3rem}.home-hero .hero-h1{font-size:clamp(3rem,17vw,4.6rem)}.hero-search{flex-direction:column}.hero-search .s-icon{top:1.35rem}.hero-search .btn{justify-content:center}.hero-signal,.signal-mini-grid{grid-template-columns:1fr}.join-band{align-items:flex-start;flex-direction:column;margin-bottom:2.5rem}.signup-shell{padding:3rem 1rem}.signup-card{padding:1rem}.tier-card{min-height:auto}
|
||||
}
|
||||
@media(max-width:560px){.hero-h1{font-size:3rem}.footer-grid{grid-template-columns:1fr}.hrs-grid{grid-template-columns:1fr}.form-box{margin:2rem 1rem}.random-row{grid-template-columns:36px 1fr}.random-arrow{display:none}.home-hero .hero-eyebrow{letter-spacing:.18em}.btn{white-space:normal;text-align:center}.signal-number{font-size:3.6rem}}
|
||||
|
||||
@@ -29,3 +29,42 @@ document.querySelectorAll('.asl').forEach(l=>{
|
||||
l.classList.add('on');
|
||||
if(location.pathname===href)l.classList.add('on');
|
||||
});
|
||||
|
||||
// Event photo lightbox
|
||||
const lightboxTriggers=document.querySelectorAll('[data-lightbox-src]');
|
||||
let lightbox;
|
||||
const closeLightbox=()=>{
|
||||
lightbox?.classList.remove('open');
|
||||
document.body.style.overflow='';
|
||||
};
|
||||
if(lightboxTriggers.length){
|
||||
lightbox=document.createElement('div');
|
||||
lightbox.className='lightbox';
|
||||
lightbox.innerHTML='<button type="button" class="lightbox-close" aria-label="Close image">×</button><img alt="">';
|
||||
document.body.appendChild(lightbox);
|
||||
const img=lightbox.querySelector('img');
|
||||
lightboxTriggers.forEach(btn=>{
|
||||
btn.addEventListener('click',()=>{
|
||||
img.src=btn.dataset.lightboxSrc;
|
||||
img.alt=btn.dataset.lightboxAlt||'Event photo';
|
||||
lightbox.classList.add('open');
|
||||
document.body.style.overflow='hidden';
|
||||
});
|
||||
});
|
||||
lightbox.addEventListener('click',e=>{if(e.target===lightbox||e.target.closest('.lightbox-close'))closeLightbox();});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeLightbox();});
|
||||
}
|
||||
|
||||
// Copy share links
|
||||
document.querySelectorAll('[data-copy-url]').forEach(btn=>{
|
||||
btn.addEventListener('click',async()=>{
|
||||
const label=btn.textContent;
|
||||
try{
|
||||
await navigator.clipboard.writeText(btn.dataset.copyUrl);
|
||||
btn.textContent='Copied';
|
||||
setTimeout(()=>btn.textContent=label,1400);
|
||||
}catch{
|
||||
prompt('Copy this link:',btn.dataset.copyUrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+8
-3
@@ -403,6 +403,9 @@ include __DIR__.'/includes/header.php';
|
||||
<?php endif; ?>
|
||||
<?php foreach ($mitems[$s['id']] as $mi): ?>
|
||||
<div class="menu-item">
|
||||
<?php if (!empty($mi['image_path'])): ?>
|
||||
<img src="<?=e($mi['image_path'])?>" alt="<?=e($mi['name'] ?? 'Menu item')?>" class="mi-image">
|
||||
<?php endif; ?>
|
||||
<div class="mi-info">
|
||||
<div class="mi-name"><?=e($mi['name'] ?? '')?></div>
|
||||
<?php if (!empty($mi['description'])): ?>
|
||||
@@ -461,9 +464,11 @@ include __DIR__.'/includes/header.php';
|
||||
<?php foreach ($reviews as $r): ?>
|
||||
<div class="rev-card">
|
||||
<div class="rev-hdr">
|
||||
<div>
|
||||
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
|
||||
<?=starDisplay((float)($r['rating'] ?? 0))?>
|
||||
<div class="rev-user">
|
||||
<div>
|
||||
<div class="rev-author"><?=e($r['username'] ?? 'Anonymous')?></div>
|
||||
<?=starDisplay((float)($r['rating'] ?? 0))?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-row" style="gap:.5rem">
|
||||
<span class="rev-date"><?=ago($r['created_at'] ?? '')?></span>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
require_once __DIR__.'/includes/boot.php';
|
||||
|
||||
$id = iget('id');
|
||||
$event = qone("
|
||||
SELECT e.*, u.username AS submitter
|
||||
FROM events e
|
||||
LEFT JOIN users u ON u.id = e.submitted_by
|
||||
WHERE e.id = ? AND (e.status = 'approved' OR ? = 1)
|
||||
", [$id, isAdmin() ? 1 : 0]);
|
||||
|
||||
if (!$event) {
|
||||
flash('Event not found.', 'error');
|
||||
go('/events.php');
|
||||
}
|
||||
|
||||
$activeNav = 'events';
|
||||
$pageTitle = e($event['title']).' - Events in Keyser, WV';
|
||||
|
||||
$dateLabel = fdate($event['event_date'], 'l, F j, Y');
|
||||
$endDateLabel = !empty($event['end_date']) ? fdate($event['end_date'], 'l, F j, Y') : '';
|
||||
$timeLabel = trim((string)($event['event_time'] ?? ''));
|
||||
$endTimeLabel = trim((string)($event['end_time'] ?? ''));
|
||||
$dateRange = $dateLabel;
|
||||
if ($endDateLabel && $endDateLabel !== $dateLabel) $dateRange .= ' through '.$endDateLabel;
|
||||
if ($timeLabel !== '') $dateRange .= ' at '.$timeLabel;
|
||||
if ($endTimeLabel !== '') $dateRange .= ' - '.$endTimeLabel;
|
||||
|
||||
include __DIR__.'/includes/header.php';
|
||||
?>
|
||||
<div class="detail-hdr">
|
||||
<div class="detail-hdr-inner">
|
||||
<div class="breadcrumb">
|
||||
<a href="/index.php">Home</a><span>›</span>
|
||||
<a href="/events.php">Events</a><span>›</span>
|
||||
<?=e($event['title'])?>
|
||||
</div>
|
||||
<div class="cat-badge">COMMUNITY EVENT</div>
|
||||
<h1 style="font-size:clamp(1.9rem,4vw,2.9rem);margin:.4rem 0"><?=e($event['title'])?></h1>
|
||||
<div class="evt-meta" style="font-size:.9rem;margin-top:.65rem">
|
||||
<span><?=e($dateRange)?></span>
|
||||
<?php if (!empty($event['venue'])): ?><span><?=e($event['venue'])?></span><?php endif; ?>
|
||||
<?php if ($event['is_featured']): ?><span class="featured-badge">FEATURED</span><?php endif; ?>
|
||||
<?php if (isAdmin() && $event['status'] !== 'approved'): ?><span class="badge badge-gold"><?=strtoupper(e($event['status']))?></span><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<div>
|
||||
|
||||
<?php if (!empty($event['image_path'])): ?>
|
||||
<div class="event-hero-photo ibox">
|
||||
<button type="button" class="event-photo-btn" data-lightbox-src="<?=e($event['image_path'])?>" data-lightbox-alt="<?=e($event['title'])?>">
|
||||
<img src="<?=e($event['image_path'])?>" alt="<?=e($event['title'])?>">
|
||||
<span>View full image</span>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="ibox">
|
||||
<div class="ibox-title">ABOUT THIS EVENT</div>
|
||||
<?php if (!empty($event['description'])): ?>
|
||||
<p style="color:var(--text-m);line-height:1.85;white-space:pre-line"><?=e($event['description'])?></p>
|
||||
<?php else: ?>
|
||||
<p style="color:var(--text-d)">No description has been added yet.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($event['address'])): ?>
|
||||
<div class="ibox">
|
||||
<div class="ibox-title">LOCATION</div>
|
||||
<div style="background:var(--bg4);border:1px dashed var(--bdr);border-radius:4px;padding:1.75rem;text-align:center">
|
||||
<?php if (!empty($event['venue'])): ?><h3 style="font-size:1.2rem;margin-bottom:.35rem"><?=e($event['venue'])?></h3><?php endif; ?>
|
||||
<p style="font-size:.9rem;color:var(--text-m);margin-bottom:.9rem"><?=e($event['address'])?></p>
|
||||
<a href="https://maps.google.com/?q=<?=urlencode(trim(($event['venue'] ? $event['venue'].' ' : '').$event['address']))?>" target="_blank" rel="noopener" class="btn btn-outline btn-sm">Open in Google Maps</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex-row">
|
||||
<a href="/events.php" class="btn btn-secondary">Back to Events</a>
|
||||
<?php if (isAdmin()): ?><a href="/admin/events.php?edit=<?=(int)$event['id']?>" class="btn btn-outline">Edit Event</a><?php endif; ?>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="ibox">
|
||||
<div class="ibox-title">EVENT DETAILS</div>
|
||||
<div class="event-detail-date">
|
||||
<div class="evt-date-box">
|
||||
<div class="evt-mon"><?=strtoupper(date('M', strtotime($event['event_date'])))?></div>
|
||||
<div class="evt-day"><?=date('j', strtotime($event['event_date']))?></div>
|
||||
<div class="evt-yr"><?=date('Y', strtotime($event['event_date']))?></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:700;color:var(--text)"><?=e($dateLabel)?></div>
|
||||
<?php if ($timeLabel !== ''): ?><div style="color:var(--text-m);font-size:.9rem"><?=e($timeLabel)?><?= $endTimeLabel !== '' ? ' - '.e($endTimeLabel) : '' ?></div><?php endif; ?>
|
||||
<?php if ($endDateLabel && $endDateLabel !== $dateLabel): ?><div style="color:var(--text-d);font-size:.82rem">Through <?=e($endDateLabel)?></div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!empty($event['venue'])): ?><div class="irow"><span class="irow-i">📍</span><span class="irow-v"><?=e($event['venue'])?></span></div><?php endif; ?>
|
||||
<?php if (!empty($event['address'])): ?><div class="irow"><span class="irow-i">⌖</span><span class="irow-v"><?=e($event['address'])?></span></div><?php endif; ?>
|
||||
<div class="irow"><span class="irow-i">$</span><span class="irow-v"><?=e($event['cost'] ?: 'Free')?></span></div>
|
||||
<?php if (!empty($event['website'])): ?>
|
||||
<div class="irow"><span class="irow-i">↗</span><a href="<?=e(externalHref($event['website']))?>" target="_blank" rel="noopener" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['website'])?></a></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($event['contact_name']) || !empty($event['contact_email']) || !empty($event['contact_phone'])): ?>
|
||||
<div class="ibox">
|
||||
<div class="ibox-title">CONTACT</div>
|
||||
<?php if (!empty($event['contact_name'])): ?><div class="irow"><span class="irow-i">◇</span><span class="irow-v"><?=e($event['contact_name'])?></span></div><?php endif; ?>
|
||||
<?php if (!empty($event['contact_phone'])): ?><div class="irow"><span class="irow-i">☎</span><a href="tel:<?=e($event['contact_phone'])?>" class="irow-v" style="color:var(--gold)"><?=e($event['contact_phone'])?></a></div><?php endif; ?>
|
||||
<?php if (!empty($event['contact_email'])): ?><div class="irow"><span class="irow-i">@</span><a href="mailto:<?=e($event['contact_email'])?>" class="irow-v" style="color:var(--gold);word-break:break-all"><?=e($event['contact_email'])?></a></div><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="ibox">
|
||||
<div class="ibox-title">SHARE</div>
|
||||
<p style="color:var(--text-m);font-size:.88rem;margin-bottom:.8rem">Send this event page to someone who should know about it.</p>
|
||||
<button type="button" class="btn btn-outline btn-sm" data-copy-url="<?=e(absoluteUrl(eventUrl($event)))?>">Copy Event Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__.'/includes/footer.php'; ?>
|
||||
+31
-6
@@ -12,10 +12,27 @@ if(isPost()&&ps('_act')==='submit_event'){
|
||||
$cost=ps('cost');$web=ps('website');$cn=ps('contact_name');$ce=ps('contact_email');$cp=ps('contact_phone');
|
||||
if(!$title) $errors[]='Event title is required.';
|
||||
if(!$date) $errors[]='Event date is required.';
|
||||
$imagePath = '';
|
||||
if(!$errors){
|
||||
$upload = storeSingleUpload(
|
||||
'event_image',
|
||||
'events',
|
||||
imageExts(),
|
||||
mbToBytes(setting('event_upload_max_mb','5'), 5),
|
||||
[
|
||||
'image_only' => true,
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width','1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if (!$upload['ok']) $errors[] = $upload['error'];
|
||||
else $imagePath = (string)($upload['path'] ?? '');
|
||||
}
|
||||
if(!$errors){
|
||||
$status=setting('events_require_approval','1')==='1'?'pending':'approved';
|
||||
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,submitted_by,status)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[$title,$desc,$venue,$addr,$date,$time,$edate?:null,$etime,$cost,$web,$cn,$ce,$cp,uid(),$status]);
|
||||
qrun("INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
[$title,$desc,$venue,$addr,$date,$time,$edate?:null,$etime,$cost,$web,$cn,$ce,$cp,$imagePath,uid(),$status]);
|
||||
flash($status==='approved'?'Event submitted and published!':'Event submitted! It will appear after review.','success');
|
||||
go('/events.php');
|
||||
}
|
||||
@@ -48,7 +65,7 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">📅 SUBMIT A COMMUNITY EVENT</div>
|
||||
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="submit_event">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="submit_event">
|
||||
<div class="fg"><label class="flabel">EVENT TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><textarea name="description" class="ftextarea" style="min-height:110px"><?=e($_POST['description']??'')?></textarea></div>
|
||||
<div class="form-row">
|
||||
@@ -67,6 +84,11 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="fg"><label class="flabel">COST / ADMISSION</label><input type="text" name="cost" class="finput" value="<?=e($_POST['cost']??'Free')?>" placeholder="Free, $10, Varies…"></div>
|
||||
<div class="fg"><label class="flabel">WEBSITE</label><input type="text" name="website" class="finput" value="<?=e($_POST['website']??'')?>" placeholder="example.com"></div>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label class="flabel">EVENT IMAGE</label>
|
||||
<input type="file" name="event_image" class="finput" accept="image/*">
|
||||
<div class="fhint">Optional JPG, PNG, GIF, or WebP. Max <?=e(setting('event_upload_max_mb','5'))?> MB.</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="flabel">CONTACT NAME</label><input type="text" name="contact_name" class="finput" value="<?=e($_POST['contact_name']??'')?>"></div>
|
||||
<div class="fg"><label class="flabel">CONTACT PHONE</label><input type="text" name="contact_phone" class="finput" value="<?=e($_POST['contact_phone']??'')?>"></div>
|
||||
@@ -86,7 +108,10 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="section">
|
||||
<?php if($events): ?>
|
||||
<?php foreach($events as $ev): ?>
|
||||
<div class="evt-card">
|
||||
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
|
||||
<?php if(!empty($ev['image_path'])): ?>
|
||||
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
|
||||
<?php endif; ?>
|
||||
<div class="evt-date-box">
|
||||
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
|
||||
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
|
||||
@@ -102,9 +127,9 @@ include __DIR__.'/includes/header.php';
|
||||
<?php if($ev['contact_phone']): ?><span>📞 <?=e($ev['contact_phone'])?></span><?php endif; ?>
|
||||
</div>
|
||||
<div class="evt-desc"><?=e($ev['description'])?></div>
|
||||
<?php if($ev['website']): ?><div class="evt-actions"><a href="https://<?=e($ev['website'])?>" target="_blank" class="btn btn-outline btn-sm">More Info ↗</a></div><?php endif; ?>
|
||||
<div class="evt-actions"><span class="btn btn-outline btn-sm">View Event</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php if($pages>1): ?><div style="display:flex;justify-content:center;gap:.5rem;margin-top:2rem;flex-wrap:wrap"><?php for($i=1;$i<=$pages;$i++): ?><a href="?page=<?=$i?>" class="btn btn-sm<?=$i===$page?' btn-primary':' btn-outline'?>"><?=$i?></a><?php endfor; ?></div><?php endif; ?>
|
||||
<?php else: ?>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
require_once __DIR__.'/includes/boot.php';
|
||||
|
||||
$activeNav = 'forum';
|
||||
$pageTitle = 'Community Forum - Keyser, WV';
|
||||
|
||||
if (setting('forum_enabled','1') !== '1' && !isAdmin()) {
|
||||
flash('The community forum is currently offline.', 'warning');
|
||||
go('/index.php');
|
||||
}
|
||||
requireLogin('/forum');
|
||||
|
||||
$topicSlug = gs('topic');
|
||||
$path = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
|
||||
if ($topicSlug === '' && preg_match('#^/forum/([^/]+)/?$#', $path, $m)) {
|
||||
$topicSlug = urldecode($m[1]);
|
||||
}
|
||||
|
||||
function forumAllowedExts(): array {
|
||||
return uploadAllowedExts(setting('forum_allowed_filetypes','jpg,jpeg,png,gif,webp,pdf,txt,doc,docx'), ['jpg','jpeg','png','gif','webp','pdf','txt','doc','docx']);
|
||||
}
|
||||
function forumUploadOpts(): array {
|
||||
return [
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb','1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width','1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality','82'),
|
||||
];
|
||||
}
|
||||
function forumStoreAttachments(): array {
|
||||
return storeMultipleUploads(
|
||||
'attachments',
|
||||
'forum',
|
||||
forumAllowedExts(),
|
||||
mbToBytes(setting('forum_upload_max_mb','5'), 5),
|
||||
forumUploadOpts()
|
||||
);
|
||||
}
|
||||
function forumSaveAttachments(array $files, int $userId, int $topicId = 0, int $replyId = 0): void {
|
||||
foreach ($files as $file) {
|
||||
qrun(
|
||||
"INSERT INTO forum_attachments(topic_id,reply_id,user_id,file_path,original_name,mime_type,file_size)VALUES(?,?,?,?,?,?,?)",
|
||||
[$topicId ?: null, $replyId ?: null, $userId, $file['path'], $file['name'] ?? '', $file['mime'] ?? '', (int)($file['size'] ?? 0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
function forumAttachmentList(array $attachments): string {
|
||||
if (!$attachments) return '';
|
||||
$out = '<div class="forum-attachments">';
|
||||
foreach ($attachments as $att) {
|
||||
$name = $att['original_name'] ?: basename((string)$att['file_path']);
|
||||
if (isImagePath((string)$att['file_path'])) {
|
||||
$out .= '<a href="'.e($att['file_path']).'" class="forum-attachment-image" target="_blank" rel="noopener"><img src="'.e($att['file_path']).'" alt="'.e($name).'"></a>';
|
||||
} else {
|
||||
$out .= '<a href="'.e($att['file_path']).'" class="forum-file" target="_blank" rel="noopener"><span>'.e($name).'</span><em>'.formatBytes((int)$att['file_size']).'</em></a>';
|
||||
}
|
||||
}
|
||||
return $out.'</div>';
|
||||
}
|
||||
function forumText(string $text): string {
|
||||
return nl2br(e($text));
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$allowedHint = implode(', ', forumAllowedExts());
|
||||
$maxHint = setting('forum_upload_max_mb','5').' MB';
|
||||
|
||||
if ($topicSlug === '') {
|
||||
if (isPost() && ps('_act') === 'create_topic') {
|
||||
csrfCheck();
|
||||
$title = ps('title');
|
||||
$body = ps('body');
|
||||
$catId = (int)p('category_id');
|
||||
$cat = qone("SELECT * FROM forum_categories WHERE id=? AND is_active=1", [$catId]);
|
||||
if ($title === '') $errors[] = 'Topic title is required.';
|
||||
if ($body === '') $errors[] = 'Topic body is required.';
|
||||
if (!$cat) $errors[] = 'Please choose a forum category.';
|
||||
|
||||
$uploaded = ['ok'=>true, 'files'=>[]];
|
||||
if (!$errors) {
|
||||
$uploaded = forumStoreAttachments();
|
||||
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
|
||||
}
|
||||
if (!$errors) {
|
||||
$tempSlug = 'draft-'.bin2hex(random_bytes(8));
|
||||
$topicId = qrun(
|
||||
"INSERT INTO forum_topics(category_id,user_id,title,slug,body)VALUES(?,?,?,?,?)",
|
||||
[$catId, uid(), $title, $tempSlug, $body]
|
||||
);
|
||||
$slug = uniqueForumSlug($title, $topicId);
|
||||
qrun("UPDATE forum_topics SET slug=? WHERE id=?", [$slug, $topicId]);
|
||||
forumSaveAttachments($uploaded['files'] ?? [], uid(), $topicId, 0);
|
||||
flash('Topic posted.', 'success');
|
||||
go(forumTopicUrl(['slug'=>$slug]));
|
||||
}
|
||||
}
|
||||
|
||||
$categories = qall("SELECT * FROM forum_categories WHERE is_active=1 ORDER BY sort_order,name");
|
||||
$topics = qall("
|
||||
SELECT ft.*, fc.name category_name, u.username,
|
||||
(SELECT COUNT(*) FROM forum_replies fr WHERE fr.topic_id=ft.id) AS reply_count,
|
||||
COALESCE((SELECT MAX(fr.created_at) FROM forum_replies fr WHERE fr.topic_id=ft.id), ft.created_at) AS last_activity
|
||||
FROM forum_topics ft
|
||||
JOIN forum_categories fc ON fc.id=ft.category_id
|
||||
JOIN users u ON u.id=ft.user_id
|
||||
ORDER BY ft.is_pinned DESC, datetime(last_activity) DESC
|
||||
");
|
||||
|
||||
include __DIR__.'/includes/header.php';
|
||||
?>
|
||||
<div class="page-hdr">
|
||||
<div class="page-hdr-inner" style="display:flex;justify-content:space-between;align-items:flex-end;gap:1rem;flex-wrap:wrap">
|
||||
<div>
|
||||
<div class="eyebrow">COMMUNITY FORUM</div>
|
||||
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin-bottom:.4rem">Keyser Community Forum</h1>
|
||||
<p style="color:var(--text-m)">Topics and replies from registered members and business owners.</p>
|
||||
</div>
|
||||
<?php if(setting('forum_enabled','1') !== '1' && isAdmin()): ?><span class="badge badge-gold">Disabled for non-admins</span><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section forum-layout">
|
||||
<div>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">LATEST TOPICS</div>
|
||||
<h2 class="sec-title">Conversations</h2>
|
||||
</div>
|
||||
</div>
|
||||
<?php if($topics): ?>
|
||||
<div class="forum-topic-list">
|
||||
<?php foreach($topics as $topic): ?>
|
||||
<a href="<?=e(forumTopicUrl($topic))?>" class="forum-topic-row">
|
||||
<span class="forum-topic-main">
|
||||
<strong><?=e($topic['title'])?></strong>
|
||||
<em><?=e($topic['category_name'])?> by <?=e($topic['username'])?> · <?=ago($topic['last_activity'])?></em>
|
||||
</span>
|
||||
<span class="forum-topic-count"><?=$topic['reply_count']?> repl<?=$topic['reply_count']==1?'y':'ies'?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="empty"><div class="empty-ico">+</div><h3>No topics yet</h3><p style="margin-top:.5rem">Start the first community conversation.</p></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">START A TOPIC</div>
|
||||
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
|
||||
<?php if($categories): ?>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<?=csrfField()?><input type="hidden" name="_act" value="create_topic">
|
||||
<div class="fg"><label class="flabel">CATEGORY *</label><select name="category_id" class="fselect" required><option value="">Choose category</option><?php foreach($categories as $cat): ?><option value="<?=$cat['id']?>"<?=((int)($_POST['category_id']??0)===(int)$cat['id'])?' selected':''?>><?=e($cat['name'])?></option><?php endforeach; ?></select></div>
|
||||
<div class="fg"><label class="flabel">TITLE *</label><input type="text" name="title" class="finput" value="<?=e($_POST['title']??'')?>" required></div>
|
||||
<div class="fg"><label class="flabel">BODY *</label><textarea name="body" class="ftextarea" required><?=e($_POST['body']??'')?></textarea></div>
|
||||
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
|
||||
<button class="btn btn-primary btn-block">Post Topic</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p style="color:var(--text-m)">No active categories are available yet.</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<?php
|
||||
include __DIR__.'/includes/footer.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
$topic = qone("
|
||||
SELECT ft.*, fc.name category_name, u.username
|
||||
FROM forum_topics ft
|
||||
JOIN forum_categories fc ON fc.id=ft.category_id
|
||||
JOIN users u ON u.id=ft.user_id
|
||||
WHERE ft.slug=?
|
||||
", [$topicSlug]);
|
||||
if (!$topic) { http_response_code(404); flash('Forum topic not found.', 'error'); go('/forum'); }
|
||||
|
||||
if (isPost() && ps('_act') === 'reply') {
|
||||
csrfCheck();
|
||||
if ((int)$topic['is_locked'] === 1 && !isAdmin()) {
|
||||
$errors[] = 'This topic is locked.';
|
||||
} else {
|
||||
$body = ps('body');
|
||||
$hasFile = false;
|
||||
foreach (normalizeUploadFiles('attachments') as $file) {
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) $hasFile = true;
|
||||
}
|
||||
if ($body === '' && !$hasFile) $errors[] = 'Add a reply or attach a file.';
|
||||
$uploaded = ['ok'=>true, 'files'=>[]];
|
||||
if (!$errors) {
|
||||
$uploaded = forumStoreAttachments();
|
||||
if (!$uploaded['ok']) $errors[] = $uploaded['error'];
|
||||
}
|
||||
if (!$errors) {
|
||||
$replyId = qrun("INSERT INTO forum_replies(topic_id,user_id,body)VALUES(?,?,?)", [$topic['id'], uid(), $body]);
|
||||
forumSaveAttachments($uploaded['files'] ?? [], uid(), 0, $replyId);
|
||||
qrun("UPDATE forum_topics SET updated_at=datetime('now') WHERE id=?", [$topic['id']]);
|
||||
flash('Reply posted.', 'success');
|
||||
go(forumTopicUrl($topic).'#reply-'.$replyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$topicAttachments = qall("SELECT * FROM forum_attachments WHERE topic_id=? AND reply_id IS NULL ORDER BY created_at", [$topic['id']]);
|
||||
$replies = qall("
|
||||
SELECT fr.*, u.username
|
||||
FROM forum_replies fr
|
||||
JOIN users u ON u.id=fr.user_id
|
||||
WHERE fr.topic_id=?
|
||||
ORDER BY fr.created_at ASC
|
||||
", [$topic['id']]);
|
||||
$replyAttachments = [];
|
||||
foreach ($replies as $reply) {
|
||||
$replyAttachments[$reply['id']] = qall("SELECT * FROM forum_attachments WHERE reply_id=? ORDER BY created_at", [$reply['id']]);
|
||||
}
|
||||
|
||||
include __DIR__.'/includes/header.php';
|
||||
?>
|
||||
<div class="page-hdr">
|
||||
<div class="page-hdr-inner">
|
||||
<div class="breadcrumb"><a href="/index.php">Home</a><span>›</span><a href="/forum">Forum</a><span>›</span><?=e($topic['title'])?></div>
|
||||
<div class="cat-badge"><?=e($topic['category_name'])?></div>
|
||||
<h1 style="font-size:clamp(2rem,4vw,2.9rem);margin:.45rem 0"><?=e($topic['title'])?></h1>
|
||||
<div class="forum-byline">Posted by <?=e($topic['username'])?> · <?=ago($topic['created_at'])?><?php if($topic['is_locked']): ?> · <span class="badge badge-gray">Locked</span><?php endif; ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-sm forum-thread">
|
||||
<article class="forum-post">
|
||||
<div class="forum-post-body"><?=forumText($topic['body'])?></div>
|
||||
<?=forumAttachmentList($topicAttachments)?>
|
||||
</article>
|
||||
|
||||
<div class="forum-replies-head">
|
||||
<div class="eyebrow">REPLIES</div>
|
||||
<h2 class="sec-title"><?=count($replies)?> repl<?=count($replies)===1?'y':'ies'?></h2>
|
||||
</div>
|
||||
|
||||
<?php foreach($replies as $reply): ?>
|
||||
<article class="forum-reply" id="reply-<?=$reply['id']?>">
|
||||
<div class="forum-reply-meta">
|
||||
<div class="forum-reply-author"><span><?=e($reply['username'])?></span></div>
|
||||
<a href="<?=e(forumTopicUrl($topic).'#reply-'.$reply['id'])?>" class="forum-permalink">#reply-<?=$reply['id']?></a>
|
||||
</div>
|
||||
<?php if($reply['body'] !== ''): ?><div class="forum-post-body"><?=forumText($reply['body'])?></div><?php endif; ?>
|
||||
<?=forumAttachmentList($replyAttachments[$reply['id']] ?? [])?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div class="ibox" style="border-color:var(--gold-d)">
|
||||
<div class="ibox-title">POST A REPLY</div>
|
||||
<?php if($errors): ?><div class="err-box"><?=implode('<br>',array_map('e',$errors))?></div><?php endif; ?>
|
||||
<?php if((int)$topic['is_locked'] === 1 && !isAdmin()): ?>
|
||||
<p style="color:var(--text-m)">This topic is locked.</p>
|
||||
<?php else: ?>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<?=csrfField()?><input type="hidden" name="_act" value="reply">
|
||||
<div class="fg"><label class="flabel">REPLY</label><textarea name="body" class="ftextarea"><?=e($_POST['body']??'')?></textarea></div>
|
||||
<div class="fg"><label class="flabel">ATTACH FILES</label><input type="file" name="attachments[]" class="finput" multiple><div class="fhint">Allowed: <?=e($allowedHint)?>. Max <?=e($maxHint)?> each.</div></div>
|
||||
<button class="btn btn-primary">Post Reply</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php include __DIR__.'/includes/footer.php'; ?>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?php
|
||||
require dirname(__DIR__).'/forum.php';
|
||||
+190
-1
@@ -48,9 +48,18 @@ function _ensureAppUpgrades(PDO $db): void {
|
||||
if (!_hasColumn($db, 'users', 'member_type')) {
|
||||
$db->exec("ALTER TABLE users ADD COLUMN member_type TEXT NOT NULL DEFAULT 'member'");
|
||||
}
|
||||
if (!_hasColumn($db, 'users', 'avatar_path')) {
|
||||
$db->exec("ALTER TABLE users ADD COLUMN avatar_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!_hasColumn($db, 'businesses', 'video_url')) {
|
||||
$db->exec("ALTER TABLE businesses ADD COLUMN video_url TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!_hasColumn($db, 'events', 'image_path')) {
|
||||
$db->exec("ALTER TABLE events ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (!_hasColumn($db, 'menu_items', 'image_path')) {
|
||||
$db->exec("ALTER TABLE menu_items ADD COLUMN image_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS business_edit_requests (
|
||||
@@ -86,6 +95,79 @@ function _ensureAppUpgrades(PDO $db): void {
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_tokens_user ON email_verification_tokens(user_id, purpose, used_at);
|
||||
CREATE TABLE IF NOT EXISTS event_imports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'facebook',
|
||||
source_area TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
venue TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
event_date TEXT NOT NULL DEFAULT '',
|
||||
event_time TEXT NOT NULL DEFAULT '',
|
||||
end_date TEXT,
|
||||
end_time TEXT NOT NULL DEFAULT '',
|
||||
cost TEXT NOT NULL DEFAULT 'Free',
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
image_path TEXT NOT NULL DEFAULT '',
|
||||
raw_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
status_note TEXT NOT NULL DEFAULT '',
|
||||
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_imports_status ON event_imports(status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_imports_external ON event_imports(provider, external_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_imports_source ON event_imports(provider, source_url) WHERE source_url <> '';
|
||||
CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_topics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_topics_category ON forum_topics(category_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_replies_topic ON forum_replies(topic_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_attachments_topic ON forum_attachments(topic_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_forum_attachments_reply ON forum_attachments(reply_id);
|
||||
");
|
||||
|
||||
$defaults = [
|
||||
@@ -97,9 +179,30 @@ function _ensureAppUpgrades(PDO $db): void {
|
||||
'mailgun_from_name' => 'Discover Keyser WV',
|
||||
'mailgun_from_email' => '',
|
||||
'mailgun_send_mode' => 'html',
|
||||
'event_upload_max_mb' => '5',
|
||||
'menu_item_image_limit' => '1',
|
||||
'menu_upload_max_mb' => '5',
|
||||
'avatar_upload_max_mb' => '3',
|
||||
'forum_enabled' => '1',
|
||||
'forum_upload_max_mb' => '5',
|
||||
'forum_allowed_filetypes' => 'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
|
||||
'forum_image_resize_threshold_mb' => '1',
|
||||
'forum_image_max_width' => '1600',
|
||||
'forum_image_quality' => '82',
|
||||
];
|
||||
$stmt = $db->prepare("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)");
|
||||
foreach ($defaults as $k => $v) $stmt->execute([$k, $v]);
|
||||
|
||||
$catCount = (int)$db->query("SELECT COUNT(*) FROM forum_categories")->fetchColumn();
|
||||
if ($catCount === 0) {
|
||||
$stmt = $db->prepare("INSERT INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)");
|
||||
foreach ([
|
||||
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
|
||||
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
|
||||
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
|
||||
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
|
||||
] as $cat) $stmt->execute($cat);
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════
|
||||
@@ -119,6 +222,7 @@ function _schema(PDO $db): void {
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
email_verified_at TEXT,
|
||||
member_type TEXT NOT NULL DEFAULT 'member',
|
||||
avatar_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
last_login TEXT
|
||||
);
|
||||
@@ -206,6 +310,7 @@ function _schema(PDO $db): void {
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price REAL NOT NULL DEFAULT 0,
|
||||
image_path TEXT NOT NULL DEFAULT '',
|
||||
is_available INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -246,11 +351,37 @@ function _schema(PDO $db): void {
|
||||
contact_name TEXT NOT NULL DEFAULT '',
|
||||
contact_email TEXT NOT NULL DEFAULT '',
|
||||
contact_phone TEXT NOT NULL DEFAULT '',
|
||||
image_path TEXT NOT NULL DEFAULT '',
|
||||
submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS event_imports(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL DEFAULT 'facebook',
|
||||
source_area TEXT NOT NULL DEFAULT '',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
venue TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
event_date TEXT NOT NULL DEFAULT '',
|
||||
event_time TEXT NOT NULL DEFAULT '',
|
||||
end_date TEXT,
|
||||
end_time TEXT NOT NULL DEFAULT '',
|
||||
cost TEXT NOT NULL DEFAULT 'Free',
|
||||
website TEXT NOT NULL DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
image_path TEXT NOT NULL DEFAULT '',
|
||||
raw_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
status_note TEXT NOT NULL DEFAULT '',
|
||||
imported_event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reports(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
|
||||
@@ -261,6 +392,47 @@ function _schema(PDO $db): void {
|
||||
admin_notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_topics (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL REFERENCES forum_categories(id) ON DELETE RESTRICT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic_id INTEGER NOT NULL REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS forum_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic_id INTEGER REFERENCES forum_topics(id) ON DELETE CASCADE,
|
||||
reply_id INTEGER REFERENCES forum_replies(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL DEFAULT '',
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
CHECK (topic_id IS NOT NULL OR reply_id IS NOT NULL)
|
||||
);
|
||||
");
|
||||
}
|
||||
|
||||
@@ -281,7 +453,17 @@ function _seed(PDO $db): void {
|
||||
'mailgun_api_key'=>'',
|
||||
'mailgun_from_name'=>'Discover Keyser WV',
|
||||
'mailgun_from_email'=>'',
|
||||
'mailgun_send_mode'=>'html'] as $k=>$v)
|
||||
'mailgun_send_mode'=>'html',
|
||||
'event_upload_max_mb'=>'5',
|
||||
'menu_item_image_limit'=>'1',
|
||||
'menu_upload_max_mb'=>'5',
|
||||
'avatar_upload_max_mb'=>'3',
|
||||
'forum_enabled'=>'1',
|
||||
'forum_upload_max_mb'=>'5',
|
||||
'forum_allowed_filetypes'=>'jpg,jpeg,png,gif,webp,pdf,txt,doc,docx',
|
||||
'forum_image_resize_threshold_mb'=>'1',
|
||||
'forum_image_max_width'=>'1600',
|
||||
'forum_image_quality'=>'82'] as $k=>$v)
|
||||
qrun("INSERT OR IGNORE INTO settings(key,value)VALUES(?,?)",[$k,$v]);
|
||||
|
||||
/* Admin user — password "password123" */
|
||||
@@ -299,6 +481,13 @@ function _seed(PDO $db): void {
|
||||
['Legal Services','⚖️',12],['Churches & Faith','⛪',13],
|
||||
] as $c) qrun("INSERT OR IGNORE INTO categories(name,icon,sort_order)VALUES(?,?,?)",$c);
|
||||
|
||||
foreach ([
|
||||
['General', 'general', 'Neighborhood conversation, questions, and introductions.', 1],
|
||||
['Local Questions', 'local-questions', 'Ask for Keyser recommendations, resources, and local tips.', 2],
|
||||
['Business Talk', 'business-talk', 'Business owner updates, ideas, and community feedback.', 3],
|
||||
['Events', 'events', 'Discuss upcoming events and things happening around town.', 4],
|
||||
] as $fc) qrun("INSERT OR IGNORE INTO forum_categories(name,slug,description,sort_order,is_active)VALUES(?,?,?,?,1)", $fc);
|
||||
|
||||
$cid = fn(string $n): int => (int)qval("SELECT id FROM categories WHERE name=?",[$n]);
|
||||
$h = fn(array $d): string => json_encode($d);
|
||||
$std = $h(['Mon'=>'9am–5pm','Tue'=>'9am–5pm','Wed'=>'9am–5pm','Thu'=>'9am–5pm','Fri'=>'9am–5pm','Sat'=>'Closed','Sun'=>'Closed']);
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<?php
|
||||
if (!function_exists('qrun')) require_once __DIR__.'/boot.php';
|
||||
|
||||
function facebookEventImporterUserAgent(): string {
|
||||
return 'MyKeyserEventImporter/1.0 (+https://mykeyser.local)';
|
||||
}
|
||||
|
||||
function facebookEventText(mixed $value): string {
|
||||
if (is_array($value)) $value = implode(' ', array_filter(array_map('facebookEventText', $value)));
|
||||
$value = html_entity_decode(strip_tags((string)($value ?? '')), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
return trim(preg_replace('/\s+/', ' ', $value));
|
||||
}
|
||||
|
||||
function facebookEventAllowedImageHost(string $url): bool {
|
||||
$host = strtolower((string)(parse_url($url, PHP_URL_HOST) ?? ''));
|
||||
if ($host === '') return false;
|
||||
foreach (['facebook.com', 'fbcdn.net', 'fbsbx.com'] as $allowed) {
|
||||
if ($host === $allowed || str_ends_with($host, '.'.$allowed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function facebookEventNormalizeUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
|
||||
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
|
||||
$url = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $url);
|
||||
if ($url === '') return '';
|
||||
if (str_starts_with($url, '//')) $url = 'https:'.$url;
|
||||
if (str_starts_with($url, '/')) {
|
||||
$base = parse_url($baseUrl);
|
||||
$scheme = $base['scheme'] ?? 'https';
|
||||
$host = $base['host'] ?? 'www.facebook.com';
|
||||
$url = $scheme.'://'.$host.$url;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) return '';
|
||||
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) return '';
|
||||
|
||||
$host = strtolower($parts['host']);
|
||||
$path = $parts['path'] ?? '';
|
||||
if (($host === 'l.facebook.com' || str_ends_with($host, '.l.facebook.com')) && !empty($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
if (!empty($query['u'])) return facebookEventNormalizeUrl((string)$query['u'], $baseUrl);
|
||||
}
|
||||
$normalized = strtolower($parts['scheme']).'://'.$host.$path;
|
||||
if (!empty($parts['query'])) $normalized .= '?'.$parts['query'];
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
function facebookEventNormalizeEventUrl(string $url, string $baseUrl = 'https://www.facebook.com'): string {
|
||||
$url = facebookEventNormalizeUrl($url, $baseUrl);
|
||||
if ($url === '') return '';
|
||||
$parts = parse_url($url);
|
||||
$host = strtolower($parts['host'] ?? '');
|
||||
if ($host !== 'facebook.com' && $host !== 'www.facebook.com' && !str_ends_with($host, '.facebook.com')) return '';
|
||||
$path = preg_replace('~/+~', '/', (string)($parts['path'] ?? ''));
|
||||
if (!preg_match('~^/(events|share)/~i', $path)) return '';
|
||||
return 'https://www.facebook.com'.rtrim($path, '/');
|
||||
}
|
||||
|
||||
function facebookEventExternalId(string $url): string {
|
||||
$path = (string)(parse_url($url, PHP_URL_PATH) ?? '');
|
||||
if (preg_match_all('~\d{6,}~', $path, $matches) && !empty($matches[0])) {
|
||||
return end($matches[0]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function facebookEventFetchUrl(string $url): array {
|
||||
$url = facebookEventNormalizeUrl($url);
|
||||
if ($url === '') return ['ok' => false, 'error' => 'Invalid URL.', 'body' => '', 'url' => ''];
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 4,
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
|
||||
CURLOPT_HTTPHEADER => ['Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$finalUrl = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
|
||||
if (PHP_VERSION_ID < 80000) curl_close($ch);
|
||||
if ($errno) return ['ok' => false, 'error' => $error ?: 'Request failed.', 'body' => '', 'url' => $url];
|
||||
if ($status < 200 || $status >= 400) return ['ok' => false, 'error' => 'HTTP '.$status.' from Facebook.', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
|
||||
return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $finalUrl ?: $url];
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 25,
|
||||
'ignore_errors' => true,
|
||||
'header' => "User-Agent: ".facebookEventImporterUserAgent()."\r\nAccept: text/html\r\n",
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
$statusLine = $http_response_header[0] ?? '';
|
||||
if ($body === false || ($statusLine && !preg_match('/\s[23]\d\d\s/', $statusLine))) {
|
||||
return ['ok' => false, 'error' => $statusLine ?: 'Request failed.', 'body' => (string)$body, 'url' => $url];
|
||||
}
|
||||
return ['ok' => true, 'error' => '', 'body' => (string)$body, 'url' => $url];
|
||||
}
|
||||
|
||||
function facebookEventDom(string $html): ?DOMDocument {
|
||||
if (!class_exists('DOMDocument')) return null;
|
||||
$dom = new DOMDocument();
|
||||
$old = libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>'.$html);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($old);
|
||||
return $loaded ? $dom : null;
|
||||
}
|
||||
|
||||
function facebookEventMeta(string $html): array {
|
||||
$dom = facebookEventDom($html);
|
||||
if (!$dom) return [];
|
||||
$out = [];
|
||||
foreach ($dom->getElementsByTagName('meta') as $meta) {
|
||||
$key = $meta->getAttribute('property') ?: $meta->getAttribute('name');
|
||||
$content = $meta->getAttribute('content');
|
||||
if ($key !== '' && $content !== '') $out[strtolower($key)] = $content;
|
||||
}
|
||||
foreach ($dom->getElementsByTagName('link') as $link) {
|
||||
if (strtolower($link->getAttribute('rel')) === 'canonical' && $link->getAttribute('href') !== '') {
|
||||
$out['canonical'] = $link->getAttribute('href');
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function facebookEventFindJsonLdEvent(mixed $node): ?array {
|
||||
if (!is_array($node)) return null;
|
||||
$type = $node['@type'] ?? '';
|
||||
$types = is_array($type) ? $type : [$type];
|
||||
foreach ($types as $candidateType) {
|
||||
if (strtolower((string)$candidateType) === 'event') return $node;
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
$found = facebookEventFindJsonLdEvent($child);
|
||||
if ($found) return $found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function facebookEventJsonLd(string $html): ?array {
|
||||
$dom = facebookEventDom($html);
|
||||
if (!$dom) return null;
|
||||
foreach ($dom->getElementsByTagName('script') as $script) {
|
||||
$type = strtolower($script->getAttribute('type'));
|
||||
if ($type !== 'application/ld+json') continue;
|
||||
$json = trim($script->textContent ?? '');
|
||||
if ($json === '') continue;
|
||||
$decoded = json_decode($json, true);
|
||||
$found = facebookEventFindJsonLdEvent($decoded);
|
||||
if ($found) return $found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function facebookEventImageFromValue(mixed $value): string {
|
||||
if (is_string($value)) return $value;
|
||||
if (is_array($value)) {
|
||||
if (isset($value['url'])) return facebookEventImageFromValue($value['url']);
|
||||
foreach ($value as $item) {
|
||||
$image = facebookEventImageFromValue($item);
|
||||
if ($image !== '') return $image;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function facebookEventAddressFromLocation(mixed $location): array {
|
||||
$venue = '';
|
||||
$address = '';
|
||||
if (is_string($location)) return [$location, ''];
|
||||
if (!is_array($location)) return ['', ''];
|
||||
|
||||
$venue = facebookEventText($location['name'] ?? '');
|
||||
$addr = $location['address'] ?? '';
|
||||
if (is_string($addr)) {
|
||||
$address = facebookEventText($addr);
|
||||
} elseif (is_array($addr)) {
|
||||
$parts = [];
|
||||
foreach (['streetAddress', 'addressLocality', 'addressRegion', 'postalCode'] as $key) {
|
||||
if (!empty($addr[$key])) $parts[] = facebookEventText($addr[$key]);
|
||||
}
|
||||
$address = implode(', ', array_filter($parts));
|
||||
}
|
||||
return [$venue, $address];
|
||||
}
|
||||
|
||||
function facebookEventSplitDateTime(string $value): array {
|
||||
$value = facebookEventText(str_replace([' at ', ' AT '], ' ', $value));
|
||||
if ($value === '') return ['', ''];
|
||||
$hasTime = (bool)preg_match('/(?:T|\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*(?:am|pm)\b)/i', $value);
|
||||
try {
|
||||
$dt = new DateTimeImmutable($value);
|
||||
} catch (Throwable) {
|
||||
return ['', ''];
|
||||
}
|
||||
return [$dt->format('Y-m-d'), $hasTime ? $dt->format('g:i A') : ''];
|
||||
}
|
||||
|
||||
function facebookEventDateFromText(string $text): array {
|
||||
$months = 'Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?';
|
||||
if (preg_match('/\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)?(?:day)?[,]?\s*(?:'.$months.')\s+\d{1,2}(?:,\s*\d{4})?(?:\s+(?:at|@)\s+\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?/i', $text, $match)) {
|
||||
return facebookEventSplitDateTime($match[0]);
|
||||
}
|
||||
if (preg_match('/\b\d{4}-\d{2}-\d{2}(?:[T\s]\d{1,2}:\d{2}(?::\d{2})?)?/i', $text, $match)) {
|
||||
return facebookEventSplitDateTime($match[0]);
|
||||
}
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
function facebookEventCleanTitle(string $title): string {
|
||||
$title = facebookEventText($title);
|
||||
$title = preg_replace('/\s*\|\s*Facebook\s*$/i', '', $title);
|
||||
$title = preg_replace('/\s*-\s*Facebook\s*$/i', '', $title);
|
||||
return trim($title);
|
||||
}
|
||||
|
||||
function facebookEventWebsiteForStorage(string $url): string {
|
||||
$url = trim($url);
|
||||
if ($url === '') return '';
|
||||
$url = preg_replace('~^https?://~i', '', $url);
|
||||
return preg_replace('~/+$~', '', $url);
|
||||
}
|
||||
|
||||
function facebookEventExtractEventUrls(string $html, string $sourceUrl = 'https://www.facebook.com'): array {
|
||||
$links = [];
|
||||
$dom = facebookEventDom($html);
|
||||
if ($dom) {
|
||||
foreach ($dom->getElementsByTagName('a') as $a) {
|
||||
$href = $a->getAttribute('href');
|
||||
$url = facebookEventNormalizeEventUrl($href, $sourceUrl);
|
||||
if ($url !== '') $links[$url] = $url;
|
||||
}
|
||||
}
|
||||
|
||||
$decoded = html_entity_decode($html, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
$decoded = str_replace(['\\/', '\\u002F', '\\u003A', '\\u0026'], ['/', '/', ':', '&'], $decoded);
|
||||
if (preg_match_all('~https?://(?:www\.)?facebook\.com/(?:events|share)/[^\s"\'<>\\\\]+~i', $decoded, $matches)) {
|
||||
foreach ($matches[0] as $raw) {
|
||||
$url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
|
||||
if ($url !== '') $links[$url] = $url;
|
||||
}
|
||||
}
|
||||
if (preg_match_all('~href=["\']([^"\']*(?:/events/|/share/)[^"\']*)["\']~i', $decoded, $matches)) {
|
||||
foreach ($matches[1] as $raw) {
|
||||
$url = facebookEventNormalizeEventUrl($raw, $sourceUrl);
|
||||
if ($url !== '') $links[$url] = $url;
|
||||
}
|
||||
}
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
function facebookEventParseHtml(string $html, string $sourceUrl = '', string $area = ''): array {
|
||||
$jsonLd = facebookEventJsonLd($html);
|
||||
$meta = facebookEventMeta($html);
|
||||
$sourceUrl = facebookEventNormalizeEventUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? '')) ?: facebookEventNormalizeUrl($sourceUrl ?: ($meta['og:url'] ?? $meta['canonical'] ?? ''));
|
||||
|
||||
$candidate = [
|
||||
'provider' => 'facebook',
|
||||
'source_area' => $area,
|
||||
'source_url' => $sourceUrl,
|
||||
'external_id' => facebookEventExternalId($sourceUrl),
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'venue' => '',
|
||||
'address' => '',
|
||||
'event_date' => '',
|
||||
'event_time' => '',
|
||||
'end_date' => null,
|
||||
'end_time' => '',
|
||||
'cost' => 'Free',
|
||||
'website' => facebookEventWebsiteForStorage($sourceUrl),
|
||||
'image_url' => '',
|
||||
'image_path' => '',
|
||||
'raw_json' => ['json_ld' => $jsonLd, 'meta' => $meta],
|
||||
];
|
||||
|
||||
if ($jsonLd) {
|
||||
$candidate['title'] = facebookEventCleanTitle((string)($jsonLd['name'] ?? ''));
|
||||
$candidate['description'] = facebookEventText($jsonLd['description'] ?? '');
|
||||
[$candidate['event_date'], $candidate['event_time']] = facebookEventSplitDateTime((string)($jsonLd['startDate'] ?? ''));
|
||||
[$endDate, $endTime] = facebookEventSplitDateTime((string)($jsonLd['endDate'] ?? ''));
|
||||
$candidate['end_date'] = $endDate ?: null;
|
||||
$candidate['end_time'] = $endTime;
|
||||
[$candidate['venue'], $candidate['address']] = facebookEventAddressFromLocation($jsonLd['location'] ?? null);
|
||||
$candidate['image_url'] = facebookEventNormalizeUrl(facebookEventImageFromValue($jsonLd['image'] ?? ''), $sourceUrl ?: 'https://www.facebook.com');
|
||||
if (!empty($jsonLd['url'])) {
|
||||
$candidate['source_url'] = facebookEventNormalizeEventUrl((string)$jsonLd['url'], $sourceUrl ?: 'https://www.facebook.com') ?: $candidate['source_url'];
|
||||
$candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
|
||||
$candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
|
||||
}
|
||||
if (!empty($jsonLd['offers']['price'])) $candidate['cost'] = facebookEventText($jsonLd['offers']['price']);
|
||||
}
|
||||
|
||||
if ($candidate['title'] === '') $candidate['title'] = facebookEventCleanTitle($meta['og:title'] ?? $meta['twitter:title'] ?? '');
|
||||
if ($candidate['description'] === '') $candidate['description'] = facebookEventText($meta['og:description'] ?? $meta['description'] ?? $meta['twitter:description'] ?? '');
|
||||
if ($candidate['image_url'] === '') $candidate['image_url'] = facebookEventNormalizeUrl($meta['og:image'] ?? $meta['twitter:image'] ?? '', $sourceUrl ?: 'https://www.facebook.com');
|
||||
if ($candidate['event_date'] === '') {
|
||||
[$candidate['event_date'], $candidate['event_time']] = facebookEventDateFromText($candidate['title'].' '.$candidate['description']);
|
||||
}
|
||||
if ($candidate['source_url'] === '' && !empty($meta['og:url'])) {
|
||||
$candidate['source_url'] = facebookEventNormalizeUrl($meta['og:url']);
|
||||
$candidate['website'] = facebookEventWebsiteForStorage($candidate['source_url']);
|
||||
$candidate['external_id'] = facebookEventExternalId($candidate['source_url']);
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
function facebookGraphEventUrl(string $eventId): string {
|
||||
$eventId = trim($eventId);
|
||||
return $eventId !== '' ? 'https://www.facebook.com/events/'.rawurlencode($eventId) : '';
|
||||
}
|
||||
|
||||
function facebookGraphPlaceToVenueAddress(mixed $place): array {
|
||||
if (!is_array($place)) return ['', ''];
|
||||
$venue = facebookEventText($place['name'] ?? '');
|
||||
$location = $place['location'] ?? [];
|
||||
if (!is_array($location)) return [$venue, ''];
|
||||
|
||||
$parts = [];
|
||||
foreach (['street', 'city', 'state', 'zip', 'country'] as $key) {
|
||||
if (!empty($location[$key])) $parts[] = facebookEventText($location[$key]);
|
||||
}
|
||||
return [$venue, implode(', ', array_filter($parts))];
|
||||
}
|
||||
|
||||
function facebookGraphEventToCandidate(array $event, string $area = ''): array {
|
||||
$eventId = trim((string)($event['id'] ?? ''));
|
||||
$sourceUrl = facebookGraphEventUrl($eventId);
|
||||
[$eventDate, $eventTime] = facebookEventSplitDateTime((string)($event['start_time'] ?? ''));
|
||||
[$endDate, $endTime] = facebookEventSplitDateTime((string)($event['end_time'] ?? ''));
|
||||
[$venue, $address] = facebookGraphPlaceToVenueAddress($event['place'] ?? null);
|
||||
$ticketUrl = facebookEventNormalizeUrl((string)($event['ticket_uri'] ?? ''));
|
||||
$website = $ticketUrl !== '' ? $ticketUrl : $sourceUrl;
|
||||
$cover = $event['cover'] ?? [];
|
||||
$imageUrl = is_array($cover) ? facebookEventNormalizeUrl((string)($cover['source'] ?? '')) : '';
|
||||
|
||||
return [
|
||||
'provider' => 'facebook',
|
||||
'source_area' => $area,
|
||||
'source_url' => $sourceUrl,
|
||||
'external_id' => $eventId,
|
||||
'title' => facebookEventCleanTitle((string)($event['name'] ?? '')),
|
||||
'description' => facebookEventText($event['description'] ?? ''),
|
||||
'venue' => $venue,
|
||||
'address' => $address,
|
||||
'event_date' => $eventDate,
|
||||
'event_time' => $eventTime,
|
||||
'end_date' => $endDate ?: null,
|
||||
'end_time' => $endTime,
|
||||
'cost' => 'Free',
|
||||
'website' => facebookEventWebsiteForStorage($website),
|
||||
'image_url' => $imageUrl,
|
||||
'image_path' => '',
|
||||
'raw_json' => ['graph_event' => $event],
|
||||
];
|
||||
}
|
||||
|
||||
function facebookEventDownloadImage(string $imageUrl): array {
|
||||
$imageUrl = facebookEventNormalizeUrl($imageUrl);
|
||||
if ($imageUrl === '') return ['ok' => true, 'path' => '', 'error' => ''];
|
||||
if (!facebookEventAllowedImageHost($imageUrl)) {
|
||||
return ['ok' => false, 'path' => '', 'error' => 'Image host is not a Facebook image host.'];
|
||||
}
|
||||
$maxBytes = mbToBytes(setting('event_upload_max_mb', '5'), 5);
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'fb-event-img-');
|
||||
if (!$tmp) return ['ok' => false, 'path' => '', 'error' => 'Could not create a temporary image file.'];
|
||||
$mime = '';
|
||||
$size = 0;
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$fh = fopen($tmp, 'wb');
|
||||
if (!$fh) return ['ok' => false, 'path' => '', 'error' => 'Could not write a temporary image file.'];
|
||||
$ch = curl_init($imageUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_FILE => $fh,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 4,
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
|
||||
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use ($fh, $maxBytes, &$size): int {
|
||||
$size += strlen($chunk);
|
||||
if ($size > $maxBytes) return 0;
|
||||
$written = fwrite($fh, $chunk);
|
||||
return $written === false ? 0 : $written;
|
||||
},
|
||||
]);
|
||||
$ok = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$mime = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
if (PHP_VERSION_ID < 80000) curl_close($ch);
|
||||
fclose($fh);
|
||||
if (!$ok || $errno || $status < 200 || $status >= 400) {
|
||||
@unlink($tmp);
|
||||
$message = $size > $maxBytes ? 'Image exceeds the configured size limit.' : ($error ?: 'Image download failed.');
|
||||
return ['ok' => false, 'path' => '', 'error' => $message];
|
||||
}
|
||||
} else {
|
||||
$context = stream_context_create(['http' => ['timeout' => 25, 'ignore_errors' => true, 'header' => 'User-Agent: '.facebookEventImporterUserAgent()."\r\n"]]);
|
||||
$body = @file_get_contents($imageUrl, false, $context);
|
||||
if ($body === false || strlen($body) > $maxBytes) {
|
||||
@unlink($tmp);
|
||||
return ['ok' => false, 'path' => '', 'error' => $body === false ? 'Image download failed.' : 'Image exceeds the configured size limit.'];
|
||||
}
|
||||
file_put_contents($tmp, $body);
|
||||
}
|
||||
|
||||
if (!function_exists('getimagesize') || !@getimagesize($tmp)) {
|
||||
@unlink($tmp);
|
||||
return ['ok' => false, 'path' => '', 'error' => 'Downloaded file is not a valid image.'];
|
||||
}
|
||||
if ($mime === '' && function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = (string)finfo_file($finfo, $tmp);
|
||||
finfo_close($finfo);
|
||||
}
|
||||
}
|
||||
$ext = match (strtolower(strtok($mime, ';') ?: '')) {
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
default => 'jpg',
|
||||
};
|
||||
$stored = storeUploadedFile(
|
||||
['name' => 'facebook-event.'.$ext, 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => filesize($tmp) ?: 0],
|
||||
'events',
|
||||
imageExts(),
|
||||
$maxBytes,
|
||||
[
|
||||
'image_only' => true,
|
||||
'resize_over_bytes' => mbToBytes(setting('forum_image_resize_threshold_mb', '1'), 1),
|
||||
'image_max_width' => (int)setting('forum_image_max_width', '1600'),
|
||||
'image_quality' => (int)setting('forum_image_quality', '82'),
|
||||
]
|
||||
);
|
||||
if (!$stored['ok']) @unlink($tmp);
|
||||
return $stored['ok']
|
||||
? ['ok' => true, 'path' => (string)$stored['path'], 'error' => '']
|
||||
: ['ok' => false, 'path' => '', 'error' => $stored['error'] ?? 'Image could not be saved.'];
|
||||
}
|
||||
|
||||
function facebookEventCandidateIssues(array $candidate): array {
|
||||
$issues = [];
|
||||
if (trim((string)($candidate['title'] ?? '')) === '') $issues[] = 'Missing title';
|
||||
$date = trim((string)($candidate['event_date'] ?? ''));
|
||||
$dt = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||||
if ($date === '' || !$dt || $dt->format('Y-m-d') !== $date) $issues[] = 'Missing valid start date';
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function facebookEventDuplicateId(array $candidate): int {
|
||||
$title = trim((string)($candidate['title'] ?? ''));
|
||||
$date = trim((string)($candidate['event_date'] ?? ''));
|
||||
if ($title === '' || $date === '') return 0;
|
||||
return (int)qval("SELECT id FROM events WHERE lower(title)=lower(?) AND event_date=? LIMIT 1", [$title, $date]);
|
||||
}
|
||||
|
||||
function facebookEventStageCandidate(array $candidate, bool $downloadImage = true): array {
|
||||
$provider = 'facebook';
|
||||
$sourceUrl = facebookEventNormalizeEventUrl((string)($candidate['source_url'] ?? '')) ?: facebookEventNormalizeUrl((string)($candidate['source_url'] ?? ''));
|
||||
$candidate['source_url'] = $sourceUrl;
|
||||
$candidate['external_id'] = trim((string)($candidate['external_id'] ?? facebookEventExternalId($sourceUrl)));
|
||||
$candidate['website'] = facebookEventWebsiteForStorage((string)($candidate['website'] ?? $sourceUrl));
|
||||
$candidate['cost'] = trim((string)($candidate['cost'] ?? '')) ?: 'Free';
|
||||
$candidate['end_date'] = ($candidate['end_date'] ?? '') ?: null;
|
||||
$candidate['raw_json'] = $candidate['raw_json'] ?? [];
|
||||
|
||||
if ($downloadImage && empty($candidate['image_path']) && !empty($candidate['image_url'])) {
|
||||
$image = facebookEventDownloadImage((string)$candidate['image_url']);
|
||||
if ($image['ok'] && !empty($image['path'])) {
|
||||
$candidate['image_path'] = $image['path'];
|
||||
} elseif (!$image['ok']) {
|
||||
$candidate['status_note'] = 'Image not downloaded: '.$image['error'];
|
||||
}
|
||||
}
|
||||
|
||||
$existing = null;
|
||||
if ($sourceUrl !== '') $existing = qone("SELECT * FROM event_imports WHERE provider=? AND source_url=? LIMIT 1", [$provider, $sourceUrl]);
|
||||
if (!$existing && $candidate['external_id'] !== '') {
|
||||
$existing = qone("SELECT * FROM event_imports WHERE provider=? AND external_id=? LIMIT 1", [$provider, $candidate['external_id']]);
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'provider' => $provider,
|
||||
'source_area' => facebookEventText($candidate['source_area'] ?? ''),
|
||||
'source_url' => $sourceUrl,
|
||||
'external_id' => $candidate['external_id'],
|
||||
'title' => facebookEventCleanTitle((string)($candidate['title'] ?? '')),
|
||||
'description' => facebookEventText($candidate['description'] ?? ''),
|
||||
'venue' => facebookEventText($candidate['venue'] ?? ''),
|
||||
'address' => facebookEventText($candidate['address'] ?? ''),
|
||||
'event_date' => trim((string)($candidate['event_date'] ?? '')),
|
||||
'event_time' => facebookEventText($candidate['event_time'] ?? ''),
|
||||
'end_date' => $candidate['end_date'],
|
||||
'end_time' => facebookEventText($candidate['end_time'] ?? ''),
|
||||
'cost' => facebookEventText($candidate['cost']),
|
||||
'website' => facebookEventWebsiteForStorage((string)$candidate['website']),
|
||||
'image_url' => facebookEventNormalizeUrl((string)($candidate['image_url'] ?? ''), $sourceUrl ?: 'https://www.facebook.com'),
|
||||
'image_path' => trim((string)($candidate['image_path'] ?? '')),
|
||||
'raw_json' => json_encode($candidate['raw_json'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}',
|
||||
'status_note' => facebookEventText($candidate['status_note'] ?? ''),
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
if (($existing['status'] ?? '') === 'imported') {
|
||||
return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'already_imported'];
|
||||
}
|
||||
qrun(
|
||||
"UPDATE event_imports SET source_area=?,source_url=?,external_id=?,title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_url=?,image_path=?,raw_json=?,status='staged',status_note=?,updated_at=datetime('now') WHERE id=?",
|
||||
[$fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note'], (int)$existing['id']]
|
||||
);
|
||||
return ['ok' => true, 'id' => (int)$existing['id'], 'action' => 'updated'];
|
||||
}
|
||||
|
||||
$id = qrun(
|
||||
"INSERT INTO event_imports(provider,source_area,source_url,external_id,title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,image_url,image_path,raw_json,status,status_note)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'staged',?)",
|
||||
[$fields['provider'], $fields['source_area'], $fields['source_url'], $fields['external_id'], $fields['title'], $fields['description'], $fields['venue'], $fields['address'], $fields['event_date'], $fields['event_time'], $fields['end_date'], $fields['end_time'], $fields['cost'], $fields['website'], $fields['image_url'], $fields['image_path'], $fields['raw_json'], $fields['status_note']]
|
||||
);
|
||||
return ['ok' => true, 'id' => $id, 'action' => 'created'];
|
||||
}
|
||||
|
||||
function facebookEventSaveCandidateFields(int $id, array $row): void {
|
||||
qrun(
|
||||
"UPDATE event_imports SET title=?,description=?,venue=?,address=?,event_date=?,event_time=?,end_date=?,end_time=?,cost=?,website=?,image_path=?,status_note='',updated_at=datetime('now') WHERE id=? AND status!='imported'",
|
||||
[
|
||||
facebookEventCleanTitle((string)($row['title'] ?? '')),
|
||||
facebookEventText($row['description'] ?? ''),
|
||||
facebookEventText($row['venue'] ?? ''),
|
||||
facebookEventText($row['address'] ?? ''),
|
||||
trim((string)($row['event_date'] ?? '')),
|
||||
facebookEventText($row['event_time'] ?? ''),
|
||||
trim((string)($row['end_date'] ?? '')) ?: null,
|
||||
facebookEventText($row['end_time'] ?? ''),
|
||||
facebookEventText($row['cost'] ?? 'Free') ?: 'Free',
|
||||
facebookEventWebsiteForStorage((string)($row['website'] ?? '')),
|
||||
trim((string)($row['image_path'] ?? '')),
|
||||
$id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function facebookEventImportCandidate(int $id, int $userId, string $eventStatus = 'pending', bool $allowDuplicate = false): array {
|
||||
$candidate = qone("SELECT * FROM event_imports WHERE id=?", [$id]);
|
||||
if (!$candidate) return ['ok' => false, 'error' => 'Import candidate not found.'];
|
||||
if (($candidate['status'] ?? '') === 'imported') return ['ok' => false, 'error' => 'This candidate has already been imported.'];
|
||||
|
||||
$issues = facebookEventCandidateIssues($candidate);
|
||||
if ($issues) return ['ok' => false, 'error' => implode('; ', $issues).'.'];
|
||||
|
||||
$duplicateId = facebookEventDuplicateId($candidate);
|
||||
if ($duplicateId && !$allowDuplicate) return ['ok' => false, 'error' => 'Possible duplicate of event #'.$duplicateId.'.'];
|
||||
|
||||
$eventStatus = in_array($eventStatus, ['pending', 'approved'], true) ? $eventStatus : 'pending';
|
||||
$imagePath = (string)($candidate['image_path'] ?? '');
|
||||
$note = '';
|
||||
if ($imagePath === '' && !empty($candidate['image_url'])) {
|
||||
$image = facebookEventDownloadImage((string)$candidate['image_url']);
|
||||
if ($image['ok'] && !empty($image['path'])) {
|
||||
$imagePath = (string)$image['path'];
|
||||
} elseif (!$image['ok']) {
|
||||
$note = 'Imported without image: '.$image['error'];
|
||||
}
|
||||
}
|
||||
|
||||
$eventId = qrun(
|
||||
"INSERT INTO events(title,description,venue,address,event_date,event_time,end_date,end_time,cost,website,contact_name,contact_email,contact_phone,image_path,submitted_by,status,is_featured)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
|
||||
[
|
||||
$candidate['title'],
|
||||
$candidate['description'],
|
||||
$candidate['venue'],
|
||||
$candidate['address'],
|
||||
$candidate['event_date'],
|
||||
$candidate['event_time'],
|
||||
$candidate['end_date'] ?: null,
|
||||
$candidate['end_time'],
|
||||
$candidate['cost'] ?: 'Free',
|
||||
facebookEventWebsiteForStorage((string)($candidate['website'] ?: $candidate['source_url'])),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
$imagePath,
|
||||
$userId ?: null,
|
||||
$eventStatus,
|
||||
]
|
||||
);
|
||||
qrun("UPDATE event_imports SET status='imported',imported_event_id=?,image_path=?,status_note=?,updated_at=datetime('now') WHERE id=?", [$eventId, $imagePath, $note, $id]);
|
||||
return ['ok' => true, 'event_id' => $eventId, 'warning' => $note];
|
||||
}
|
||||
@@ -22,6 +22,9 @@
|
||||
<?php if (setting('registration_enabled','1')==='1'): ?>
|
||||
<li><a href="/register.php">Create Account</a></li>
|
||||
<?php endif; ?>
|
||||
<?php if ((authed() && setting('forum_enabled','1')==='1') || isAdmin()): ?>
|
||||
<li><a href="/forum">Community Forum</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="/events.php?submit=1">Submit an Event</a></li>
|
||||
<li><a href="https://govisitmineralwv.com" target="_blank" rel="noopener">Mineral County Tourism ↗</a></li>
|
||||
<li><a href="https://cityofkeyser.com" target="_blank" rel="noopener">City of Keyser ↗</a></li>
|
||||
|
||||
+187
-1
@@ -40,7 +40,7 @@ function g(string $k, mixed $d = ''): mixed { return $_GET[$k] ?? $d; }
|
||||
function iget(string $k): int { return (int)($_POST[$k] ?? $_GET[$k] ?? 0); }
|
||||
function ps(string $k): string { return trim((string)($_POST[$k] ?? '')); }
|
||||
function gs(string $k): string { return trim((string)($_GET[$k] ?? '')); }
|
||||
function isPost(): bool { return strtoupper($_SERVER['REQUEST_METHOD']) === 'POST'; }
|
||||
function isPost(): bool { return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST'; }
|
||||
|
||||
/* ── CSRF ─────────────────────────────────────────────── */
|
||||
function csrf(): string {
|
||||
@@ -150,6 +150,192 @@ function validBusinessVideoUrl(string $url): bool {
|
||||
return in_array($scheme, ['http','https'], true) && businessVideoProvider($url) !== '';
|
||||
}
|
||||
|
||||
/* ── Uploads / Images ─────────────────────────────────── */
|
||||
function uploadRoot(): string { return dirname(__DIR__).'/uploads'; }
|
||||
function uploadPublicPath(string $subdir, string $filename): string {
|
||||
return '/uploads/'.trim($subdir, '/').'/'.$filename;
|
||||
}
|
||||
function cleanUploadSubdir(string $subdir): string {
|
||||
$subdir = trim(str_replace('\\', '/', $subdir), '/');
|
||||
return preg_replace('/[^a-zA-Z0-9_\/-]+/', '', $subdir) ?: 'misc';
|
||||
}
|
||||
function ensureUploadDir(string $subdir): string {
|
||||
$subdir = cleanUploadSubdir($subdir);
|
||||
$dir = uploadRoot().'/'.$subdir;
|
||||
if (!is_dir($dir)) mkdir($dir, 0775, true);
|
||||
return $dir;
|
||||
}
|
||||
function mbToBytes(mixed $mb, float $default = 5): int {
|
||||
$n = (float)$mb;
|
||||
if ($n <= 0) $n = $default;
|
||||
return max(1, (int)round($n * 1024 * 1024));
|
||||
}
|
||||
function uploadAllowedExts(string $csv, array $fallback): array {
|
||||
$raw = array_filter(array_map('trim', explode(',', strtolower($csv))));
|
||||
$exts = $raw ?: $fallback;
|
||||
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $exts)));
|
||||
}
|
||||
function imageExts(): array { return ['jpg','jpeg','png','gif','webp']; }
|
||||
function isImageExt(string $ext): bool { return in_array(strtolower($ext), imageExts(), true); }
|
||||
function isImagePath(string $path): bool {
|
||||
return isImageExt(strtolower(pathinfo(parse_url($path, PHP_URL_PATH) ?: $path, PATHINFO_EXTENSION)));
|
||||
}
|
||||
function uploadErrorText(int $code): string {
|
||||
return match($code) {
|
||||
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded file is too large.',
|
||||
UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially received.',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'The server is missing a temporary upload directory.',
|
||||
UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded file.',
|
||||
UPLOAD_ERR_EXTENSION => 'A server extension stopped the upload.',
|
||||
default => 'The file could not be uploaded.',
|
||||
};
|
||||
}
|
||||
function resizeImageFile(string $path, int $thresholdBytes, int $maxWidth = 1600, int $quality = 82): bool {
|
||||
if ($thresholdBytes <= 0 || !file_exists($path) || filesize($path) <= $thresholdBytes) return false;
|
||||
if (!function_exists('getimagesize') || !function_exists('imagecreatetruecolor')) return false;
|
||||
$info = @getimagesize($path);
|
||||
if (!$info || empty($info[0]) || empty($info[1]) || empty($info['mime'])) return false;
|
||||
|
||||
[$w, $h] = $info;
|
||||
$mime = $info['mime'];
|
||||
$loader = match($mime) {
|
||||
'image/jpeg' => 'imagecreatefromjpeg',
|
||||
'image/png' => 'imagecreatefrompng',
|
||||
'image/gif' => 'imagecreatefromgif',
|
||||
'image/webp' => 'imagecreatefromwebp',
|
||||
default => '',
|
||||
};
|
||||
if ($loader === '' || !function_exists($loader)) return false;
|
||||
$src = @$loader($path);
|
||||
if (!$src) return false;
|
||||
|
||||
$scale = min(1, $maxWidth / max($w, $h));
|
||||
$nw = max(1, (int)round($w * $scale));
|
||||
$nh = max(1, (int)round($h * $scale));
|
||||
$dst = imagecreatetruecolor($nw, $nh);
|
||||
if ($mime === 'image/png' || $mime === 'image/webp') {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
$transparent = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefilledrectangle($dst, 0, 0, $nw, $nh, $transparent);
|
||||
}
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||
$quality = max(40, min(95, $quality));
|
||||
$ok = match($mime) {
|
||||
'image/jpeg' => imagejpeg($dst, $path, $quality),
|
||||
'image/png' => imagepng($dst, $path, 6),
|
||||
'image/gif' => imagegif($dst, $path),
|
||||
'image/webp' => function_exists('imagewebp') ? imagewebp($dst, $path, $quality) : false,
|
||||
default => false,
|
||||
};
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
if ($ok) @chmod($path, 0664);
|
||||
return (bool)$ok;
|
||||
}
|
||||
function normalizeUploadFiles(string $field): array {
|
||||
if (empty($_FILES[$field])) return [];
|
||||
$file = $_FILES[$field];
|
||||
if (!is_array($file['name'])) return [$file];
|
||||
$out = [];
|
||||
foreach ($file['name'] as $i => $name) {
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'type' => $file['type'][$i] ?? '',
|
||||
'tmp_name' => $file['tmp_name'][$i] ?? '',
|
||||
'error' => $file['error'][$i] ?? UPLOAD_ERR_NO_FILE,
|
||||
'size' => $file['size'][$i] ?? 0,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
function storeUploadedFile(array $file, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) return ['ok'=>true, 'path'=>''];
|
||||
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) return ['ok'=>false, 'error'=>uploadErrorText((int)$file['error'])];
|
||||
if ((int)($file['size'] ?? 0) > $maxBytes) return ['ok'=>false, 'error'=>'The uploaded file exceeds the configured size limit.'];
|
||||
|
||||
$original = (string)($file['name'] ?? 'upload');
|
||||
$ext = strtolower(pathinfo($original, PATHINFO_EXTENSION));
|
||||
$allowedExts = array_map('strtolower', $allowedExts);
|
||||
if ($ext === '' || !in_array($ext, $allowedExts, true)) {
|
||||
return ['ok'=>false, 'error'=>'This file type is not allowed.'];
|
||||
}
|
||||
if (!empty($opts['image_only']) && !isImageExt($ext)) return ['ok'=>false, 'error'=>'Please upload an image file.'];
|
||||
if (!empty($opts['image_only']) && function_exists('getimagesize') && !@getimagesize((string)$file['tmp_name'])) {
|
||||
return ['ok'=>false, 'error'=>'The selected file is not a valid image.'];
|
||||
}
|
||||
|
||||
$dir = ensureUploadDir($subdir);
|
||||
$filename = date('YmdHis').'-'.bin2hex(random_bytes(5)).'.'.$ext;
|
||||
$dest = $dir.'/'.$filename;
|
||||
$tmp = (string)$file['tmp_name'];
|
||||
$moved = is_uploaded_file($tmp) ? move_uploaded_file($tmp, $dest) : @rename($tmp, $dest);
|
||||
if (!$moved) return ['ok'=>false, 'error'=>'The uploaded file could not be saved.'];
|
||||
@chmod($dest, 0664);
|
||||
|
||||
if (isImageExt($ext)) {
|
||||
resizeImageFile(
|
||||
$dest,
|
||||
(int)($opts['resize_over_bytes'] ?? 0),
|
||||
(int)($opts['image_max_width'] ?? 1600),
|
||||
(int)($opts['image_quality'] ?? 82)
|
||||
);
|
||||
}
|
||||
|
||||
$mime = '';
|
||||
if (function_exists('finfo_open')) {
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo) {
|
||||
$mime = (string)finfo_file($finfo, $dest);
|
||||
finfo_close($finfo);
|
||||
}
|
||||
}
|
||||
return [
|
||||
'ok' => true,
|
||||
'path' => uploadPublicPath($subdir, $filename),
|
||||
'name' => $original,
|
||||
'mime' => $mime,
|
||||
'size' => file_exists($dest) ? filesize($dest) : (int)($file['size'] ?? 0),
|
||||
];
|
||||
}
|
||||
function storeSingleUpload(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
|
||||
$files = normalizeUploadFiles($field);
|
||||
return $files ? storeUploadedFile($files[0], $subdir, $allowedExts, $maxBytes, $opts) : ['ok'=>true, 'path'=>''];
|
||||
}
|
||||
function storeMultipleUploads(string $field, string $subdir, array $allowedExts, int $maxBytes, array $opts = []): array {
|
||||
$saved = [];
|
||||
foreach (normalizeUploadFiles($field) as $file) {
|
||||
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
|
||||
$res = storeUploadedFile($file, $subdir, $allowedExts, $maxBytes, $opts);
|
||||
if (!$res['ok']) return ['ok'=>false, 'error'=>$res['error'] ?? 'Upload failed.', 'files'=>$saved];
|
||||
if (!empty($res['path'])) $saved[] = $res;
|
||||
}
|
||||
return ['ok'=>true, 'files'=>$saved];
|
||||
}
|
||||
function formatBytes(int $bytes): string {
|
||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1).' MB';
|
||||
if ($bytes >= 1024) return number_format($bytes / 1024, 1).' KB';
|
||||
return $bytes.' B';
|
||||
}
|
||||
function uniqueForumSlug(string $title, int $id): string {
|
||||
$base = makeSlug($title) ?: 'topic';
|
||||
return $base.'-'.$id;
|
||||
}
|
||||
function forumTopicUrl(array $topic): string {
|
||||
return '/forum/'.rawurlencode((string)$topic['slug']).'/';
|
||||
}
|
||||
|
||||
function eventUrl(array $event): string {
|
||||
return '/event.php?id='.(int)($event['id'] ?? 0);
|
||||
}
|
||||
|
||||
function externalHref(string $url): string {
|
||||
$url = trim($url);
|
||||
if ($url === '') return '';
|
||||
if (preg_match('~^https?://~i', $url)) return $url;
|
||||
return 'https://'.$url;
|
||||
}
|
||||
|
||||
/* ── Email verification / Mailgun ─────────────────────── */
|
||||
function requestBaseUrl(): string {
|
||||
$configured = trim(setting('site_base_url', ''));
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<li><a href="/directory.php" class="nl<?=($activeNav??'')==='dir'?' active':''?>">Directory</a></li>
|
||||
<li><a href="/attractions.php" class="nl<?=($activeNav??'')==='attr'?' active':''?>">Attractions</a></li>
|
||||
<li><a href="/events.php" class="nl<?=($activeNav??'')==='events'?' active':''?>">Events</a></li>
|
||||
<?php if ((authed() && setting('forum_enabled','1')==='1') || isAdmin()): ?>
|
||||
<li><a href="/forum" class="nl<?=($activeNav??'')==='forum'?' active':''?>">Forum</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="/about.php" class="nl<?=($activeNav??'')==='about'?' active':''?>">About</a></li>
|
||||
<?php if (authed()): ?>
|
||||
<?php if (isAdmin()): ?>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
$__requestPath = (string)(parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? '');
|
||||
if (preg_match('#^/forum/[^/]+/?$#', $__requestPath)) {
|
||||
require __DIR__.'/forum.php';
|
||||
exit;
|
||||
}
|
||||
require_once __DIR__.'/includes/boot.php';
|
||||
$pageTitle='Discover Keyser WV — The Friendliest City in the U.S.A.';
|
||||
$activeNav='home';
|
||||
@@ -31,7 +36,7 @@ include __DIR__.'/includes/header.php';
|
||||
<div class="home-hero-copy">
|
||||
<div class="hero-eyebrow">MINERAL COUNTY · WEST VIRGINIA</div>
|
||||
<h1 class="hero-h1">DISCOVER<span class="gld">KEYSER</span></h1>
|
||||
<p class="hero-desc">This place is new. Sign up to claim your business!</p>
|
||||
<p class="hero-desc">This place is new. Sign up to claim your business, submit corrections and events!</p>
|
||||
<form action="/directory.php" method="GET" class="hero-search">
|
||||
<span class="s-icon">⌕</span>
|
||||
<input class="search-input" type="text" name="q" placeholder="Search restaurants, shops, services...">
|
||||
@@ -82,6 +87,52 @@ include __DIR__.'/includes/header.php';
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="home-band">
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">UPCOMING EVENTS</div>
|
||||
<h2 class="sec-title">What’s Happening Around Keyser</h2>
|
||||
</div>
|
||||
<a href="/events.php" class="btn btn-outline btn-sm">All Events</a>
|
||||
</div>
|
||||
<?php if($upevts): ?>
|
||||
<div class="event-strip">
|
||||
<?php foreach($upevts as $ev): ?>
|
||||
<a href="<?=e(eventUrl($ev))?>" class="evt-card evt-link-card">
|
||||
<?php if(!empty($ev['image_path'])): ?>
|
||||
<div class="evt-image-wrap"><img src="<?=e($ev['image_path'])?>" alt="<?=e($ev['title'])?>" class="evt-image"></div>
|
||||
<?php endif; ?>
|
||||
<div class="evt-date-box">
|
||||
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
|
||||
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
|
||||
<div class="evt-yr"><?=date('Y',strtotime($ev['event_date']))?></div>
|
||||
</div>
|
||||
<div class="evt-body">
|
||||
<div class="evt-title"><?=e($ev['title'])?><?php if($ev['is_featured']): ?> <span class="featured-badge">FEATURED</span><?php endif; ?></div>
|
||||
<div class="evt-meta">
|
||||
<?php if($ev['venue']): ?><span><?=e($ev['venue'])?></span><?php endif; ?>
|
||||
<?php if($ev['event_time']): ?><span><?=e($ev['event_time'])?></span><?php endif; ?>
|
||||
<span><?=e($ev['cost'])?></span>
|
||||
</div>
|
||||
<div class="evt-desc"><?=e(substr($ev['description'],0,160)).(strlen($ev['description'])>160?'...':'')?></div>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="empty event-empty">
|
||||
<div class="empty-ico">◇</div>
|
||||
<h3>No upcoming approved events yet</h3>
|
||||
<p style="margin-top:.5rem;color:var(--text-d)">Community events will appear here as they are approved.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="text-center mt3">
|
||||
<a href="/events.php?submit=1" class="btn btn-primary">Submit an Event</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -121,49 +172,6 @@ include __DIR__.'/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="home-band">
|
||||
<div class="section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<div class="eyebrow">UPCOMING EVENTS</div>
|
||||
<h2 class="sec-title">What’s Happening Around Keyser</h2>
|
||||
</div>
|
||||
<a href="/events.php" class="btn btn-outline btn-sm">All Events</a>
|
||||
</div>
|
||||
<?php if($upevts): ?>
|
||||
<div class="event-strip">
|
||||
<?php foreach($upevts as $ev): ?>
|
||||
<div class="evt-card">
|
||||
<div class="evt-date-box">
|
||||
<div class="evt-mon"><?=strtoupper(date('M',strtotime($ev['event_date'])))?></div>
|
||||
<div class="evt-day"><?=date('j',strtotime($ev['event_date']))?></div>
|
||||
<div class="evt-yr"><?=date('Y',strtotime($ev['event_date']))?></div>
|
||||
</div>
|
||||
<div class="evt-body">
|
||||
<div class="evt-title"><?=e($ev['title'])?><?php if($ev['is_featured']): ?> <span class="featured-badge">FEATURED</span><?php endif; ?></div>
|
||||
<div class="evt-meta">
|
||||
<?php if($ev['venue']): ?><span><?=e($ev['venue'])?></span><?php endif; ?>
|
||||
<?php if($ev['event_time']): ?><span><?=e($ev['event_time'])?></span><?php endif; ?>
|
||||
<span><?=e($ev['cost'])?></span>
|
||||
</div>
|
||||
<div class="evt-desc"><?=e(substr($ev['description'],0,160)).(strlen($ev['description'])>160?'...':'')?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="empty event-empty">
|
||||
<div class="empty-ico">◇</div>
|
||||
<h3>No upcoming approved events yet</h3>
|
||||
<p style="margin-top:.5rem;color:var(--text-d)">Community events will appear here as they are approved.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="text-center mt3">
|
||||
<a href="/events.php?submit=1" class="btn btn-primary">Submit an Event</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section about-split">
|
||||
<div>
|
||||
<div class="eyebrow">ABOUT KEYSER</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ requireLogin();
|
||||
$bizId=iget('id');
|
||||
$biz=qone("SELECT * FROM businesses WHERE id=?",[$bizId]);
|
||||
if(!$biz||!owns($bizId)){flash('Access denied.','error');go('/dashboard.php');}
|
||||
$menuImageLimit=max(0,min(1,(int)setting('menu_item_image_limit','1')));
|
||||
$menuImagesEnabled=$menuImageLimit>0;
|
||||
|
||||
if(isPost()){
|
||||
csrfCheck();
|
||||
@@ -28,8 +30,25 @@ if(isPost()){
|
||||
$sid=(int)p('section_id');
|
||||
$name=ps('item_name');
|
||||
if($name){
|
||||
$imagePath='';
|
||||
if($menuImagesEnabled){
|
||||
$upload=storeSingleUpload(
|
||||
'item_image',
|
||||
'menus',
|
||||
imageExts(),
|
||||
mbToBytes(setting('menu_upload_max_mb','5'),5),
|
||||
[
|
||||
'image_only'=>true,
|
||||
'resize_over_bytes'=>mbToBytes(setting('forum_image_resize_threshold_mb','1'),1),
|
||||
'image_max_width'=>(int)setting('forum_image_max_width','1600'),
|
||||
'image_quality'=>(int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if(!$upload['ok']){flash($upload['error'],'error');go("/menu.php?id=$bizId");}
|
||||
$imagePath=(string)($upload['path']??'');
|
||||
}
|
||||
$order=(int)qval("SELECT COALESCE(MAX(sort_order),0)+1 FROM menu_items WHERE section_id=?",[$sid]);
|
||||
qrun("INSERT INTO menu_items(section_id,name,description,price,sort_order)VALUES(?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$order]);
|
||||
qrun("INSERT INTO menu_items(section_id,name,description,price,image_path,sort_order)VALUES(?,?,?,?,?,?)",[$sid,$name,ps('item_desc'),(float)p('item_price',0),$imagePath,$order]);
|
||||
flash('Item added!','success');
|
||||
}
|
||||
}
|
||||
@@ -39,8 +58,29 @@ if(isPost()){
|
||||
}
|
||||
if($act==='edit_item'){
|
||||
$iid=(int)p('iid');
|
||||
qrun("UPDATE menu_items SET name=?,description=?,price=?,is_available=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),(int)p('is_available',1),$iid]);
|
||||
flash('Item updated!','success');
|
||||
$item=qone("SELECT mi.* FROM menu_items mi JOIN menu_sections ms ON ms.id=mi.section_id WHERE mi.id=? AND ms.business_id=?",[$iid,$bizId]);
|
||||
if($item){
|
||||
$imagePath=(string)($item['image_path']??'');
|
||||
if($menuImagesEnabled && p('remove_item_image','0')==='1') $imagePath='';
|
||||
if($menuImagesEnabled){
|
||||
$upload=storeSingleUpload(
|
||||
'item_image',
|
||||
'menus',
|
||||
imageExts(),
|
||||
mbToBytes(setting('menu_upload_max_mb','5'),5),
|
||||
[
|
||||
'image_only'=>true,
|
||||
'resize_over_bytes'=>mbToBytes(setting('forum_image_resize_threshold_mb','1'),1),
|
||||
'image_max_width'=>(int)setting('forum_image_max_width','1600'),
|
||||
'image_quality'=>(int)setting('forum_image_quality','82'),
|
||||
]
|
||||
);
|
||||
if(!$upload['ok']){flash($upload['error'],'error');go("/menu.php?id=$bizId");}
|
||||
if(!empty($upload['path'])) $imagePath=(string)$upload['path'];
|
||||
}
|
||||
qrun("UPDATE menu_items SET name=?,description=?,price=?,image_path=?,is_available=? WHERE id=?",[ps('item_name'),ps('item_desc'),(float)p('item_price',0),$imagePath,(int)p('is_available',1),$iid]);
|
||||
flash('Item updated!','success');
|
||||
}
|
||||
}
|
||||
go("/menu.php?id=$bizId");
|
||||
}
|
||||
@@ -97,10 +137,13 @@ include __DIR__.'/includes/header.php';
|
||||
<?php foreach($items[$sec['id']] as $item): ?>
|
||||
<div style="border-bottom:1px solid var(--bdr);padding:.75rem 0">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem">
|
||||
<div>
|
||||
<div class="menu-owner-row">
|
||||
<?php if(!empty($item['image_path'])): ?><img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>" class="menu-owner-thumb"><?php endif; ?>
|
||||
<div>
|
||||
<div style="font-weight:600;color:<?=$item['is_available']?'var(--text)':'var(--text-d)'?>"><?=e($item['name'])?> <?php if(!$item['is_available']): ?><span class="badge badge-gray">Unavailable</span><?php endif; ?></div>
|
||||
<?php if($item['description']): ?><div style="font-size:.8rem;color:var(--text-d)"><?=e($item['description'])?></div><?php endif; ?>
|
||||
<?php if($item['price']>0): ?><div style="color:var(--gold);font-family:'Oswald',sans-serif;font-size:.9rem"><?=money((float)$item['price'])?></div><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-row" style="gap:.3rem;flex-shrink:0">
|
||||
<button onclick="this.closest('div').parentElement.querySelector('.edit-item-form').classList.toggle('hidden')" class="btn btn-xs btn-secondary">✏️</button>
|
||||
@@ -108,12 +151,25 @@ include __DIR__.'/includes/header.php';
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-item-form hidden" style="background:var(--bg4);border:1px solid var(--bdr);border-radius:4px;padding:.85rem;margin-top:.65rem">
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="edit_item"><input type="hidden" name="iid" value="<?=$item['id']?>">
|
||||
<div class="form-row" style="margin-bottom:.6rem">
|
||||
<div class="fg"><label class="flabel">NAME</label><input type="text" name="item_name" class="finput" value="<?=e($item['name'])?>" required></div>
|
||||
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="<?=e($item['price'])?>" step="0.01" min="0"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" value="<?=e($item['description'])?>"></div>
|
||||
<?php if($menuImagesEnabled): ?>
|
||||
<div class="fg">
|
||||
<label class="flabel">ITEM IMAGE</label>
|
||||
<?php if(!empty($item['image_path'])): ?>
|
||||
<div class="menu-admin-image">
|
||||
<img src="<?=e($item['image_path'])?>" alt="<?=e($item['name'])?>">
|
||||
<label style="display:flex;align-items:center;gap:.45rem;font-size:.82rem;color:var(--text-m);cursor:pointer"><input type="checkbox" name="remove_item_image" value="1"> Remove current image</label>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" name="item_image" class="finput" accept="image/*">
|
||||
<div class="fhint">One image allowed. Max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="fg"><label class="flabel">AVAILABLE</label><select name="is_available" class="fselect"><option value="1"<?=$item['is_available']?' selected':''?>>Yes</option><option value="0"<?=!$item['is_available']?' selected':''?>>No (hidden from menu)</option></select></div>
|
||||
<button type="submit" class="btn btn-success btn-sm">Save Item</button>
|
||||
</form>
|
||||
@@ -125,12 +181,15 @@ include __DIR__.'/includes/header.php';
|
||||
<div style="margin-top:.85rem;padding-top:.85rem">
|
||||
<details><summary style="cursor:pointer;font-size:.82rem;color:var(--gold);font-family:'Oswald',sans-serif;letter-spacing:.08em">➕ Add Item to "<?=e($sec['name'])?>"</summary>
|
||||
<div style="padding:.85rem 0">
|
||||
<form method="POST"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
|
||||
<form method="POST" enctype="multipart/form-data"><?=csrfField()?><input type="hidden" name="_act" value="add_item"><input type="hidden" name="section_id" value="<?=$sec['id']?>">
|
||||
<div class="form-row" style="margin-bottom:.6rem">
|
||||
<div class="fg"><label class="flabel">ITEM NAME *</label><input type="text" name="item_name" class="finput" required placeholder="e.g. Spaghetti Carbonara"></div>
|
||||
<div class="fg"><label class="flabel">PRICE ($)</label><input type="number" name="item_price" class="finput" value="0" step="0.01" min="0"></div>
|
||||
</div>
|
||||
<div class="fg"><label class="flabel">DESCRIPTION</label><input type="text" name="item_desc" class="finput" placeholder="Brief description…"></div>
|
||||
<?php if($menuImagesEnabled): ?>
|
||||
<div class="fg"><label class="flabel">ITEM IMAGE</label><input type="file" name="item_image" class="finput" accept="image/*"><div class="fhint">Optional. One image allowed, max <?=e(setting('menu_upload_max_mb','5'))?> MB.</div></div>
|
||||
<?php endif; ?>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Add Item</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
require_once dirname(__DIR__).'/includes/boot.php';
|
||||
require_once dirname(__DIR__).'/includes/facebook_event_importer.php';
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
fwrite(STDERR, "This script must be run from the command line.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const FB_GRAPH_DEFAULT_VERSION = 'v23.0';
|
||||
const FB_GRAPH_EVENT_FIELDS = 'id,name,description,start_time,end_time,place{id,name,location},cover{source},ticket_uri';
|
||||
const FB_GRAPH_PLACE_FIELDS = 'id,name,location';
|
||||
|
||||
function fbCliUsage(): void {
|
||||
echo "Facebook Graph API event importer for My Keyser\n\n";
|
||||
echo "Usage:\n";
|
||||
echo " FACEBOOK_GRAPH_ACCESS_TOKEN=... php scripts/facebook-events-import.php --area=\"Keyser, WV\" --page-id=123456789\n";
|
||||
echo " php scripts/facebook-events-import.php --access-token=... --event-id=123456789012345\n";
|
||||
echo " php scripts/facebook-events-import.php --access-token=... --pages-file=/path/to/page-ids.txt\n";
|
||||
echo " php scripts/facebook-events-import.php --access-token=... --area=\"Keyser, WV\" --center=39.4364,-78.9762 --distance=25000 --query=\"Keyser\" --discover-pages\n\n";
|
||||
echo "Required:\n";
|
||||
echo " --access-token=TOKEN Graph API access token, or set FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n";
|
||||
echo "Sources:\n";
|
||||
echo " --page-id=ID[,ID] Facebook Page IDs to read through /{page-id}/events.\n";
|
||||
echo " --pages-file=FILE Plain-text Page ID file, one ID per line.\n";
|
||||
echo " --event-id=ID[,ID] Specific Facebook Event IDs to read through /{event-id}.\n";
|
||||
echo " --events-file=FILE Plain-text Event ID file, one ID per line.\n";
|
||||
echo " --discover-pages Use Graph place search for --center/--distance/--query, then read events for matching Page/place IDs.\n\n";
|
||||
echo "Options:\n";
|
||||
echo " --area=TEXT Label shown in the admin import review queue.\n";
|
||||
echo " --center=LAT,LNG Center point for Graph place search.\n";
|
||||
echo " --distance=METERS Radius for Graph place search. Default: 25000.\n";
|
||||
echo " --query=TEXT Place search query. Defaults to --area.\n";
|
||||
echo " --place-limit=N Maximum places to discover. Default: 50.\n";
|
||||
echo " --limit=N Maximum events to stage total. Default: 100.\n";
|
||||
echo " --since=YYYY-MM-DD Start date for Page event reads. Default: today.\n";
|
||||
echo " --until=YYYY-MM-DD End date for Page event reads. Default: +1 year.\n";
|
||||
echo " --graph-version=vNN.0 Graph API version. Default: ".FB_GRAPH_DEFAULT_VERSION." or FACEBOOK_GRAPH_VERSION.\n";
|
||||
echo " --no-images Stage Graph cover URLs but skip downloading cover photos.\n";
|
||||
echo " --dry-run Fetch and print candidates without writing to the database.\n";
|
||||
echo " --help Show this help.\n\n";
|
||||
echo "Notes:\n";
|
||||
echo " This script uses only official Graph API endpoints. It does not scrape HTML, log in,\n";
|
||||
echo " solve challenges, or bypass Facebook access controls. Your Meta app/token must have\n";
|
||||
echo " permission to read the requested Page or Event data.\n";
|
||||
}
|
||||
|
||||
function fbCliOptions(array $argv): array {
|
||||
$options = [];
|
||||
for ($i = 1; $i < count($argv); $i++) {
|
||||
$arg = $argv[$i];
|
||||
if (!str_starts_with($arg, '--')) continue;
|
||||
$arg = substr($arg, 2);
|
||||
if (str_contains($arg, '=')) {
|
||||
[$key, $value] = explode('=', $arg, 2);
|
||||
} else {
|
||||
$key = $arg;
|
||||
$value = true;
|
||||
if ($i + 1 < count($argv) && !str_starts_with($argv[$i + 1], '--')) $value = $argv[++$i];
|
||||
}
|
||||
if (isset($options[$key])) {
|
||||
if (!is_array($options[$key])) $options[$key] = [$options[$key]];
|
||||
$options[$key][] = $value;
|
||||
} else {
|
||||
$options[$key] = $value;
|
||||
}
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
function fbCliValues(array $options, string $key): array {
|
||||
if (!isset($options[$key])) return [];
|
||||
$value = $options[$key];
|
||||
if (!is_array($value)) $value = [$value];
|
||||
$out = [];
|
||||
foreach ($value as $item) {
|
||||
foreach (explode(',', (string)$item) as $part) {
|
||||
$part = trim($part);
|
||||
if ($part !== '') $out[] = $part;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function fbCliFileValues(array $options, string $key): array {
|
||||
$out = [];
|
||||
foreach (fbCliValues($options, $key) as $file) {
|
||||
if (!is_file($file) || !is_readable($file)) {
|
||||
throw new RuntimeException("File is not readable: ".$file);
|
||||
}
|
||||
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim(preg_replace('/#.*/', '', $line));
|
||||
if ($line !== '') $out[] = $line;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function fbCliLine(string $message = ''): void {
|
||||
echo $message."\n";
|
||||
}
|
||||
|
||||
function fbCliGraphVersion(array $options): string {
|
||||
$version = trim((string)($options['graph-version'] ?? getenv('FACEBOOK_GRAPH_VERSION') ?: FB_GRAPH_DEFAULT_VERSION));
|
||||
if ($version === '') $version = FB_GRAPH_DEFAULT_VERSION;
|
||||
return str_starts_with($version, 'v') ? $version : 'v'.$version;
|
||||
}
|
||||
|
||||
function fbCliGraphBase(string $version): string {
|
||||
return 'https://graph.facebook.com/'.rawurlencode($version).'/';
|
||||
}
|
||||
|
||||
function fbCliGraphRequest(string $path, array $params, string $token, string $version): array {
|
||||
$path = ltrim($path, '/');
|
||||
$url = fbCliGraphBase($version).$path;
|
||||
if ($params) $url .= '?'.http_build_query($params);
|
||||
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 4,
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_USERAGENT => facebookEventImporterUserAgent(),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer '.$token,
|
||||
],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
if (PHP_VERSION_ID < 80000) curl_close($ch);
|
||||
if ($errno) throw new RuntimeException($error ?: 'Graph API request failed.');
|
||||
} else {
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'timeout' => 30,
|
||||
'ignore_errors' => true,
|
||||
'header' => "Accept: application/json\r\nAuthorization: Bearer ".$token."\r\nUser-Agent: ".facebookEventImporterUserAgent()."\r\n",
|
||||
],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $context);
|
||||
$statusLine = $http_response_header[0] ?? '';
|
||||
$status = preg_match('/\s(\d{3})\s/', $statusLine, $match) ? (int)$match[1] : 0;
|
||||
if ($body === false) throw new RuntimeException($statusLine ?: 'Graph API request failed.');
|
||||
}
|
||||
|
||||
$json = json_decode((string)$body, true);
|
||||
if (!is_array($json)) throw new RuntimeException('Graph API returned invalid JSON.');
|
||||
if ($status < 200 || $status >= 300 || isset($json['error'])) {
|
||||
$err = $json['error'] ?? [];
|
||||
$message = is_array($err) ? ($err['message'] ?? 'Graph API error') : 'Graph API error';
|
||||
$code = is_array($err) && isset($err['code']) ? ' (code '.$err['code'].')' : '';
|
||||
throw new RuntimeException($message.$code);
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
function fbCliDateToTimestamp(string $date, string $fallback): int {
|
||||
$date = trim($date);
|
||||
$ts = strtotime($date !== '' ? $date : $fallback);
|
||||
if ($ts === false) throw new RuntimeException("Invalid date: ".$date);
|
||||
return $ts;
|
||||
}
|
||||
|
||||
function fbCliDiscoverPages(array $options, string $token, string $version): array {
|
||||
if (empty($options['discover-pages'])) return [];
|
||||
$center = trim((string)($options['center'] ?? ''));
|
||||
if ($center === '' || !preg_match('/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/', $center)) {
|
||||
throw new RuntimeException('--discover-pages requires --center=LAT,LNG.');
|
||||
}
|
||||
$area = trim((string)($options['area'] ?? ''));
|
||||
$query = trim((string)($options['query'] ?? $area));
|
||||
if ($query === '') throw new RuntimeException('--discover-pages requires --query or --area.');
|
||||
|
||||
$limit = max(1, min(100, (int)($options['place-limit'] ?? 50)));
|
||||
$distance = max(1, (int)($options['distance'] ?? 25000));
|
||||
$json = fbCliGraphRequest('search', [
|
||||
'type' => 'place',
|
||||
'q' => $query,
|
||||
'center' => $center,
|
||||
'distance' => $distance,
|
||||
'fields' => FB_GRAPH_PLACE_FIELDS,
|
||||
'limit' => $limit,
|
||||
], $token, $version);
|
||||
|
||||
$ids = [];
|
||||
foreach (($json['data'] ?? []) as $place) {
|
||||
if (!empty($place['id'])) $ids[(string)$place['id']] = (string)$place['id'];
|
||||
}
|
||||
fbCliLine('Discovered '.count($ids).' Page/place ID'.(count($ids) === 1 ? '' : 's').' from Graph place search.');
|
||||
return array_values($ids);
|
||||
}
|
||||
|
||||
function fbCliFetchPageEvents(string $pageId, string $token, string $version, int $since, int $until, int $remaining): array {
|
||||
$events = [];
|
||||
$after = '';
|
||||
while ($remaining > count($events)) {
|
||||
$params = [
|
||||
'fields' => FB_GRAPH_EVENT_FIELDS,
|
||||
'since' => $since,
|
||||
'until' => $until,
|
||||
'limit' => min(100, $remaining - count($events)),
|
||||
];
|
||||
if ($after !== '') $params['after'] = $after;
|
||||
$json = fbCliGraphRequest(rawurlencode($pageId).'/events', $params, $token, $version);
|
||||
foreach (($json['data'] ?? []) as $event) {
|
||||
if (is_array($event)) $events[] = $event + ['_source_page_id' => $pageId];
|
||||
}
|
||||
$after = (string)($json['paging']['cursors']['after'] ?? '');
|
||||
if ($after === '' || empty($json['data'])) break;
|
||||
}
|
||||
return $events;
|
||||
}
|
||||
|
||||
function fbCliFetchEvent(string $eventId, string $token, string $version): array {
|
||||
$json = fbCliGraphRequest(rawurlencode($eventId), ['fields' => FB_GRAPH_EVENT_FIELDS], $token, $version);
|
||||
return $json + ['_source_event_id' => $eventId];
|
||||
}
|
||||
|
||||
function fbCliStageGraphEvent(array $event, string $area, bool $downloadImages, bool $dryRun, array &$summary): void {
|
||||
$candidate = facebookGraphEventToCandidate($event, $area);
|
||||
$title = $candidate['title'] ?: '(untitled)';
|
||||
if ($dryRun) {
|
||||
$summary['dry_run']++;
|
||||
fbCliLine('DRY RUN: '.$title.' | '.($candidate['event_date'] ?: 'no date').' | '.($candidate['source_url'] ?: 'no source URL'));
|
||||
return;
|
||||
}
|
||||
|
||||
$result = facebookEventStageCandidate($candidate, $downloadImages);
|
||||
if ($result['ok']) {
|
||||
$summary[$result['action']] = ($summary[$result['action']] ?? 0) + 1;
|
||||
fbCliLine(strtoupper(str_replace('_', ' ', $result['action'])).': #'.$result['id'].' '.$title);
|
||||
} else {
|
||||
$summary['failed']++;
|
||||
fbCliLine('FAILED: '.$title.' - '.$result['error']);
|
||||
}
|
||||
}
|
||||
|
||||
$options = fbCliOptions($argv);
|
||||
if (isset($options['help']) || $options === []) {
|
||||
fbCliUsage();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$token = trim((string)($options['access-token'] ?? getenv('FACEBOOK_GRAPH_ACCESS_TOKEN') ?: ''));
|
||||
if ($token === '') {
|
||||
fwrite(STDERR, "Missing Graph API access token. Use --access-token or FACEBOOK_GRAPH_ACCESS_TOKEN.\n\n");
|
||||
fbCliUsage();
|
||||
exit(2);
|
||||
}
|
||||
|
||||
try {
|
||||
$version = fbCliGraphVersion($options);
|
||||
$area = trim((string)($options['area'] ?? ''));
|
||||
$limit = max(1, (int)($options['limit'] ?? 100));
|
||||
$downloadImages = !isset($options['no-images']);
|
||||
$dryRun = isset($options['dry-run']);
|
||||
$summary = ['created' => 0, 'updated' => 0, 'already_imported' => 0, 'dry_run' => 0, 'failed' => 0, 'graph_failed' => 0];
|
||||
|
||||
$pageIds = array_values(array_unique(array_merge(
|
||||
fbCliValues($options, 'page-id'),
|
||||
fbCliFileValues($options, 'pages-file'),
|
||||
fbCliDiscoverPages($options, $token, $version)
|
||||
)));
|
||||
$eventIds = array_values(array_unique(array_merge(
|
||||
fbCliValues($options, 'event-id'),
|
||||
fbCliFileValues($options, 'events-file')
|
||||
)));
|
||||
|
||||
if (!$pageIds && !$eventIds) {
|
||||
throw new RuntimeException('Provide at least one --page-id, --pages-file, --event-id, --events-file, or --discover-pages source.');
|
||||
}
|
||||
|
||||
$since = fbCliDateToTimestamp((string)($options['since'] ?? ''), 'today');
|
||||
$until = fbCliDateToTimestamp((string)($options['until'] ?? ''), '+1 year');
|
||||
$staged = 0;
|
||||
|
||||
foreach ($eventIds as $eventId) {
|
||||
if ($staged >= $limit) break;
|
||||
fbCliLine('Fetching Graph event: '.$eventId);
|
||||
try {
|
||||
$event = fbCliFetchEvent($eventId, $token, $version);
|
||||
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
|
||||
$staged++;
|
||||
} catch (Throwable $e) {
|
||||
$summary['graph_failed']++;
|
||||
fbCliLine('GRAPH FAILED: event '.$eventId.' - '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pageIds as $pageId) {
|
||||
if ($staged >= $limit) break;
|
||||
$remaining = $limit - $staged;
|
||||
fbCliLine('Fetching Graph Page events: '.$pageId);
|
||||
try {
|
||||
$events = fbCliFetchPageEvents($pageId, $token, $version, $since, $until, $remaining);
|
||||
} catch (Throwable $e) {
|
||||
$summary['graph_failed']++;
|
||||
fbCliLine('GRAPH FAILED: page '.$pageId.' - '.$e->getMessage());
|
||||
continue;
|
||||
}
|
||||
foreach ($events as $event) {
|
||||
if ($staged >= $limit) break;
|
||||
fbCliStageGraphEvent($event, $area, $downloadImages, $dryRun, $summary);
|
||||
$staged++;
|
||||
}
|
||||
}
|
||||
|
||||
fbCliLine();
|
||||
fbCliLine('Summary:');
|
||||
fbCliLine(' Created: '.$summary['created']);
|
||||
fbCliLine(' Updated: '.$summary['updated']);
|
||||
fbCliLine(' Already imported: '.$summary['already_imported']);
|
||||
if ($dryRun) fbCliLine(' Dry-run candidates: '.$summary['dry_run']);
|
||||
fbCliLine(' Stage failures: '.$summary['failed']);
|
||||
fbCliLine(' Graph failures: '.$summary['graph_failed']);
|
||||
fbCliLine();
|
||||
fbCliLine('Review staged imports at: /admin/event_imports.php');
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "Error: ".$e->getMessage()."\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user