diff --git a/.env.example b/.env.example index 755543a..6fd670d 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,6 @@ POSTGRES_HOSTNAME=postgres_host POSTGRES_PORT=postgres_port POSTGRES_DB=postgres_database POSTGRES_USER=postgres_user -POSTGRES_PASSWORD=xxxx - -ADMIN_PASSWORD=xxxxx - +POSTGRES_PASSWORD= +ADMIN_PASSWORD= MUNICIPALITY_SLUG=lohne \ No newline at end of file diff --git a/.gitignore b/.gitignore index e89a32f..8f04f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env .vscode/ *.log -scripts \ No newline at end of file +scripts + +public/uploads/photos/* +!public/uploads/photos/.gitkeep \ No newline at end of file diff --git a/migrations/003_news_table.sql b/migrations/003_news_table.sql new file mode 100644 index 0000000..cd68291 --- /dev/null +++ b/migrations/003_news_table.sql @@ -0,0 +1,44 @@ +-- ===================================================================== +-- Migration 004: Creates News Table for Municipality Announcements +-- ===================================================================== + + +-- --------------------------------------------------------------------- +-- Block 1: Creates Table "news" +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS news ( + news_id SERIAL PRIMARY KEY, + municipality_id INTEGER NOT NULL REFERENCES municipalities(municipality_id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author_name VARCHAR(100) NOT NULL DEFAULT 'Stadtverwaltung', + published_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + + +-- --------------------------------------------------------------------- +-- Block 2: Trigger Functions +-- --------------------------------------------------------------------- + +-- Automatically Refresh updated_at on every UPDATE. +CREATE TRIGGER set_news_updated_at + BEFORE UPDATE ON news + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + + +-- --------------------------------------------------------------------- +-- Block 3 Indexes for fast Queries +-- --------------------------------------------------------------------- +CREATE INDEX idx_news_municipality ON news(municipality_id); + + +-- --------------------------------------------------------------------- +-- Block 4: Seed Data — Initial News Article +-- --------------------------------------------------------------------- +INSERT INTO news (municipality_id, title, content) +SELECT municipality_id, 'Mitmachkarte gestartet', + 'Die Mitmachkarte als Bürgerbeteiligungsportal der Stadt Lohne (Oldenburg) wird nun freigeschaltet. Wir freuen uns auf Ihre Hinweise und Vorschläge!' +FROM municipalities WHERE slug = 'lohne'; \ No newline at end of file diff --git a/migrations/004_reverse_geocoding.sql b/migrations/004_reverse_geocoding.sql new file mode 100644 index 0000000..662caa8 --- /dev/null +++ b/migrations/004_reverse_geocoding.sql @@ -0,0 +1,8 @@ +-- ===================================================================== +-- Migration 004: Adds Address Column for Reverse Geocoding +-- ===================================================================== + +ALTER TABLE contributions + ADD COLUMN address VARCHAR(255) DEFAULT NULL; + +COMMENT ON COLUMN contributions.address IS 'Reverse geocoded Address, stored automatically on Creation.'; \ No newline at end of file diff --git a/migrations/005_browser_id.sql b/migrations/005_browser_id.sql new file mode 100644 index 0000000..d4fdad1 --- /dev/null +++ b/migrations/005_browser_id.sql @@ -0,0 +1,27 @@ +-- ===================================================================== +-- Migration 005: Adds Browser ID for anonymous User Identification +-- ===================================================================== + +-- Adds browser_id Column to Contributions +ALTER TABLE contributions + ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL; + +-- Adds browser_id Column to Votes +-- Replaces voter_name for Identification +ALTER TABLE votes + ADD COLUMN browser_id VARCHAR(36) DEFAULT NULL; + +-- Index for fast Vote Lookup by Browser +CREATE INDEX idx_votes_browser ON votes(browser_id); + + +-- New UNIQUE Constraint: One Vote per Browser per Contribution + +-- Drops old Constraint voter_name based +ALTER TABLE votes + DROP CONSTRAINT IF EXISTS votes_unique_per_voter; + +-- Creates new Constraint browser_id based +ALTER TABLE votes + ADD CONSTRAINT votes_contribution_browser_unique + UNIQUE (contribution_id, browser_id); \ No newline at end of file diff --git a/migrations/006_comments_and_photos.sql b/migrations/006_comments_and_photos.sql new file mode 100644 index 0000000..97366f6 --- /dev/null +++ b/migrations/006_comments_and_photos.sql @@ -0,0 +1,35 @@ +-- ===================================================================== +-- Migration 006: Comments Table and Photo Support +-- ===================================================================== + + +-- --------------------------------------------------------------------- +-- Block 1: Creates Table "comments" +-- Stores Comments on Contributions. Comments is linked to +-- Contributions and identified by browser_id. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS comments ( + comment_id SERIAL PRIMARY KEY, + contribution_id INTEGER NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + author_name VARCHAR(100) NOT NULL, + browser_id VARCHAR(36) DEFAULT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + + +-- --------------------------------------------------------------------- +-- Block 2: Indexes for fast Comment Queries +-- --------------------------------------------------------------------- +CREATE INDEX idx_comments_contribution ON comments(contribution_id); +CREATE INDEX idx_comments_browser ON comments(browser_id); + + +-- --------------------------------------------------------------------- +-- Block 3: Adds Photo Path Column to Contributions +-- Stores relative Path to uploaded Photo File. +-- --------------------------------------------------------------------- +ALTER TABLE contributions + ADD COLUMN photo_path VARCHAR(255) DEFAULT NULL; + +COMMENT ON COLUMN contributions.photo_path IS 'Relative Path to uploaded Photo. NULL = no Photo.'; \ No newline at end of file diff --git a/public/admin.css b/public/admin.css deleted file mode 100644 index adfb977..0000000 --- a/public/admin.css +++ /dev/null @@ -1,529 +0,0 @@ -/* ===================================================================== - Moderation Page — Styles - Separate Stylesheet for the Admin Moderation Interface. - ===================================================================== */ - - -/* ----------------------------------------------------------------- - Base - ----------------------------------------------------------------- */ -* { box-sizing: border-box; margin: 0; padding: 0; } - -body { - font-family: 'Segoe UI', system-ui, sans-serif; - background: #f4f5f7; - color: #1a1a2e; - font-size: 15px; -} - - -/* ----------------------------------------------------------------- - Header - ----------------------------------------------------------------- */ -.admin-header { - background: var(--color-primary); - color: white; - padding: 14px 24px; - display: flex; - justify-content: space-between; - align-items: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.admin-header h1 { - font-size: 1.15rem; - font-weight: 600; -} - -.admin-nav { - display: flex; - gap: 16px; - align-items: center; -} - -.admin-nav a { - color: white; - text-decoration: none; - opacity: 0.8; - font-size: 0.85rem; - transition: opacity 150ms ease; -} - -.admin-nav a:hover { opacity: 1; } - - -/* ----------------------------------------------------------------- - Container - ----------------------------------------------------------------- */ -.admin-container { - max-width: 960px; - margin: 24px auto; - padding: 0 16px; -} - - -/* ----------------------------------------------------------------- - Statistics Cards - ----------------------------------------------------------------- */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 12px; - margin-bottom: 28px; -} - -.stat-card { - background: white; - border-radius: 8px; - padding: 16px; - text-align: center; - border: 1px solid #e0e0e0; -} - -.stat-card .stat-number { - font-size: 1.8rem; - font-weight: 700; - color: var(--color-primary); -} - -.stat-card .stat-label { - font-size: 0.8rem; - color: #5a5a7a; - margin-top: 4px; -} - - -/* ----------------------------------------------------------------- - Filter Tabs - ----------------------------------------------------------------- */ -.filter-tabs { - display: flex; - gap: 4px; - margin-bottom: 20px; - border-bottom: 2px solid #e0e0e0; - padding-bottom: 0; -} - -.filter-tab { - padding: 8px 16px; - border: none; - background: none; - font-family: inherit; - font-size: 0.85rem; - font-weight: 600; - color: #5a5a7a; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -2px; - transition: color 150ms ease, border-color 150ms ease; -} - -.filter-tab:hover { - color: var(--color-primary); -} - -.filter-tab.active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); -} - -.filter-tab .tab-count { - background: #e0e0e0; - color: #5a5a7a; - font-size: 0.7rem; - padding: 1px 6px; - border-radius: 10px; - margin-left: 4px; -} - -.filter-tab.active .tab-count { - background: var(--color-primary); - color: white; -} - - -/* ----------------------------------------------------------------- - Sort Controls - ----------------------------------------------------------------- */ -.sort-controls { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - font-size: 0.85rem; - color: #5a5a7a; -} - -.sort-controls select { - padding: 4px 8px; - border: 1px solid #e0e0e0; - border-radius: 4px; - font-family: inherit; - font-size: 0.85rem; - cursor: pointer; -} - - -/* ----------------------------------------------------------------- - Collapsible Contribution Rows - ----------------------------------------------------------------- */ -.contribution-row { - background: white; - border: 1px solid #e0e0e0; - border-radius: 8px; - margin-bottom: 10px; - overflow: hidden; - transition: border-color 150ms ease; -} - -.contribution-row:hover { - border-color: #bbb; -} - -.contribution-row-header { - padding: 12px 16px; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: background 150ms ease; -} - -.contribution-row-header:hover { - background: #f8f9fa; -} - -.contribution-row-summary { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; -} - -.contribution-row-summary .title { - font-weight: 600; - font-size: 0.95rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.collapse-icon { - color: #999; - font-size: 0.75rem; - flex-shrink: 0; - transition: transform 200ms ease; -} - -.contribution-row.open .collapse-icon { - transform: rotate(180deg); -} - - -/* ----------------------------------------------------------------- - Contribution Detail View (expanded) - ----------------------------------------------------------------- */ -.contribution-row-detail { - padding: 0 16px 16px 16px; - border-top: 1px solid #f0f0f0; - display: none; -} - -.contribution-row.open .contribution-row-detail { - display: block; -} - -.detail-layout { - display: flex; - gap: 16px; - margin-top: 12px; - margin-bottom: 12px; -} - -.detail-map { - width: 220px; - height: 170px; - border-radius: 6px; - border: 1px solid #e0e0e0; - flex-shrink: 0; - background: #f0f0f0; -} - -.detail-content { - flex: 1; - min-width: 0; -} - -.detail-content .description { - font-size: 0.85rem; - line-height: 1.5; - color: #5a5a7a; - margin-bottom: 10px; -} - -.detail-content .description.empty { - color: #bbb; - font-style: italic; -} - -.detail-meta { - font-size: 0.8rem; - color: #999; - display: flex; - flex-direction: column; - gap: 4px; -} - -.detail-meta span { - display: flex; - align-items: center; - gap: 6px; -} - - -/* ----------------------------------------------------------------- - Action Buttons - ----------------------------------------------------------------- */ -.action-buttons { - display: flex; - gap: 8px; - flex-wrap: wrap; - padding-top: 12px; - border-top: 1px solid #f0f0f0; -} - -.btn { - padding: 7px 14px; - border: none; - border-radius: 6px; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 5px; - font-family: inherit; - transition: filter 150ms ease; - text-decoration: none; -} - -.btn:hover { filter: brightness(1.1); } - -.btn-approve { background: #2e7d32; color: white; } -.btn-reject { background: #c62828; color: white; } -.btn-edit { background: #1565C0; color: white; } -.btn-delete { background: #424242; color: white; } -.btn-map { background: #546E7A; color: white; } - - -/* ----------------------------------------------------------------- - Badges - ----------------------------------------------------------------- */ -.badge { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - flex-shrink: 0; -} - -.badge-pending { background: #fff3cd; color: #856404; } -.badge-approved { background: #d4edda; color: #155724; } -.badge-rejected { background: #f8d7da; color: #721c24; } - - -/* ----------------------------------------------------------------- - Empty State - ----------------------------------------------------------------- */ -.empty-state { - text-align: center; - padding: 40px; - color: #999; - font-size: 0.9rem; -} - - -/* ----------------------------------------------------------------- - Section Spacing - ----------------------------------------------------------------- */ -.section { margin-bottom: 32px; } - - -/* ----------------------------------------------------------------- - Placeholder Tabs (future Features) - ----------------------------------------------------------------- */ -.placeholder-content { - text-align: center; - padding: 60px 20px; - color: #bbb; -} - -.placeholder-content i { - font-size: 2.5rem; - margin-bottom: 12px; - display: block; -} - -.placeholder-content p { - font-size: 0.9rem; -} - - -/* ----------------------------------------------------------------- - Navigation Tabs (Page Sections) - ----------------------------------------------------------------- */ -.page-tabs { - display: flex; - gap: 4px; - margin-bottom: 24px; - background: white; - padding: 4px; - border-radius: 8px; - border: 1px solid #e0e0e0; -} - -.page-tab { - padding: 8px 16px; - border: none; - background: none; - font-family: inherit; - font-size: 0.85rem; - font-weight: 600; - color: #5a5a7a; - cursor: pointer; - border-radius: 6px; - transition: all 150ms ease; - display: flex; - align-items: center; - gap: 6px; -} - -.page-tab:hover { background: #f0f0f0; } - -.page-tab.active { - background: var(--color-primary); - color: white; -} - - -/* ----------------------------------------------------------------- - Login Page - ----------------------------------------------------------------- */ -.login-wrapper { - display: flex; - align-items: center; - justify-content: center; - min-height: 100vh; -} - -.login-box { - background: white; - border-radius: 12px; - padding: 32px; - max-width: 380px; - width: 90%; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); - text-align: center; -} - -.login-box h1 { - font-size: 1.3rem; - margin-bottom: 8px; -} - -.login-box p { - font-size: 0.85rem; - color: #5a5a7a; - margin-bottom: 20px; -} - -.login-box input[type="password"] { - width: 100%; - padding: 10px 12px; - border: 1px solid #e0e0e0; - border-radius: 6px; - font-size: 0.9rem; - margin-bottom: 12px; - font-family: inherit; -} - -.login-box input:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 3px rgba(0, 55, 109, 0.1); -} - -.login-box button { - width: 100%; - padding: 10px; - background: var(--color-primary); - color: white; - border: none; - border-radius: 6px; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - font-family: inherit; -} - -.login-box button:hover { filter: brightness(1.15); } - -.login-error { - color: #c62828; - font-size: 0.85rem; - margin-bottom: 12px; -} - -.back-link { - margin-top: 16px; - font-size: 0.8rem; -} - -.back-link a { color: #5a5a7a; } - - -/* ----------------------------------------------------------------- - Mobile Responsive - ----------------------------------------------------------------- */ -@media (max-width: 768px) { - .admin-header { - flex-direction: column; - gap: 8px; - padding: 12px 16px; - } - - .admin-header h1 { font-size: 1rem; } - - .detail-layout { - flex-direction: column; - } - - .detail-map { - width: 100%; - height: 180px; - } - - .contribution-row-summary .title { - max-width: 200px; - } - - .action-buttons { - flex-direction: column; - } - - .action-buttons .btn { - justify-content: center; - } - - .filter-tabs { - overflow-x: auto; - } - - .page-tabs { - overflow-x: auto; - } -} \ No newline at end of file diff --git a/public/admin.php b/public/admin.php index 144d407..ce77702 100644 --- a/public/admin.php +++ b/public/admin.php @@ -57,6 +57,16 @@ $stmt = $pdo->prepare("SELECT * FROM municipalities WHERE slug = :slug"); $stmt->execute([':slug' => getenv('MUNICIPALITY_SLUG')]); $municipality = $stmt->fetch(); +// Loads News for Moderation +$stmt = $pdo->prepare(" + SELECT news_id, title, content, author_name, published_at, created_at + FROM news + WHERE municipality_id = :mid + ORDER BY published_at DESC +"); +$stmt->execute([':mid' => $municipality['municipality_id']]); +$news_items = $stmt->fetchAll(); + // Shows Login Page if not authenticated if ($page === 'login' || !is_admin()) { show_login_page($municipality, $login_error ?? null); @@ -102,8 +112,7 @@ $counts['total'] = count($all_contributions);
Bitte geben Sie das Moderationspasswort ein.
-