Quivara commited on
Commit
75aa02d
·
verified ·
1 Parent(s): 54c71e0

Upload 10 files

Browse files
alisto_project/backend/alisto.db ADDED
Binary file (36.9 kB). View file
 
alisto_project/backend/index.html ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ALISTO</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;900&family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+ <div id="login-modal" class="modal-overlay hidden">
14
+ <div class="modal-content small-modal">
15
+ <div class="modal-header">
16
+ <h2>Responder Login</h2>
17
+ <button id="close-login-btn" class="icon-btn-ghost"><i class="fa-solid fa-xmark"></i></button>
18
+ </div>
19
+ <div class="login-form">
20
+ <input type="text" id="username" placeholder="Username" class="login-input">
21
+ <input type="password" id="password" placeholder="Password" class="login-input">
22
+ <button id="login-submit-btn" class="action-btn resolve-btn full-width">Log In</button>
23
+ <p id="login-error" style="color: #ff4444; margin-top: 10px; font-size: 0.9em;"></p>
24
+ </div>
25
+ </div>
26
+ </div>
27
+
28
+ <div id="logout-modal" class="modal-overlay hidden">
29
+ <div class="modal-content" style="max-width: 400px; text-align: center;">
30
+ <div class="modal-header" style="justify-content: center; margin-bottom: 10px;">
31
+ <h2 style="color: #ed4801;">Confirm Logout</h2>
32
+ </div>
33
+ <p style="color: #ccc; margin-bottom: 30px;">Are you sure you want to end your session?</p>
34
+
35
+ <div class="action-buttons" style="justify-content: center; gap: 15px;">
36
+ <button id="cancel-logout-btn" class="action-btn" style="background: #444; padding: 10px 20px;">Cancel</button>
37
+ <button id="confirm-logout-btn" class="action-btn confirm-btn" style="padding: 10px 20px;">Yes, Logout</button>
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <div id="new-alert-notification" class="alert-popup hidden">
43
+ <i class="fa-solid fa-bell"></i>
44
+ <span id="notification-message">New URGENT Alert Received!</span>
45
+ </div>
46
+
47
+ <audio id="alert-sound" src="https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3" preload="auto"></audio>
48
+
49
+ <div id="stats-modal" class="modal-overlay hidden">
50
+ <div class="modal-content">
51
+ <div class="modal-header">
52
+ <h2>Situation Report</h2>
53
+ <button id="close-stats-btn" class="icon-btn-ghost"><i class="fa-solid fa-xmark"></i></button>
54
+ </div>
55
+ <div class="charts-container">
56
+ <div class="chart-wrapper">
57
+ <h3>Active Incidents by Type</h3>
58
+ <canvas id="typeChart"></canvas>
59
+ </div>
60
+ <div class="chart-wrapper">
61
+ <h3>Urgency Breakdown</h3>
62
+ <canvas id="urgencyChart"></canvas>
63
+ </div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <header class="main-header">
69
+ <div class="logo">
70
+ <span class="logo-main">ALISTO</span>
71
+ <span class="logo-sub">Alert System</span>
72
+ </div>
73
+
74
+ <div class="search-container">
75
+ <i class="fa-solid fa-xmark clear-icon hidden"></i>
76
+ <input type="text" placeholder="Search incidents..." class="search-input">
77
+ <i class="fa-solid fa-magnifying-glass search-icon clickable" data_tooltip="Search"></i>
78
+ </div>
79
+
80
+ <nav class="main-nav">
81
+ <a href="index.html" class="nav-link active" data_tooltip="Go to Posts">Dashboard</a>
82
+ <a href="map.html" class="nav-link" data_tooltip="Go to Live Map">Live Map</a>
83
+ <a href="#" id="nav-login-btn" class="nav-link" style="color: #ed4801;">Login</a>
84
+
85
+ <div class="profile-container" id="profile-container-wrap" style="display: none;">
86
+
87
+ <div id="profile-toggle" class="profile-icon">
88
+ <i class="fa-solid fa-user"></i>
89
+ </div>
90
+
91
+ <div id="profile-dropdown" class="profile-dropdown hidden">
92
+ <div class="profile-info-header">
93
+ <div class="profile-icon-large">
94
+ <i class="fa-solid fa-user"></i>
95
+ </div>
96
+ <div id="dropdown-username" class="dropdown-username">Officer ID</div>
97
+ </div>
98
+
99
+ <hr class="dropdown-separator">
100
+
101
+ <button id="dropdown-logout-btn" class="dropdown-logout-btn">
102
+ <i class="fa-solid fa-right-from-bracket"></i> Logout
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </nav>
107
+ </header>
108
+
109
+ <section class="hero">
110
+
111
+ <div class="sidebar-wrapper">
112
+ <div class="filter-bar">
113
+
114
+ <button id="show-stats-btn" class="pulse-btn" data_tooltip="View Real-time Alert Statistics" data_tooltip_align="left">
115
+ <i class="fa-solid fa-chart-pie"></i>
116
+ </button>
117
+
118
+ <div class="separator"></div>
119
+
120
+ <div class="filter-bar-c">
121
+ <div class="filter-bar-r">
122
+
123
+ <div class="filter-wrap1" data_tooltip="Sort alerts by Time">
124
+ <select id="sort-select" class="filter-select">
125
+ <option value="newest" title="Hi">Newest</option>
126
+ <option value="oldest">Oldest</option>
127
+ </select>
128
+ </div>
129
+
130
+ <div class="filter-wrap2" data_tooltip="Filter by Urgency Level">
131
+ <select id="urgency-select" class="filter-select">
132
+ <option value="all">All Levels</option>
133
+ <option value="High">High</option>
134
+ <option value="Medium">Medium</option>
135
+ <option value="Low">Low</option>
136
+ </select>
137
+ </div>
138
+
139
+ <div class="filter-wrap3" data_tooltip="Filter alerts by status">
140
+ <select id="view-select" class="filter-select">
141
+ <option value="all">All Status</option>
142
+ <option value="active">Active</option>
143
+ <!-- <option value="active">Verified</option> -->
144
+ <option value="archived">Resolved</option>
145
+ </select>
146
+ </div>
147
+ </div>
148
+
149
+ <div class="filter-bar-r">
150
+ <div class="filter-wrap4" data_tooltip="Filter by Disaster Type">
151
+ <select id="type-select" class="filter-select">
152
+ <option value="all">All Disaster Types</option>
153
+ <option value="Flood">Flood</option>
154
+ <option value="Typhoon">Typhoon</option>
155
+ <option value="Earthquake">Earthquake</option>
156
+ <option value="Fire">Fire</option>
157
+ <option value="Volcano">Volcano</option>
158
+ <option value="Landslide">Landslide</option>
159
+ <option value="General Emergency">General</option>
160
+ </select>
161
+ </div>
162
+
163
+ <div class="filter-wrap5" data_tooltip="Filter by Assistance Needed">
164
+ <select id="assist-select" class="filter-select">
165
+ <option value="all">All Assistances</option>
166
+ <option value="Rescue">Rescue</option>
167
+ <option value="Medical">Medical</option>
168
+ <option value="Evacuation">Evacuation</option>
169
+ <option value="Food/Water">Food/Water</option>
170
+ <option value="General Assistance">General</option>
171
+ </select>
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <button id="export-btn" class="icon-btn" data_tooltip="Download Report (CSV)">
177
+ <i class="fa-solid fa-download"></i>
178
+ </button>
179
+
180
+ </div>
181
+
182
+ <div class="sidebar" id="incident-feed">
183
+ <p style="color: white; padding: 20px; text-align: center;">Loading alerts...</p>
184
+ </div>
185
+
186
+ <div class="active-alert-counter">
187
+ <span style="font-size: 0.9em; color: #ccc;">Active Alerts: </span>
188
+ <span id="dashboard-alert-count" style="font-weight: 700; color: #ffc107;">0</span>
189
+ </div>
190
+
191
+ </div>
192
+
193
+ <div class="detail-box" id="postInfo">
194
+ <div class="detail-header-r">
195
+ <h2 class="detail-title" id="detail-title">Select an Alert</h2>
196
+
197
+ <div class="action-buttons">
198
+ <button id="verify-btn" class="action-btn1 verify-btn" onclick="updateStatus('Verified')" data_tooltip="Mark this alert as Verified (Confirmed)">
199
+ <i class="fa-solid fa-check"></i>
200
+ <span class="btn-text">Verify</span>
201
+ </button>
202
+ <button id="resolve-btn" class="action-btn1 resolve-btn" onclick="updateStatus('Resolved')" data_tooltip="Mark this alert as Resolved and dismiss">
203
+ <i class="fa-solid fa-box-archive"></i>
204
+ <span class="btn-text">Resolve</span>
205
+ </button>
206
+ <a href="#" id="detail-link" target="_blank" class="detail-redirect" data_tooltip="Go to Reddit Post">
207
+ <i class="fa-brands fa-reddit-alien"></i> Post
208
+ </a>
209
+ </div>
210
+ </div>
211
+
212
+ <hr class="detail-line">
213
+
214
+ <div class="detail-important-r">
215
+ <div class="detail-row1-c">
216
+ <div>
217
+ <div class="detail-label" style="margin-top: 10px;">Assistance Needed</div>
218
+ <div class="detail-text" id="detail-assistance" style="color: #ed4801; font-weight: bold; text-transform: uppercase;">-</div>
219
+ </div>
220
+ <div>
221
+ <div class="detail-label">Location / Address</div>
222
+ <span class="detail-text" id="detail-location">-</span>
223
+ </div>
224
+
225
+ <div class="detail-row1-col2-r">
226
+ <div class="contact-c">
227
+ <div class="detail-label">Contact Person</div>
228
+ <span class="detail-text" id="detail-contact-name">-</span>
229
+ </div>
230
+
231
+ <div class="contact-c">
232
+ <div class="detail-label">Contact Number</div>
233
+ <span class="detail-text" id="detail-contact-number">-</span>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <div style="text-align: right;">
239
+ <div class="detail-label">Posted</div>
240
+ <div class="time" id="detail-time">_ mins ago at __:__ __</div>
241
+
242
+ <!-- <div class="detail-label" style="margin-top: 10px;">Assistance Needed</div>
243
+ <div id="detail-assistance" style="color: #ed4801; font-weight: bold; text-transform: uppercase;">-</div> -moved above -->
244
+
245
+ <div class="detail-label" style="margin-top: 10px;">Status</div>
246
+ <div class="detail-text" id="detail-urgency" style="color: white;">-</div>
247
+ <div id="detail-status" class="status-badge">New</div>
248
+ </div>
249
+ </div>
250
+ <hr style="margin: 30px 0 20px 0; border: 0; border-top: 1px dashed #444;">
251
+ <div class="detail-label">Post content</div>
252
+ <div class="detail-content" id="detail-body">
253
+ Click on an alert from the sidebar to view full details here.
254
+ </div>
255
+ </div>
256
+
257
+ </section>
258
+
259
+ <script src="script.js"></script>
260
+ </body>
261
+ </html>
alisto_project/backend/ingest_reddit.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncpraw
2
+ import asyncio
3
+ import os
4
+ import torch
5
+ import pickle
6
+ import numpy as np
7
+ import torch.nn.functional as F
8
+ from datetime import datetime
9
+ from dotenv import load_dotenv
10
+ from flask import Flask
11
+ from models import db, DisasterPost
12
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
13
+ from ner_extractor import extract_entities
14
+
15
+ # 1. Config & Setup
16
+ # defines the subreddits to be monitored by the scraper
17
+ SUBREDDITS = "AlistoSimulation"
18
+ # SUBREDDITS = "Philippines+NaturalDisasters+DisasterUpdatePH+Assistance+Typhoon+AlistoSimulation"
19
+
20
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ # loads environment variables from .env file
22
+ load_dotenv(os.path.join(BASE_DIR, '../.env'))
23
+
24
+ # initializes the Flask application context for database access
25
+ app = Flask(__name__)
26
+ DB_PATH = os.path.join(BASE_DIR, 'alisto.db')
27
+ app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
28
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
29
+ # sets a timeout for stable database connection
30
+ app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {'connect_args': {'timeout': 15}}
31
+ db.init_app(app)
32
+
33
+ # 2. Load Models
34
+ print("Loading ALISTO Brains...")
35
+ MODEL_DIR = os.path.join(BASE_DIR, 'models')
36
+ ROBERTA_DIR = os.path.join(MODEL_DIR, 'roberta_model')
37
+ TFIDF_PATH = os.path.join(MODEL_DIR, 'tfidf_ensemble.pkl')
38
+
39
+ # A. RoBERTa (XLM-R Multilingual)
40
+ # loads the RoBERTa tokenizer and sequence classification model (Context Expert)
41
+ try:
42
+ tokenizer = AutoTokenizer.from_pretrained(ROBERTA_DIR)
43
+ roberta_model = AutoModelForSequenceClassification.from_pretrained(ROBERTA_DIR)
44
+ device = torch.device("cpu") # determines the device (CPU/GPU) for model execution
45
+ roberta_model.to(device)
46
+ roberta_model.eval()
47
+ print("✅ Context Expert (XLM-R) loaded")
48
+ except Exception as e:
49
+ print(f"❌ Error loading RoBERTa: {e}")
50
+ exit()
51
+
52
+ # B. TF-IDF (The Gatekeeper)
53
+ # loads the pre-trained TF-IDF vectorizer and ensemble model (Gatekeeper)
54
+ try:
55
+ with open(TFIDF_PATH, 'rb') as f:
56
+ tfidf_model = pickle.load(f)
57
+ print("✅ Gatekeeper (TF-IDF) loaded")
58
+ except Exception as e:
59
+ print(f"❌ Error loading TF-IDF: {e}")
60
+ tfidf_model = None
61
+
62
+ # 3. Reference Lists (Kept from your original)
63
+ # list of Philippine locations used for basic geo-validation
64
+ PHILIPPINE_LOCATIONS = [
65
+ "Philippines", "PH", "Luzon", "Visayas", "Mindanao", "Metro Manila", "NCR",
66
+ "Manila", "Quezon City", "Makati", "Taguig", "Pasig", "Mandaluyong",
67
+ "Marikina", "Las Pinas", "Las Piñas", "Muntinlupa", "Caloocan",
68
+ "Paranaque", "Parañaque", "Valenzuela", "Pasay", "Malabon",
69
+ "Navotas", "San Juan", "Pateros",
70
+ "Cavite", "Naic", "Bacoor", "Imus", "Dasmarinas", "Dasmariñas",
71
+ "General Trias", "Tagaytay", "Kawit", "Noveleta", "Rosario", "Tanza",
72
+ "Silang", "Trece Martires", "Laguna", "Calamba", "Santa Rosa", "Binan",
73
+ "Biñan", "San Pedro", "Cabuyao", "Los Banos", "Los Baños", "Rizal",
74
+ "Antipolo", "Cainta", "Taytay", "San Mateo", "Binangonan", "Batangas",
75
+ "Bulacan", "Pampanga", "Tarlac", "Cebu", "Iloilo", "Tacloban",
76
+ "Davao", "Cagayan", "Bicol", "Albay", "Isabela"
77
+ ]
78
+
79
+ # function to process a single Reddit submission through all filters and save it
80
+ async def process_post(post):
81
+ """handles logic for a single Reddit submission (filtering, AI, saving)"""
82
+ try:
83
+ full_text = f"{post.title} {post.selftext}"
84
+
85
+ # A. Check for Duplicates & Credibility (Unchanged logic)
86
+ # checks for existing post ID in the database
87
+ with app.app_context():
88
+ exists = DisasterPost.query.filter_by(reddit_id=post.id).first()
89
+ if exists: return
90
+ # blocks posts from suspicious new/low-karma accounts
91
+ if not is_credible_user(post):
92
+ print(f"\n------------------- DEBUG REJECTION -------------------")
93
+ print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
94
+ print(f"REASON: Credibility Check (Account too new/Low Karma)")
95
+ print(f"---------------------------------------------------------\n")
96
+ return
97
+
98
+ # B. Logic Filter (First Defense) (Unchanged logic)
99
+ # runs simple keyword checks to filter news/financial/irrelevant content
100
+ is_bad, reason = is_news_or_irrelevant(full_text)
101
+ if is_bad:
102
+ print(f"\n------------------- DEBUG REJECTION -------------------")
103
+ print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
104
+ print(f"REASON: Logic Filter (Common Sense Layer) Categorized as: {reason}")
105
+ print(f"---------------------------------------------------------\n")
106
+ return
107
+
108
+ # C. AI Analysis (Unchanged logic)
109
+ # runs the cascade AI check (TF-IDF then RoBERTa)
110
+ is_urgent, score, source = predict_urgency(full_text)
111
+ if not is_urgent:
112
+ print(f"\n------------------- DEBUG REJECTION -------------------")
113
+ print(f"❌ REJECTED POST ID: {post.id} (Title: {post.title[:30]})")
114
+ print(f"REASON: AI Confidence too low Score: {score:.2%} (Source: {source})")
115
+ print(f"---------------------------------------------------------\n")
116
+ return
117
+
118
+ # D. Entity Extraction
119
+ # extracts location, contact number, and contact person name
120
+ ner_results = extract_entities(full_text)
121
+ city_location = ner_results.get('location', "Unknown Location")
122
+ full_raw_address = ner_results.get('full_address', "Check Post")
123
+ contact_num = ner_results.get('contact', None)
124
+ contact_person_name = ner_results.get('contact_person_name', None)
125
+
126
+ # E. Final Triage and Data Preparation
127
+ # assigns location and determines disaster/assistance type
128
+ if isinstance(city_location, list):
129
+ location = city_location[0] if city_location else "Unknown Location"
130
+ else:
131
+ location = city_location
132
+
133
+ disaster_type = get_disaster_type(full_text)
134
+ assistance_type = get_assistance_type(full_text)
135
+
136
+ # 1. Calculate Dynamic Urgency (NEW)
137
+ # assigns High, Medium, or Low urgency based on severity keywords
138
+ dynamic_urgency = assign_dynamic_urgency(full_text)
139
+
140
+ # 2. Finalize Author (Fallback Logic)
141
+ # defaults to Reddit username if no contact name is explicitly extracted
142
+ reddit_username = str(post.author) if post.author else "Unknown"
143
+ final_author = contact_person_name if contact_person_name else reddit_username
144
+
145
+ # 3. Print Final Alert Confirmation
146
+ print(f"""------------------- ALERT SAVED -------------------\n🚨 ALERT ({score:.2%}): {disaster_type} in {location} Urgency: {dynamic_urgency} \n---------------------------------------------------------""")
147
+
148
+ # F. Single Database Creation and Commit
149
+ # creates and commits the final DisasterPost object to the database
150
+ new_post = DisasterPost(
151
+ reddit_id=post.id,
152
+ title=post.title,
153
+ content=post.selftext or post.title,
154
+ author=final_author,
155
+ location=location,
156
+ full_address=full_raw_address,
157
+ contact_number=contact_num,
158
+ disaster_type=disaster_type,
159
+ assistance_type=assistance_type,
160
+ urgency_level=dynamic_urgency,
161
+ is_help_request=True,
162
+ timestamp=datetime.utcfromtimestamp(post.created_utc)
163
+ )
164
+
165
+ with app.app_context():
166
+ db.session.add(new_post)
167
+ db.session.commit()
168
+
169
+ except Exception as e:
170
+ print(f"Post Processing Error for {post.id}: {e}")
171
+
172
+ # validates if the extracted location is relevant to the Philippines
173
+ def check_for_philippine_location(location_list):
174
+ if not location_list: return False
175
+ ph_locations = [loc.lower() for loc in PHILIPPINE_LOCATIONS]
176
+ for extracted_loc in location_list:
177
+ # Check partial match (e.g., "Marikina City" matches "Marikina")
178
+ for known_loc in ph_locations:
179
+ if known_loc in extracted_loc.lower() or extracted_loc.lower() in known_loc:
180
+ return True
181
+ return False
182
+
183
+ # classifies the type of disaster based on severity keywords
184
+ def get_disaster_type(text):
185
+ text_lower = text.lower()
186
+ mapping = {
187
+ "Earthquake": ["quake", "lindol", "shake", "aftershock"],
188
+ "Landslide": ["landslide", "guho", "mudslide", "natabunan"],
189
+ "Volcano": ["volcano", "lava", "ash", "magma", "taal", "mayon"],
190
+ "Fire": ["fire", "sunog", "burn", "smoke"],
191
+ "Typhoon": ["typhoon", "bagyo", "storm", "wind", "signal", "ulysses", "odette"],
192
+ "Flood": ["flood", "baha", "water", "river", "drown", "lubog", "taas ng tubig"]
193
+ }
194
+
195
+ for dtype, keywords in mapping.items():
196
+ if any(k in text_lower for k in keywords):
197
+ return dtype
198
+ return "General Emergency"
199
+
200
+ # classifies the specific type of assistance needed (e.g., Medical, Rescue, Food)
201
+ def get_assistance_type(text):
202
+ """determines the specific help needed using Nested Priority"""
203
+ text = text.lower()
204
+
205
+ # --- 1. IMMEDIATE RESCUE (Life Threatening) ---
206
+ rescue_kw = [
207
+ "rescue", "saklolo", "trapped", "stuck", "stranded",
208
+ "bubong", "roof", "boat", "bangka", "drowning", "lunod",
209
+ "di makalabas", "unable to leave"
210
+ ]
211
+ if any(k in text for k in rescue_kw):
212
+
213
+ critical_medical_override_kw = [
214
+ "bleeding", "unconscious", "head injury", "head wound",
215
+ "severely bleeding", "stroke", "heart attack", "trauma"
216
+ ]
217
+ if any(k in text for k in critical_medical_override_kw):
218
+ return "Medical"
219
+
220
+ return "Rescue" # if no critical medical keywords found
221
+
222
+ # --- 2. MEDICAL (Specific Needs/Ambulance) ---
223
+ # handles standalone medical needs if no rescue keywords were found
224
+ medical_kw = [
225
+ "medical", "doctor", "gamot", "medicine", "insulin", "dialysis",
226
+ "hospital", "oxygen", "pregnant", "labor", "manganganak", "ambulance",
227
+ "first aid", "pills", "medication"
228
+ ]
229
+ if any(k in text for k in medical_kw):
230
+ return "Medical"
231
+
232
+ # --- 3. EVACUATION (Shelter/Transport) ---
233
+ # classifies the need for temporary shelter or transport
234
+ evac_kw = [
235
+ "evacuate", "evacuation", "shelter", "center", "likas", "tents",
236
+ "matutuluyan", "alis", "transportation", "walang matutuluyan"
237
+ ]
238
+ if any(k in text for k in evac_kw):
239
+ return "Evacuation"
240
+
241
+ # --- 4. FOOD & WATER (Logistics) ---
242
+ # classifies the need for essential supplies (food, water, formula)
243
+ food_kw = [
244
+ "food", "pagkain", "water", "tubig", "gutom", "hungry", "relief",
245
+ "goods", "makakain", "inumin", "groceries", "supplies", "supply", "wala ng stock",
246
+ "gatas", "milk", "formula", "baby supplies", "ubos na", "wala na", "stock", "stock ng"
247
+ ]
248
+ if any(k in text for k in food_kw):
249
+ return "Food/Water"
250
+
251
+ return "General Assistance"
252
+
253
+
254
+ # --- LOGIC FILTERS (The "Common Sense" Layer) ---
255
+ # runs simple logic checks to filter out news reports and non-urgent context
256
+ def is_news_or_irrelevant(text):
257
+ text_lower = text.lower()
258
+
259
+ # 1. NEWS & REPORTS
260
+ news_indicators = [
261
+ "breaking:", "just in:", "news:", "update:", "report:",
262
+ "casualties", "death toll", "according to", "reported that",
263
+ "suspension", "declared", "signal no", "public advisory",
264
+ "weather update", "volcano alert", "mmda", "pagasa"
265
+ ]
266
+
267
+ # 2. MONEY / SELLING
268
+ financial_indicators = [
269
+ "gcash", "paypal", "budget", "loan", "selling",
270
+ "fundraising", "donate", "send funds"
271
+ ]
272
+
273
+ # 3. IRRELEVANT CONTEXT
274
+ irrelevant_contexts = [
275
+ "how can i help", "where to donate", "thoughts and prayers",
276
+ "keep safe", "god bless", "praying for", "discussion:", "opinion:"
277
+ ]
278
+
279
+ # Logic Checks
280
+ if any(ind in text_lower for ind in news_indicators):
281
+ return True, "News/Report"
282
+
283
+ # blocks financial requests unless life-threatening keywords are also present
284
+ has_financial = any(ind in text_lower for ind in financial_indicators)
285
+ is_life_death = any(k in text_lower for k in ["trapped", "lubog", "roof", "rescue", "drowning", "stuck"])
286
+
287
+ if has_financial and not is_life_death:
288
+ return True, "Financial/Non-Urgent"
289
+
290
+ # blocks posts containing non-urgent discussion or commentary
291
+ if any(ctx in text_lower for ctx in irrelevant_contexts):
292
+ return True, "Context/NotUrgent"
293
+
294
+ return False, None
295
+
296
+ # runs the two-stage AI classification check (TF-IDF then RoBERTa)
297
+ def predict_urgency(text):
298
+
299
+ # 1. Gatekeeper (TF-IDF)
300
+ # quickly rejects posts with extremely low urgency confidence (below 10%)
301
+ if tfidf_model:
302
+ tfidf_probs = tfidf_model.predict_proba([text])[0]
303
+ tfidf_conf = tfidf_probs[1]
304
+
305
+ # If the fast model is sure it's junk, skip the heavy lifting
306
+ if tfidf_conf < 0.20:
307
+ return False, tfidf_conf, "TF-IDF Reject"
308
+
309
+ # 2. Context Expert (RoBERTa)
310
+ # runs the slower, context-aware model for final classification
311
+ inputs = tokenizer(text, return_tensors="pt", truncation=True, padding=True, max_length=128)
312
+ with torch.no_grad():
313
+ outputs = roberta_model(**inputs)
314
+ probs = F.softmax(outputs.logits, dim=-1)
315
+ roberta_conf = probs[0][1].item() # Probability of 'Rescue Request'
316
+
317
+ # final acceptance threshold (40%) for the RoBERTa model
318
+ return (roberta_conf > 0.4), roberta_conf, "RoBERTa"
319
+
320
+ # assigns the final severity level (High, Medium, Low) based on severity keywords
321
+ def assign_dynamic_urgency(text):
322
+ text_lower = text.lower()
323
+
324
+ # 1. HIGH URGENCY (Immediate Life-Threatening Event or Critical Medical Need)
325
+ high_keywords = [
326
+ "bleeding", "unconscious", "severely injured", "severe injury", "life threatening",
327
+ "insulin", "oxygen", "ambulance", "urgent medicine", "doctor", "hospital",
328
+
329
+ "trap", "trapped", "bubong", "collapsed", "di mapigilan", "drowning",
330
+ "lampas tao", "lubog", "delikado", "baha na", "mamatay"
331
+ ]
332
+ if any(k in text_lower for k in high_keywords):
333
+ return "High"
334
+
335
+ # 2. MEDIUM URGENCY (Time-Sensitive, Logistical Crisis)
336
+ medium_keywords = [
337
+ "stranded", "running out", "evacuate", "kailangan agad", "lowbat",
338
+ "paubos", "senior", "bedridden", "disabled", "gatas", "formula"
339
+ ]
340
+ if any(k in text_lower for k in medium_keywords):
341
+ return "Medium"
342
+
343
+ # 3. LOW URGENCY (General Supplies/Warning)
344
+ # posts that pass the AI but lack the above severity indicators fall here
345
+ return "Low"
346
+
347
+ # blocks posts from accounts created less than 2 days ago or with negative karma
348
+ def is_credible_user(post):
349
+ try:
350
+ author = post.author
351
+
352
+ # checks if author is deleted or unknown
353
+ if not author:
354
+ return False
355
+
356
+ # 1. Check Account Age (Must be older than 2 days)
357
+ created_time = datetime.utcfromtimestamp(author.created_utc)
358
+ account_age = datetime.utcnow() - created_time
359
+
360
+ if account_age.days < 2:
361
+ print(f"   ⚠️ Blocked: Account too new ({account_age.days} days)")
362
+ return False
363
+
364
+ # 2. Check Karma (Must not be negative)
365
+ total_karma = author.comment_karma + author.link_karma
366
+ if total_karma < -5:
367
+ print(f"   ⚠️ Blocked: Negative Karma ({total_karma})")
368
+ return False
369
+
370
+ return True
371
+
372
+ except Exception as e:
373
+ # allows posts to pass if Reddit API fails to get user info
374
+ return True
375
+
376
+
377
+ # 4. Main Scraper Loop
378
+ # orchestrates the entire scraping process (historical scan + real-time stream)
379
+ async def scrape_reddit():
380
+ print("Connecting to Reddit API...")
381
+
382
+ client_id = os.getenv("REDDIT_CLIENT_ID")
383
+ client_secret = os.getenv("REDDIT_CLIENT_SECRET")
384
+
385
+ if not client_id or not client_secret:
386
+ print("❌ Error: Client ID or Secret missing in .env")
387
+ return
388
+
389
+ # initializes PRAW using the secure Client Credentials Flow (read-only)
390
+ reddit = asyncpraw.Reddit(
391
+ client_id=client_id,
392
+ client_secret=client_secret,
393
+ user_agent=os.getenv("REDDIT_USER_AGENT", "script:alisto_bot:v3.0")
394
+ )
395
+
396
+ try:
397
+ subreddit = await reddit.subreddit(SUBREDDITS)
398
+ print(f"👁️  ALISTO ACTIVE: Monitoring r/{SUBREDDITS}...")
399
+
400
+ # --- PHASE 1: FETCH LATEST EXISTING POSTS (e.g., last 500) ---
401
+ print("🔍 Scanning last 500 posts for missed alerts...")
402
+ # iterates over the last 500 posts asynchronously
403
+ async for post in subreddit.new(limit=500):
404
+ await process_post(post)
405
+
406
+ print("✅ Historical scan complete")
407
+
408
+ # --- PHASE 2: START REAL-TIME STREAM (Forever Loop) ---
409
+ print("📡 Starting real-time stream for new submissions...")
410
+
411
+ # starts the continuous loop to monitor for new submissions
412
+ async for post in subreddit.stream.submissions(skip_existing=False):
413
+ await process_post(post)
414
+
415
+ except Exception as e:
416
+ print(f"Global Scraper Error: {e}")
417
+ finally:
418
+ await reddit.close()
419
+ print("Scraper stopped")
420
+
421
+
422
+ # executes the main scraping loop when the script is run
423
+ if __name__ == "__main__":
424
+ try:
425
+ loop = asyncio.new_event_loop()
426
+ asyncio.set_event_loop(loop)
427
+ loop.run_until_complete(scrape_reddit())
428
+ except KeyboardInterrupt:
429
+ print("\n🛑 Stopped by user")
alisto_project/backend/map.html ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>ALISTO | Live Map</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
8
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;900&family=Roboto:wght@400;500&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
10
+ <link rel="stylesheet" href="style.css">
11
+
12
+ <style>
13
+ /* Specific Map Page Styles */
14
+ body { overflow: hidden; }
15
+
16
+ #map {
17
+ width: 100%;
18
+ height: 100vh;
19
+ z-index: 1;
20
+ }
21
+
22
+ </style>
23
+ </head>
24
+ <body>
25
+
26
+ <div class="map-sidebar">
27
+ <a href="index.html" class="back-btn">← Back to Dashboard</a>
28
+
29
+ <hr style="border: 0; border-top: 1px solid #555; margin: -2px 0 10px 0;">
30
+
31
+ <h1 style="font-family: Montserrat; color: #ed4801; margin-bottom: 0px;">LIVE MAP</h1>
32
+ <p style="font-size: 0.85em; color: #ccc; margin-bottom: 15px;">Real-time disaster tracking</p>
33
+
34
+ <div id="status-text">Connecting to Alisto...</div>
35
+
36
+ <div class="stat-item">
37
+ <span class="stat-highlight" id="alert-count">0</span> Active Alerts
38
+ </div>
39
+ </div>
40
+
41
+ <!-- <div class="sidebar" id="map-incident-feed">
42
+ <p style="color: white; padding: 20px; text-align: center;">Loading alerts...</p>
43
+ </div> -->
44
+
45
+ <div class="map-collapse-sidebar" id="map-sidebar-wrap">
46
+ <div class="map-sidebar-content">
47
+ <div id="map-incident-feed">
48
+ <p style="color:#ccc; font-size: 0.8em; padding: 10px;">Loading active alerts...</p>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <button class="sidebar-toggle-btn" id="sidebar-toggle">
53
+ <i class="fa-solid fa-bars"></i>
54
+ </button>
55
+
56
+ <div id="map"></div>
57
+
58
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
59
+ <script src="map_script.js"></script>
60
+ </body>
61
+ </html>
alisto_project/backend/map_script.js ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 1. EXTENDED COORDINATE DATABASE
2
+ // defines a static database of coordinates for Philippine cities and provinces
3
+ const cityCoords = {
4
+     // --- NCR ---
5
+     "Manila": [14.5995, 120.9842],
6
+     "Quezon City": [14.6760, 121.0437],
7
+     "Makati": [14.5547, 121.0244],
8
+     "Taguig": [14.5176, 121.0509],
9
+     "Pasig": [14.5763, 121.0851],
10
+     "Mandaluyong": [14.5794, 121.0359],
11
+     "Marikina": [14.6333, 121.0980],
12
+     "Las Pinas": [14.4445, 120.9939],
13
+     "Muntinlupa": [14.4081, 121.0415],
14
+     "Caloocan": [14.6401, 120.9745],
15
+     "Parañaque": [14.4793, 121.0198],
16
+     "Valenzuela": [14.7011, 120.9830],
17
+     "Pasay": [14.5378, 121.0014],
18
+     "Malabon": [14.6625, 120.9512],
19
+     "Navotas": [14.6732, 120.9350],
20
+     "San Juan": [14.6019, 121.0355],
21
+     "Pateros": [14.5454, 121.0687],
22
+
23
+     // --- CAVITE ---
24
+     "Cavite": [14.2831, 120.9168],
25
+     "Naic": [14.3168, 120.7628],
26
+     "Bacoor": [14.4624, 120.9645],
27
+     "Imus": [14.4297, 120.9367],
28
+     "Dasmarinas": [14.3294, 120.9367],
29
+     "General Trias": [14.3876, 120.8842],
30
+     "Tagaytay": [14.1153, 120.9621],
31
+     "Kawit": [14.4448, 120.9022],
32
+     "Noveleta": [14.4263, 120.8820],
33
+     "Rosario": [14.4153, 120.8532],
34
+     "Tanza": [14.3949, 120.8532],
35
+     "Silang": [14.2312, 120.9746],
36
+     "Trece Martires": [14.2883, 120.8677],
37
+
38
+     // --- LAGUNA ---
39
+     "Laguna": [14.2166, 121.1667],
40
+     "Calamba": [14.2142, 121.1553],
41
+     "Santa Rosa": [14.3121, 121.1132],
42
+     "Binan": [14.3400, 121.0827],
43
+     "San Pedro": [14.3644, 121.0370],
44
+     "Cabuyao": [14.2796, 121.1219],
45
+     "Los Banos": [14.1708, 121.2413],
46
+
47
+     // --- RIZAL ---
48
+     "Rizal": [14.5906, 121.2236],
49
+     "Antipolo": [14.5844, 121.1763],
50
+     "Cainta": [14.5760, 121.1213],
51
+     "Taytay": [14.5623, 121.1376],
52
+     "San Mateo": [14.6963, 121.1215],
53
+     "Binangonan": [14.4759, 121.1893],
54
+
55
+     // --- BULACAN ---
56
+     "Bulacan": [14.8524, 120.8228],
57
+     "Malolos": [14.8527, 120.8160],
58
+     "Meycauayan": [14.7356, 120.9622],
59
+     "San Jose del Monte": [14.8143, 121.0427],
60
+     "Bocaue": [14.8066, 120.9256],
61
+
62
+     // --- MAJOR PROVINCES / CITIES ---
63
+     "Pampanga": [15.0359, 120.6924],
64
+     "Tarlac": [15.4802, 120.5979],
65
+     "Batangas": [13.7565, 121.0583],
66
+     "Baguio": [16.4023, 120.5960],
67
+     "Cebu": [10.3157, 123.8854],
68
+     "Iloilo": [10.7202, 122.5621],
69
+     "Davao": [7.1907, 125.4553],
70
+     "Cagayan": [17.6133, 121.7302],
71
+     "Bicol": [13.4350, 123.4100],
72
+     "Albay": [13.1391, 123.7438],
73
+     "Tacloban": [11.2442, 125.0039],
74
+     "Zamboanga": [6.9214, 122.0790],
75
+     "Palawan": [9.8349, 118.7384],
76
+     "Mindoro": [13.0264, 121.2227],
77
+     "Isabela": [16.9754, 121.8107],
78
+     "Pangasinan": [15.9236, 120.3392],
79
+     "Philippines": [12.8797, 121.7740] // CENTER OF PH
80
+ };
81
+
82
+ let map;
83
+ let markers = [];
84
+
85
+ // initializes the map view and starts fetching data
86
+ document.addEventListener("DOMContentLoaded", () => {
87
+     // sets the initial map center on the CALABARZON/NCR area
88
+     map = L.map('map', {zoomControl: false}).setView([14.40, 121.00], 10); // Pass options object
89
+
90
+     // adds the dark-themed tile layer to the map
91
+ //     L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
92
+ //         attribution: '&copy; OpenStreetMap &copy; CARTO',
93
+ //         subdomains: 'abcd',
94
+ //         maxZoom: 19
95
+ //     }).addTo(map);
96
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
97
+ attribution: '&copy; OpenStreetMap &copy; CARTO',
98
+ subdomains: 'abcd',
99
+ maxZoom: 19
100
+ }).addTo(map);
101
+
102
+ L.control.zoom({
103
+ position: 'bottomright' // Choose your new position here
104
+ }).addTo(map);
105
+
106
+     console.log("ALISTO Map: Loaded");
107
+     // fetches the first set of post data
108
+     fetchDataAndPlot();
109
+     // sets a timer to refresh data every 30 seconds
110
+     setInterval(fetchDataAndPlot, 30000);
111
+
112
+ const sidebar = document.getElementById('map-sidebar-wrap');
113
+ const toggleBtn = document.getElementById('sidebar-toggle');
114
+ const toggleIcon = toggleBtn ? toggleBtn.querySelector('i') : null;
115
+
116
+ if (sidebar && toggleBtn && toggleIcon) {
117
+ // Use Font Awesome icons for clarity
118
+ const iconOpen = 'fa-bars';
119
+ const iconClose = 'fa-times'; // X icon
120
+
121
+ toggleBtn.addEventListener('click', () => {
122
+ sidebar.classList.toggle('collapsed');
123
+
124
+ // Toggle the icon for visual feedback (Corrected UX Logic)
125
+             if (sidebar.classList.contains('collapsed')) {
126
+                 // If NOW collapsed (hidden), show the OPEN icon (fa-bars)
127
+                 toggleIcon.classList.remove(iconClose);
128
+                 toggleIcon.classList.add(iconOpen); // Show bars to open
129
+             } else {
130
+       ��         // If NOW open (visible), show the CLOSE icon (fa-times)
131
+                 toggleIcon.classList.remove(iconOpen);
132
+                 toggleIcon.classList.add(iconClose); // Show X to close
133
+             }
134
+ });
135
+
136
+ // Ensure the sidebar is not initially collapsed (optional, depends on default CSS)
137
+ // sidebar.classList.remove('collapsed');
138
+ }
139
+ });
140
+
141
+ // fetches the list of active disaster posts from the API
142
+ function fetchDataAndPlot() {
143
+ fetch('/api/posts')
144
+ .then(response => response.json())
145
+ .then(data => {
146
+ // updates the alert count in the map sidebar
147
+ updateSidebar(data);
148
+
149
+ // --- CRITICAL INTEGRATION POINT ---
150
+ // 1. Plot the markers on the map
151
+ plotMarkers(data);
152
+
153
+ // 2. Render the alert list inside the sidebar
154
+ renderMapSidebarFeed(data);
155
+ // --- END INTEGRATION POINT ---
156
+ })
157
+ .catch(err => console.error("Map Fetch Error:", err));
158
+ }
159
+
160
+ // updates the displayed alert count and system status in the floating sidebar
161
+ function updateSidebar(data) {
162
+     const countEl = document.getElementById('alert-count');
163
+     const statusEl = document.getElementById('status-text');
164
+     if(countEl) countEl.innerText = data.length;
165
+     if(statusEl) statusEl.innerText = "System Active";
166
+ }
167
+
168
+ // clears existing markers and plots new circular markers for each post
169
+ function plotMarkers(posts) {
170
+     // removes all existing markers from the map
171
+     markers.forEach(m => map.removeLayer(m));
172
+     markers = [];
173
+
174
+     // iterates through all posts to find coordinates and draw markers
175
+     posts.forEach(async post => {
176
+         // asynchronously finds the latitude and longitude for the location
177
+         let coords = await getCoordinatesSmart(post.location);
178
+
179
+         if (coords) {
180
+             // sets color and size based on post urgency level
181
+             const urgencyColor = post.urgency_level === 'High' ? '#ff4444' : '#ed4801';
182
+             const radius = post.urgency_level === 'High' ? 14 : 8;
183
+
184
+             // creates and adds a circular marker (L.circleMarker) to the map
185
+             const circle = L.circleMarker(coords, {
186
+                 color: urgencyColor,
187
+                 fillColor: urgencyColor,
188
+                 fillOpacity: 0.7,
189
+                 radius: radius
190
+             }).addTo(map);
191
+
192
+             const timeStr = new Date(post.timestamp).toLocaleTimeString();
193
+            
194
+             // binds a detailed pop-up box to the circular marker
195
+ //             circle.bindPopup(`
196
+ //                 <div style="font-family: 'Roboto', sans-serif; color: #333; min-width: 200px;">
197
+ //                     <strong style="text-transform:uppercase; color: #d32f2f; font-size: 1.1em;">
198
+ //                         ${post.disaster_type}
199
+ //                     </strong>
200
+ //                    
201
+ //                     <div style="color: #ed4801; font-weight: 700; font-size: 0.9em; margin-bottom: 4px; text-transform: uppercase;">
202
+ //                         ⚠ ${post.assistance_type || "General Help"}
203
+ //                     </div>
204
+
205
+ //                     <span style="font-size: 0.9em; color: #555;">📍 ${post.location}</span>
206
+ //                    
207
+ //                     <hr style="margin:8px 0; border:0; border-top:1px solid #ccc;">
208
+ //                    
209
+ //                     <div style="font-size: 0.9em; margin-bottom: 5px; font-weight: 500;">
210
+ //                         "${post.title}"
211
+ //                     </div>
212
+ //                    
213
+ //                     <small style="color: #888;">${timeStr}</small>
214
+ //                 </div>
215
+ //             `);
216
+             // adds the new marker to the global array
217
+             markers.push(circle);
218
+         }
219
+     });
220
+ }
221
+
222
+ // --- NEW SMART FINDER (Hybrid: Static List + API) ---
223
+ // function to look up coordinates, prioritizing the static list then Nominatim API
224
+ async function getCoordinatesSmart(locationStr) {
225
+     if (!locationStr) return cityCoords["Philippines"];
226
+
227
+     // 1. Try Static List (Instant)
228
+     // direct match check
229
+     const exactMatch = Object.keys(cityCoords).find(k => k.toLowerCase() === locationStr.toLowerCase());
230
+     if (exactMatch) return cityCoords[exactMatch];
231
+
232
+     // fuzzy match check
233
+     const fuzzyKey = Object.keys(cityCoords).find(city => locationStr.toLowerCase().includes(city.toLowerCase()));
234
+     if (fuzzyKey) return cityCoords[fuzzyKey];
235
+
236
+     // 2. Try OpenStreetMap API (Dynamic)
237
+     // checks local cache before making a network request
238
+     if (!window.coordCache) window.coordCache = {};
239
+     if (window.coordCache[locationStr]) return window.coordCache[locationStr];
240
+
241
+     try {
242
+         console.log(`Fetching coords for: ${locationStr}...`);
243
+         // fetches coordinates from the Nominatim API
244
+         const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${locationStr}, Philippines`);
245
+         const data = await response.json();
246
+        
247
+         // processes and caches the result
248
+         if (data && data.length > 0) {
249
+             const lat = parseFloat(data[0].lat);
250
+             const lon = parseFloat(data[0].lon);
251
+             const result = [lat, lon];
252
+             window.coordCache[locationStr] = result;
253
+             return result;
254
+         }
255
+     } catch (e) {
256
+         console.error("Geocoding failed:", e);
257
+     }
258
+
259
+     // 3. Fallback
260
+     // returns the center of the Philippines if geocoding fails
261
+     return cityCoords["Philippines"];
262
+ }
263
+
264
+ function formatRelativeTime(timestamp) {
265
+ const now = new Date();
266
+ const posted = new Date(timestamp);
267
+ const diffMs = now - posted;
268
+ const diffMins = Math.floor(diffMs / 60000);
269
+
270
+ if (isNaN(diffMins)) return "Unknown time";
271
+ if (diffMins < 1) return "Just now";
272
+ if (diffMins < 60) return diffMins + " mins ago";
273
+ if (diffMins < 1440) {
274
+ const hours = Math.floor(diffMins / 60);
275
+ return hours + (hours === 1 ? " hr ago" : " hrs ago");
276
+ }
277
+ const days = Math.floor(diffMins / 1440);
278
+ return days + (days === 1 ? " day ago" : " days ago");
279
+ }
280
+
281
+ function formatAbsoluteDateTime(timestamp) {
282
+ const posted = new Date(timestamp);
283
+ const dateOptions = { year: 'numeric', month: 'long', day: 'numeric' };
284
+ const formattedDate = posted.toLocaleDateString('en-US', dateOptions);
285
+ const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true };
286
+ const formattedTime = posted.toLocaleTimeString('en-US', timeOptions);
287
+ return `${formattedDate}\n${formattedTime}`;
288
+ }
289
+
290
+ // Function to render the list of posts inside the map's collapsible sidebar
291
+ function renderMapSidebarFeed(posts) {
292
+ const feedContainer = document.getElementById('map-incident-feed');
293
+ if (!feedContainer) return;
294
+
295
+ feedContainer.innerHTML = ''; // Clear previous content
296
+
297
+ if (posts.length === 0) {
298
+ feedContainer.innerHTML = `<p style="color: #ccc; padding: 10px; text-align: center;">No active alerts to display.</p>`;
299
+ return;
300
+ }
301
+
302
+ posts.forEach(post => {
303
+ // Use the CSS classes from index.html: alert-box, box-title, box-subtitle, etc.
304
+ const box = document.createElement('div');
305
+ box.className = 'alert-box'; // This class is defined in style.css for the dashboard look
306
+
307
+ const relativeTimeStr = formatRelativeTime(post.timestamp);
308
+
309
+ let statusColor = '#ed4801';
310
+ if (post.status === 'Verified') statusColor = '#ffc107';
311
+ if (post.status === 'Resolved') statusColor = '#00C851';
312
+
313
+ // Use the CLEAN location for the display
314
+ const locationName = post.location || 'Unknown Area';
315
+
316
+ // Note: The structure inside here matches index.html's alert boxes now
317
+ box.innerHTML = `
318
+ <div class="alert-icon" style="background-color: ${statusColor}"></div>
319
+ <div class="box-text">
320
+ <div class="box-title">
321
+ ${post.disaster_type} • ${locationName}
322
+ <span style="font-size:0.7em; opacity:0.7">(${post.urgency_level.toUpperCase()})</span>
323
+ </div>
324
+
325
+ <div style="font-size: 0.8em; color: #ccc; margin-top: 3px;">
326
+ ${relativeTimeStr}
327
+ </div>
328
+ </div>
329
+ `; //📍
330
+
331
+ // Add click listener to zoom map to the marker location
332
+ box.addEventListener('click', async () => {
333
+ let coords = await getCoordinatesSmart(locationName);
334
+ if (coords) {
335
+ map.setView(coords, 14); // Zoom to the location with a zoom level of 14
336
+
337
+ // // Optional: Collapse sidebar after clicking an alert
338
+ // document.getElementById('map-sidebar-wrap').classList.add('collapsed');
339
+ // // You may need to manually update the icon here too
340
+ }
341
+ });
342
+
343
+ feedContainer.appendChild(box);
344
+ });
345
+ }
alisto_project/backend/models.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask_sqlalchemy import SQLAlchemy
2
+ from datetime import datetime
3
+
4
+ # initializes the SQLAlchemy object for the application
5
+ db = SQLAlchemy()
6
+
7
+ # defines the main data model for a scraped disaster post
8
+ class DisasterPost(db.Model):
9
+ # sets the name of the database table
10
+ __tablename__ = 'posts'
11
+
12
+ # primary key for the table
13
+ id = db.Column(db.Integer, primary_key=True)
14
+ # unique ID from Reddit to prevent duplicates
15
+ reddit_id = db.Column(db.String(50), unique=True, nullable=False)
16
+ # title of the Reddit post
17
+ title = db.Column(db.String(300), nullable=False)
18
+ # full body content of the post
19
+ content = db.Column(db.Text, nullable=True)
20
+ # stores the contact person's name or Reddit username
21
+ author = db.Column(db.String(50), nullable=True)
22
+ # timestamp when the post was created (UTC)
23
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow)
24
+
25
+ # Intelligence Fields
26
+ # extracted location or address of the incident
27
+ location = db.Column(db.String(100), nullable=True)
28
+
29
+ full_address = db.Column(db.String(300), nullable=True)
30
+ # extracted mobile or contact number
31
+ contact_number = db.Column(db.String(50), nullable=True)
32
+ # AI-classified type of disaster (e.g., Flood, Fire)
33
+ disaster_type = db.Column(db.String(50), nullable=True)
34
+ # AI-classified specific assistance needed (e.g., Medical, Rescue)
35
+ assistance_type = db.Column(db.String(50), nullable=True)
36
+ # dynamically assigned severity level (High, Medium, Low)
37
+ urgency_level = db.Column(db.String(20), nullable=True)
38
+ # boolean flag indicating if the post is a help request
39
+ is_help_request = db.Column(db.Boolean, default=False)
40
+
41
+ # Metadata
42
+ # operational status of the post (New, Verified, Resolved)
43
+ status = db.Column(db.String(20), default="New")
44
+
45
+ # converts the model object into a dictionary format for API responses
46
+ def to_dict(self):
47
+ # converts the timestamp to ISO 8601 format
48
+ iso_time = self.timestamp.isoformat()
49
+ # ensures the time string ends with 'Z' for standardized UTC representation
50
+ if not iso_time.endswith("Z"):
51
+ iso_time += "Z"
52
+
53
+ return {
54
+ "id": self.id,
55
+ "reddit_id": self.reddit_id,
56
+ "title": self.title,
57
+ "content": self.content,
58
+ "author": self.author,
59
+ "timestamp": iso_time,
60
+ "location": self.location,
61
+ "full_address": self.full_address or "Check Post",
62
+ "contact_number": self.contact_number or "Check Post",
63
+ "disaster_type": self.disaster_type,
64
+ "assistance_type": self.assistance_type,
65
+ "urgency_level": self.urgency_level,
66
+ "status": self.status
67
+ }
alisto_project/backend/ner_extractor.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from transformers import AutoTokenizer, AutoModelForTokenClassification, pipeline
2
+ import re
3
+ from templates import LOCATIONS
4
+
5
+ SUFFIXES_TO_REMOVE = [" City", " Province", " Region", " Cty", " Prov"]
6
+ SEPARATORS = [",", "(", "\n", " - "]
7
+
8
+ # Load NER model once
9
+ model_name = "Davlan/xlm-roberta-base-ner-hrl"
10
+ # loads the tokenizer for the multilingual NER model
11
+ tokenizer = AutoTokenizer.from_pretrained(model_name, force_download=True, use_fast=False)
12
+ # loads the pre-trained token classification model (the NER engine)
13
+ model = AutoModelForTokenClassification.from_pretrained(model_name, force_download=True)
14
+ # creates a Hugging Face pipeline for easy entity recognition
15
+ nlp = pipeline("ner", model=model, tokenizer=tokenizer, aggregation_strategy="simple")
16
+
17
+ # list of keywords often incorrectly classified as locations by the NER model
18
+ NON_LOC_KEYWORDS = ["S.O.S", "PLS", "HELP", "URGENT", "ALERT", "LOCATION", "ADDRESS", "ASAP"]
19
+
20
+ def clean_location_string(location_str):
21
+ """Cleans a raw location string to prioritize and return the main City/Province name."""
22
+ if not location_str:
23
+ return "Unknown Location"
24
+
25
+ # 1. Strip common suffixes and trim whitespace
26
+ working_str = location_str.strip()
27
+ for suffix in SUFFIXES_TO_REMOVE:
28
+ if working_str.lower().endswith(suffix.lower()):
29
+ working_str = working_str[:-len(suffix)]
30
+ break
31
+
32
+ # --- 2. Primary Check: Prioritize Known Locations on the full (or suffix-stripped) string ---
33
+ working_lower = working_str.lower()
34
+
35
+ # Check the working_str directly
36
+ for city in sorted(LOCATIONS, key=len, reverse=True):
37
+ if city.lower() == working_lower:
38
+ return city
39
+ if city.lower() in working_lower:
40
+ return city
41
+
42
+
43
+ # --- 3. Simplification Fallback: Get the last segment based on separators ---
44
+
45
+ if ',' in working_str:
46
+ cleaned_segment = working_str.split(',')[-1].strip()
47
+ else:
48
+ cleaned_segment = working_str
49
+ for sep in SEPARATORS:
50
+ if sep in cleaned_segment:
51
+ cleaned_segment = cleaned_segment.split(sep)[-1].strip()
52
+
53
+ # 4. Re-strip Suffixes from the simplified segment
54
+ for suffix in SUFFIXES_TO_REMOVE:
55
+ if cleaned_segment.lower().endswith(suffix.lower()):
56
+ cleaned_segment = cleaned_segment[:-len(suffix)]
57
+ break
58
+
59
+ # 5. Re-run the known location check on the simplified segment
60
+ cleaned_segment_lower = cleaned_segment.lower()
61
+ for city in sorted(LOCATIONS, key=len, reverse=True):
62
+ if city.lower() == cleaned_segment_lower:
63
+ return city
64
+
65
+ # --- 6. Aggressive Check (If all else fails, use the last 1-2 words) ---
66
+ words = cleaned_segment.split()
67
+ if len(words) > 2:
68
+ return " ".join(words[-2:])
69
+
70
+ # Final Guarantee
71
+ return cleaned_segment or location_str.strip()
72
+ # --- END: Robust clean_location_string ---
73
+
74
+
75
+ # function to extract the contact person's name using Regex
76
+ def extract_contact_person_name(text):
77
+ # regex pattern to find name following contact/name keywords
78
+ name_pattern = re.compile(
79
+ # Look for: contact person, icontact si, or contact name (Filipino and English)
80
+ r'(?:contact\s*person|contact\s*name|icontact\s*si|contact|pangalan)\s*[:\s]*(.*?)\s*'
81
+ # Stop capturing when the mobile number, needs, or next paragraph starts
82
+ r'(?=\s*(?:Mobile|Number|Needs|09\d{2}|[A-Z]{3,}:|\n|$))',
83
+
84
+ # r'(?:contact\s*person|contact\s*name|contact|icontact|si|pangalan|name):\s*(.*?)(?=\s*(?:Mobile|Number|Needs|09\d{2}|[A-Z]{3,}:|\n|$))',
85
+ re.IGNORECASE | re.DOTALL
86
+ )
87
+
88
+ match = name_pattern.search(text)
89
+ if match:
90
+ # returns the captured name after removing parenthetical nicknames and periods
91
+ name = match.group(1).strip()
92
+ return re.sub(r'\s*\(.*\)\s*$', '', name).strip().rstrip('.')
93
+ return None
94
+
95
+ # function to extract mobile or landline phone numbers using Regex
96
+ def extract_contact_number(text):
97
+
98
+ # regex for common Philippine mobile and landline patterns
99
+ phone_pattern = re.compile(r'(09\d{2}\s?-?\s?\d{3}\s?-?\s?\d{4}|(?:\+63|63)?\d{10})')
100
+
101
+ match = phone_pattern.search(text)
102
+ if match:
103
+ # returns the captured phone number
104
+ return match.group(0).strip()
105
+ return None
106
+
107
+ # function to extract the detailed address line by prioritizing explicit labels
108
+ def extract_address_line(text):
109
+ # regex pattern to capture address text following keywords, stopping at noise or new lines
110
+ address_pattern = re.compile(
111
+ # Look for: location, lokasyon, nandito, address, loc, near (Filipino and English)
112
+ r'(?:location|lokasyon|nandito|address|loc|near)\s*'
113
+ # Possible separators: colon, space, or Filipino polite particles
114
+ r'(?:[:\s]|ay|sa|po\s*namin\s*ay|po\s*ay)\s*'
115
+ # Capture the address until a noise pattern or new line
116
+ r'(.*?)\s*'
117
+ # Stop capturing when a new line, parenthesis, contact/mobile keywords, or common Filipino phrases appear
118
+ r'(?=\s*\n|\s*\(|\s*Contact|\s*Mobile|\s*Number|\s*icontact\s*si|\s*kami|\s*yung|\s*bahay|$)',
119
+
120
+ # r'(?:location|lokasyon|nandito|address|loc|near):\s*(.*?)(?=\s*\n|\s*\(|\s*Contact|\s*Mobile|\s*Number|\s*kami|\s*yung|\s*bahay|\s*kmi|\s*near\s|$)',
121
+ re.IGNORECASE
122
+ )
123
+
124
+ match = address_pattern.search(text)
125
+ if match:
126
+ # returns the cleaned address line
127
+ return match.group(1).strip().rstrip('.')
128
+ return None
129
+
130
+ # main function that orchestrates all entity extraction and filtering
131
+ def extract_entities(text):
132
+
133
+ # 1. Get explicit address via Regex (This is a candidate for full_address)
134
+ explicit_address_candidate = extract_address_line(text)
135
+
136
+ # 2. Run general NER extraction
137
+ results = nlp(text)
138
+
139
+ # Stores ALL potential location strings (Explicit, NER, etc.)
140
+ potential_locations = []
141
+
142
+ # 3. Add Explicit Address as the *first* candidate
143
+ if explicit_address_candidate:
144
+ potential_locations.append(explicit_address_candidate)
145
+
146
+ # 4. Process NER results and add them as candidates
147
+ for entity in results:
148
+ if entity['entity_group'] == 'LOC':
149
+ clean_loc = entity['word'].replace("##", "").strip()
150
+
151
+ # CRITICAL NEW FILTER: If the NER tag is too long (more than 5 words), skip it.
152
+ # This prevents a full address recognized by NER from polluting the results.
153
+ if len(clean_loc.split()) > 5:
154
+ continue
155
+
156
+ # Standard Filters
157
+ if len(clean_loc) <= 2: continue
158
+ if clean_loc.upper().replace('.', '') in NON_LOC_KEYWORDS: continue
159
+
160
+ if clean_loc not in potential_locations:
161
+ potential_locations.append(clean_loc)
162
+
163
+ # --- CORE LOGIC: Find the best city match from ALL candidates ---
164
+ best_city_name = None
165
+ best_full_address = explicit_address_candidate # Default to the full Regex address
166
+
167
+ # Go through every candidate found, prioritizing the one that leads to a known city
168
+ for raw_loc_candidate in potential_locations:
169
+
170
+ # 5. Use the highly reliable cleaning logic
171
+ cleaned_city = clean_location_string(raw_loc_candidate)
172
+
173
+ # 6. Check if the result of cleaning is a recognized city name (from LOCATIONS)
174
+ if cleaned_city in LOCATIONS:
175
+ best_city_name = cleaned_city
176
+ best_full_address = raw_loc_candidate
177
+ # We found the perfect city match! STOP IMMEDIATELY.
178
+ break
179
+
180
+ # 7. Final Fallbacks (Only if no canonical city was found in the loop)
181
+ city_location = best_city_name or clean_location_string(best_full_address or text)
182
+
183
+ # 8. Extract Contacts
184
+ contact_number = extract_contact_number(text)
185
+ contact_name = extract_contact_person_name(text)
186
+
187
+ # 9. Return final result
188
+ return {
189
+ "location": city_location if city_location != "Unknown Location" else "Unknown City",
190
+ "full_address": best_full_address or "Check Post",
191
+ "contact": contact_number,
192
+ "contact_person_name": contact_name,
193
+ "raw": results
194
+ }
alisto_project/backend/script.js ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // GLOBAL VARIABLES
2
+ let typeChart = null;
3
+ let urgencyChart = null;
4
+ let lastClickedId = null;
5
+ let knownPostIds = new Set();
6
+ let readPostIds = new Set();
7
+ let isLoggedIn = false; // track login state
8
+ let isInitialLoadComplete = false; // flag to prevent false alerts on page load
9
+ let currentUsername = ''; // stores the logged-in user's username
10
+
11
+ document.addEventListener("DOMContentLoaded", function() {
12
+ console.log("ALISTO Dashboard Loaded");
13
+
14
+ // 1. Check Login Status Immediately
15
+ checkLoginStatus();
16
+
17
+ // 2. Init Data
18
+ initCharts();
19
+ fetchPosts();
20
+ fetchStats();
21
+
22
+ setInterval(() => { fetchPosts(); fetchStats(); }, 30000);
23
+
24
+ // 3. Filter Listeners
25
+ document.getElementById('sort-select').addEventListener('change', () => fetchPosts());
26
+ document.getElementById('view-select').addEventListener('change', () => fetchPosts());
27
+ document.getElementById('urgency-select').addEventListener('change', () => fetchPosts());
28
+ document.getElementById('type-select').addEventListener('change', () => fetchPosts());
29
+ document.getElementById('assist-select').addEventListener('change', () => fetchPosts());
30
+
31
+ // 4. Export (Check login first)
32
+ document.getElementById('export-btn').addEventListener('click', () => {
33
+ if(!isLoggedIn) { alert("Responders Only. Please Log In."); return; }
34
+ window.location.href = '/api/export';
35
+ });
36
+
37
+ // 5. Stats Modal
38
+ const statsModal = document.getElementById('stats-modal');
39
+ document.getElementById('show-stats-btn').addEventListener('click', () => {
40
+ statsModal.classList.remove('hidden');
41
+ fetchStats();
42
+ });
43
+ document.getElementById('close-stats-btn').addEventListener('click', () => statsModal.classList.add('hidden'));
44
+
45
+ // 6. Login Modal Logic
46
+ setupLoginLogic();
47
+ setupSearch();
48
+ setupProfileDropdown();
49
+
50
+ // 🚨 The manual button listeners were correctly removed from here in the previous step.
51
+ });
52
+
53
+ // ----------------------------------------------------------------------
54
+ // ACTION BUTTON VISUAL SYNC LOGIC
55
+ // ----------------------------------------------------------------------
56
+
57
+ function updateActionButtons(postStatus) {
58
+ const verifyBtn = document.getElementById('verify-btn');
59
+ const resolveBtn = document.getElementById('resolve-btn');
60
+
61
+ if (!verifyBtn || !resolveBtn) return; // Safety check
62
+
63
+ // 1. Reset all active states first (CRITICAL STEP)
64
+ verifyBtn.classList.remove('is-verified');
65
+ resolveBtn.classList.remove('is-resolved');
66
+
67
+ // Reset button text
68
+ document.querySelector('#verify-btn .btn-text').textContent = 'Verify';
69
+ document.querySelector('#resolve-btn .btn-text').textContent = 'Resolve';
70
+
71
+ // 2. Apply Active State Classes based *only* on the current status
72
+
73
+ if (postStatus === 'Verified') {
74
+ // If Verified, apply the 'is-verified' style
75
+ verifyBtn.classList.add('is-verified');
76
+ document.querySelector('#verify-btn .btn-text').textContent = 'Verified';
77
+
78
+ } else if (postStatus === 'Resolved') {
79
+ // If Resolved, apply the 'is-resolved' style
80
+ resolveBtn.classList.add('is-resolved');
81
+ document.querySelector('#resolve-btn .btn-text').textContent = 'Resolved';
82
+ }
83
+ }
84
+
85
+ // ----------------------------------------------------------------------
86
+ // AUTHENTICATION LOGIC
87
+ // ----------------------------------------------------------------------
88
+
89
+ // checks the user's current login status via API
90
+ function checkLoginStatus() {
91
+ fetch('/api/user_status')
92
+ .then(res => res.json())
93
+ .then(data => {
94
+ isLoggedIn = data.is_logged_in;
95
+ currentUsername = data.username || '';
96
+ updateUIForAuth();
97
+ });
98
+ }
99
+
100
+ // toggles the visibility of login links, profile dropdown, and action buttons
101
+ function updateUIForAuth() {
102
+ const navBtn = document.getElementById('nav-login-btn');
103
+ const profileWrap = document.getElementById('profile-container-wrap');
104
+ const dropdownUsername = document.getElementById('dropdown-username');
105
+ const actionButtonsContainer = document.querySelector('.action-buttons');
106
+
107
+ if (isLoggedIn) {
108
+ // LOGGED IN: Show Profile Icon, Update Name, Hide Login Link
109
+ if (navBtn) navBtn.style.display = 'none';
110
+ if (profileWrap) profileWrap.style.display = 'flex'; // Show the profile icon container
111
+ if (dropdownUsername) dropdownUsername.innerText = currentUsername;
112
+ if (actionButtonsContainer) actionButtonsContainer.style.visibility = 'visible';
113
+ } else {
114
+ // LOGGED OUT: Show Login Link, Hide Profile Icon
115
+ if (navBtn) navBtn.style.display = 'inline-block';
116
+ if (profileWrap) profileWrap.style.display = 'none'; // Hide the profile icon container
117
+ if (actionButtonsContainer) actionButtonsContainer.style.visibility = 'hidden';
118
+ }
119
+ }
120
+
121
+ // handles click events for the new profile icon and logout button inside the dropdown
122
+ function setupProfileDropdown() {
123
+ const profileToggle = document.getElementById('profile-toggle');
124
+ const profileDropdown = document.getElementById('profile-dropdown');
125
+ const logoutBtn = document.getElementById('dropdown-logout-btn');
126
+
127
+ // Get the reference to the existing logout modal element
128
+ const logoutModal = document.getElementById('logout-modal');
129
+
130
+ // 1. Toggle visibility when clicking the icon (Unchanged)
131
+ if (profileToggle) {
132
+ profileToggle.addEventListener('click', (e) => {
133
+ e.stopPropagation();
134
+ profileDropdown.classList.toggle('hidden');
135
+ });
136
+ }
137
+
138
+ // 2. Logout button handler (MODIFIED BLOCK)
139
+ if (logoutBtn) {
140
+ logoutBtn.addEventListener('click', (e) => {
141
+ e.preventDefault();
142
+
143
+ // Action: Show the confirmation modal instead of redirecting
144
+ if (logoutModal) {
145
+ logoutModal.classList.remove('hidden');
146
+ } else {
147
+ // Fallback, should not happen if index.html is correct
148
+ window.location.href = '/api/logout';
149
+ }
150
+ });
151
+ }
152
+
153
+ // 3. Close when clicking outside (Unchanged)
154
+ document.addEventListener('click', (e) => {
155
+ if (profileToggle && profileDropdown && !profileToggle.contains(e.target) && !profileDropdown.contains(e.target)) {
156
+ if (!profileDropdown.classList.contains('hidden')) {
157
+ profileDropdown.classList.add('hidden');
158
+ }
159
+ }
160
+ });
161
+ }
162
+
163
+ // sets up handlers for the login and logout modals
164
+ function setupLoginLogic() {
165
+ const loginModal = document.getElementById('login-modal');
166
+ const logoutModal = document.getElementById('logout-modal');
167
+ const navBtn = document.getElementById('nav-login-btn');
168
+ const closeBtn = document.getElementById('close-login-btn');
169
+ const submitBtn = document.getElementById('login-submit-btn');
170
+
171
+ // 1. NAV BUTTON CLICK HANDLER
172
+ navBtn.addEventListener('click', (e) => {
173
+ e.preventDefault();
174
+ if (isLoggedIn) {
175
+ logoutModal.classList.remove('hidden');
176
+ } else {
177
+ loginModal.classList.remove('hidden');
178
+ }
179
+ });
180
+
181
+ // 2. LOGOUT MODAL HANDLERS
182
+ document.getElementById('confirm-logout-btn').addEventListener('click', () => {
183
+ window.location.href = '/api/logout';
184
+ });
185
+
186
+ document.getElementById('cancel-logout-btn').addEventListener('click', () => {
187
+ logoutModal.classList.add('hidden');
188
+ });
189
+
190
+ // 3. LOGIN MODAL HANDLERS
191
+ closeBtn.addEventListener('click', () => loginModal.classList.add('hidden'));
192
+
193
+ submitBtn.addEventListener('click', () => {
194
+ const u = document.getElementById('username').value;
195
+ const p = document.getElementById('password').value;
196
+
197
+ fetch('/api/login', {
198
+ method: 'POST',
199
+ headers: {'Content-Type': 'application/json'},
200
+ body: JSON.stringify({username: u, password: p})
201
+ })
202
+ .then(res => {
203
+ if(res.ok) return res.json();
204
+ throw new Error('Invalid credentials');
205
+ })
206
+ .then(data => {
207
+ isLoggedIn = true;
208
+ updateUIForAuth();
209
+ loginModal.classList.add('hidden');
210
+ document.getElementById('username').value = '';
211
+ document.getElementById('password').value = '';
212
+ document.getElementById('login-error').innerText = '';
213
+ })
214
+ .catch(err => {
215
+ document.getElementById('login-error').innerText = "Invalid Username or Password";
216
+ });
217
+ });
218
+ }
219
+
220
+ // calculates and formats the time difference for 'X hrs ago'
221
+ function formatRelativeTime(timestamp) {
222
+ const now = new Date();
223
+ const posted = new Date(timestamp);
224
+ const diffMs = now - posted;
225
+ const diffMins = Math.floor(diffMs / 60000);
226
+
227
+ if (isNaN(diffMins)) return "Unknown time";
228
+ if (diffMins < 1) return "Just now";
229
+ if (diffMins < 60) return diffMins + " mins ago";
230
+ if (diffMins < 1440) {
231
+ const hours = Math.floor(diffMins / 60);
232
+ return hours + (hours === 1 ? " hr ago" : " hrs ago");
233
+ }
234
+ const days = Math.floor(diffMins / 1440);
235
+ return days + (days === 1 ? " day ago" : " days ago");
236
+ }
237
+
238
+ // formats the absolute date and time in the specified multi-line format
239
+ function formatAbsoluteDateTime(timestamp) {
240
+ const posted = new Date(timestamp);
241
+
242
+ // 1. Set options for the full date (e.g., "December 12, 2025")
243
+ const dateOptions = { year: 'numeric', month: 'long', day: 'numeric' };
244
+ const formattedDate = posted.toLocaleDateString('en-US', dateOptions);
245
+
246
+ // 2. Set options for the time (e.g., "08:52 PM")
247
+ const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: true };
248
+ const formattedTime = posted.toLocaleTimeString('en-US', timeOptions);
249
+
250
+ // Combine them as requested
251
+ return `${formattedDate}\n${formattedTime}`;
252
+ }
253
+ // ----------------------------------------------------------------------
254
+ // RENDER LOGIC
255
+ // ----------------------------------------------------------------------
256
+
257
+ // renders the list of incident posts in the sidebar feed
258
+ function renderSidebar(data) {
259
+ const sidebar = document.getElementById('incident-feed');
260
+ if (!sidebar) return;
261
+ sidebar.innerHTML = '';
262
+
263
+ const countEl = document.getElementById('dashboard-alert-count');
264
+ if (countEl) {
265
+ countEl.innerText = data.length;
266
+ }
267
+
268
+ if (data.length === 0) {
269
+ sidebar.innerHTML = `<p style="color: #ccc; padding: 20px; text-align: center;">No alerts found.</p>`;
270
+ return;
271
+ }
272
+
273
+ data.forEach(post => {
274
+ const box = document.createElement('div');
275
+ box.className = 'alert-box';
276
+
277
+ if (post.id === lastClickedId) box.classList.add('selected');
278
+ else if (post.status === 'New' && !readPostIds.has(post.id)) box.classList.add('unread');
279
+
280
+ const relativeTimeStr = formatRelativeTime(post.timestamp);
281
+ // const timeStr = new Date(post.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
282
+
283
+ let statusColor = '#ed4801'; /*ed4801*/
284
+ if (post.status === 'Verified') statusColor = '#ffc107'; /*00C851*/
285
+ if (post.status === 'Resolved') statusColor = '#00C851'; /*33b5e5*/
286
+
287
+ let statusText = `(${post.status})`;
288
+ if (post.status === 'New' && readPostIds.has(post.id)) statusText = "";
289
+
290
+ // Determine Badge Color Class
291
+ let assistClass = 'assist-badge'; // Default
292
+ const type = (post.assistance_type || "").toLowerCase();
293
+ if (type.includes('medical')) assistClass += ' assist-medical';
294
+ else if (type.includes('rescue')) assistClass += ' assist-rescue';
295
+ else if (type.includes('food')) assistClass += ' assist-food';
296
+ else if (type.includes('evac')) assistClass += ' assist-evac';
297
+
298
+ box.innerHTML = `
299
+ <div class="alert-icon" style="background-color: ${statusColor}"></div>
300
+ <div class="box-text">
301
+ <div class="box-title">
302
+ ${post.disaster_type} • ${post.location}
303
+ <span style="font-size:0.7em; opacity:0.7">${statusText}</span>
304
+ </div>
305
+
306
+ <div style="display: flex; align-items: center; margin-top: 4px; gap: 5px;">
307
+ <span class="${assistClass}">${post.assistance_type || "General"}</span>
308
+ <div class="box-subtitle" style="margin-top:0;">${relativeTimeStr}</div>
309
+ </div>
310
+ </div>
311
+ `; // removed ${post.full_address} •
312
+
313
+ box.addEventListener('click', () => {
314
+ const detailBox = document.getElementById('postInfo');
315
+ if (lastClickedId === post.id) {
316
+ detailBox.classList.remove('active');
317
+ box.classList.remove('selected');    
318
+ lastClickedId = null;
319
+ renderSidebar(data);
320
+ } else {
321
+ document.querySelectorAll('.alert-box').forEach(el => el.classList.remove('selected'));
322
+ box.classList.add('selected');
323
+ lastClickedId = post.id;
324
+ readPostIds.add(post.id);
325
+
326
+ updateDetailView(post);
327
+ detailBox.classList.add('active');
328
+
329
+ // CRITICAL: Check auth again to show/hide buttons for this specific detail view
330
+ updateUIForAuth();
331
+
332
+ renderSidebar(data);
333
+ }
334
+ });
335
+ sidebar.appendChild(box);
336
+ });
337
+ }
338
+
339
+ // updates the detail panel with the selected post's information
340
+ function updateDetailView(post) {
341
+ const setText = (id, text) => {
342
+ const el = document.getElementById(id);
343
+ if (el) el.innerText = text;
344
+ };
345
+
346
+ // setText('detail-title', post.title);
347
+ const titleEl = document.getElementById('detail-title');
348
+ if (titleEl) {
349
+ // Safely extract and capitalize the disaster type
350
+ const disaster = (post.disaster_type || 'INCIDENT').toUpperCase();
351
+
352
+ // Safely extract and capitalize the location (City)
353
+ const locationName = (post.location || 'UNKNOWN LOCATION').toUpperCase();
354
+
355
+ // Set the new title format
356
+ titleEl.textContent = `${disaster} in ${locationName}`;
357
+ }
358
+
359
+ setText('detail-location', post.full_address || "Unknown Address");
360
+ setText('detail-assistance', post.assistance_type || "General");
361
+ setText('detail-contact-name', post.author ? (
362
+ // Check if the name contains a space (suggests a full name)
363
+ // If no space is found, assume it is a username and prepend 'u/'
364
+ post.author.includes(' ') ? post.author : `u/${post.author}`
365
+ ) : "Unknown");
366
+ setText('detail-contact-number', post.contact_number || "Check Post");
367
+ // setText('detail-body', post.content);
368
+ const bodyEl = document.getElementById('detail-body');
369
+
370
+ if (bodyEl) {
371
+ const postTitle = post.title || 'No Original Title Provided';
372
+ const postBody = post.content || 'No Body Text Provided';
373
+
374
+ // Use HTML structure to clearly present both original title and body.
375
+ bodyEl.innerHTML = `
376
+ <div>
377
+ <p style="font-weight: bold; margin-bottom: 5px; color: #ddd; font-size: 1.15em;">${postTitle}</p>
378
+ </div>
379
+
380
+ <p style="font-size: 1.05em; line-height: 1.5;">${postBody}</p>
381
+ `; // <p style="font-size: 0.75em; line-height: 1.5;">Post content: </p>
382
+ }
383
+ setText('detail-status', post.status);
384
+
385
+ const statusBadge = document.getElementById('detail-status');
386
+ if (statusBadge) {
387
+ // Clear all previous status classes
388
+ statusBadge.classList.remove('status-new', 'status-verified', 'status-resolved');
389
+
390
+ // Apply the correct new class
391
+ if (post.status) {
392
+ statusBadge.classList.add(`status-${post.status.toLowerCase()}`);
393
+ }
394
+ }
395
+
396
+ const link = document.getElementById('detail-link');
397
+ if (link) {
398
+ const isSimulated = typeof post.reddit_id === 'string' && (post.reddit_id.startsWith('fake') || post.reddit_id.startsWith('sim'));
399
+ link.href = isSimulated ? '#' : `https://reddit.com/comments/${post.reddit_id.replace('t3_', '')}`;
400
+ }
401
+
402
+ const urgEl = document.getElementById('detail-urgency');
403
+ if (urgEl) {
404
+ urgEl.innerText = post.urgency_level;
405
+ urgEl.style.color = post.urgency_level === 'High' ? '#ff4444' : '#00C851';
406
+ }
407
+
408
+ const timeEl = document.getElementById('detail-time');
409
+
410
+ if (timeEl) {
411
+ // 1. Get the Date object from the post timestamp
412
+ const posted = new Date(post.timestamp);
413
+
414
+ // 2. Format the Date part (e.g., December 12, 2025)
415
+ const dateStr = posted.toLocaleDateString('en-US', {
416
+ year: 'numeric',
417
+ month: 'long',
418
+ day: 'numeric'
419
+ });
420
+
421
+ // 3. Format the Time part (e.g., 08:52 PM)
422
+ const exactTimeStr = posted.toLocaleTimeString([], {
423
+ hour: '2-digit',
424
+ minute: '2-digit',
425
+ hour12: true
426
+ });
427
+
428
+ // 4. Generate the new HTML structure
429
+ timeEl.innerHTML = `
430
+ <div style="font-size: 1.3em; color: white; line-height: 1.2;">${dateStr}</div>
431
+ <div style="font-size: 1.1em; color: #ed4801; margin-top: 2px;">${exactTimeStr}</div>
432
+ `;
433
+ }
434
+
435
+ // Synchronize buttons with the loaded post status
436
+ updateActionButtons(post.status);
437
+ }
438
+
439
+ // ----------------------------------------------------------------------
440
+ // STANDARD FUNCTIONS (FINAL WORKING VERSION)
441
+ // ----------------------------------------------------------------------
442
+
443
+ // handles the logic for updating the post status (Verify/Resolve)
444
+ function updateStatus(intendedStatus) {
445
+ if (!lastClickedId) return;
446
+ const badge = document.getElementById('detail-status');
447
+ // FIX: Read status and clean it by converting to lowercase and trimming whitespace
448
+ const currentStatus = badge ? badge.innerText.trim() : 'New';
449
+ let finalStatus;
450
+
451
+ if (intendedStatus === 'Verified') {
452
+ // ... (rest of the logic)
453
+ // Check against the current, clean status
454
+ if (currentStatus.toLowerCase() === 'verified') {
455
+ finalStatus = 'New';
456
+ } else {
457
+ finalStatus = 'Verified';
458
+ }
459
+
460
+ } else if (intendedStatus === 'Resolved') {
461
+ // ... (rest of the logic)
462
+ // Check against the current, clean status
463
+ if (currentStatus.toLowerCase() === 'resolved') {
464
+ finalStatus = 'New';
465
+ } else {
466
+ finalStatus = 'Resolved';
467
+ }
468
+ } else {
469
+ return;
470
+ }
471
+
472
+ // Safety check: ensure we are actually changing the status
473
+ if (finalStatus === currentStatus) {
474
+ return;
475
+ }
476
+
477
+ // --- API CALL AND UI UPDATE ---
478
+ fetch(`/api/posts/${lastClickedId}/status`, {
479
+ method: 'POST',
480
+ headers: { 'Content-Type': 'application/json' },
481
+ body: JSON.stringify({ status: finalStatus })
482
+ })
483
+ .then(res => {
484
+ if(res.status === 401) { alert("Unauthorized. Please log in."); return; }
485
+ // If the API call returns success (200), proceed with UI updates based on the finalStatus.
486
+ return res.json();
487
+ })
488
+ .then(data => {
489
+ if(data) {
490
+ fetchPosts();
491
+ fetchStats();
492
+
493
+ if (badge) {
494
+ // 1. Update text
495
+ badge.innerText = finalStatus;
496
+
497
+ // 2. Apply color class (visual sync fix)
498
+ badge.classList.remove('status-new', 'status-verified', 'status-resolved');
499
+ badge.classList.add(`status-${finalStatus.toLowerCase()}`);
500
+ }
501
+
502
+ // 3. Update buttons' appearance
503
+ updateActionButtons(finalStatus);
504
+ }
505
+ })
506
+ .catch(error => {
507
+ console.error("Status Update Failed:", error);
508
+ alert("Failed to update status due to network error.");
509
+ });
510
+ }
511
+
512
+ // pop up notificaiton for new alerts
513
+ function showNotification(message) {
514
+ const popup = document.getElementById('new-alert-notification');
515
+ const msgEl = document.getElementById('notification-message');
516
+ if (!popup || !msgEl) return;
517
+
518
+ msgEl.innerText = message;
519
+
520
+ // 1. Prepare for animation (ensure it's visible but off-screen)
521
+ popup.classList.remove('hidden');
522
+
523
+ // 2. Force reflow to ensure CSS animation starts correctly
524
+ void popup.offsetWidth;
525
+
526
+ // 3. Trigger slide-in animation
527
+ popup.classList.add('visible');
528
+
529
+ // 4. Set timeout to slide out after 6 seconds
530
+ setTimeout(() => {
531
+ popup.classList.remove('visible');
532
+
533
+ // 5. Hide completely after animation finishes (0.5s transition time in CSS)
534
+ setTimeout(() => {
535
+ popup.classList.add('hidden');
536
+ }, 500);
537
+ }, 6000); // Display for 6 seconds
538
+ }
539
+
540
+ // initializes and draws the chart.js graphs for stats modal
541
+ function initCharts() {
542
+ const ctxType = document.getElementById('typeChart');
543
+ const ctxUrg = document.getElementById('urgencyChart');
544
+ if (!ctxType || !ctxUrg) return;
545
+
546
+ typeChart = new Chart(ctxType.getContext('2d'), {
547
+ type: 'doughnut',
548
+ data: { labels: [], datasets: [{ data: [], backgroundColor: ['#ed4801', '#33b5e5', '#00C851', '#ffbb33', '#aa66cc'], borderWidth: 0 }] },
549
+ options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right', labels: { color: 'white' } } } }
550
+ });
551
+
552
+ urgencyChart = new Chart(ctxUrg.getContext('2d'), {
553
+ type: 'bar',
554
+ data: { labels: ['High', 'Low/Med'], datasets: [{ label: 'Count', data: [0, 0], backgroundColor: ['#ff4444', '#00C851'], borderRadius: 5 }] },
555
+ options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: 'white' } }, x: { ticks: { color: 'white' } } }, plugins: { legend: { display: false } } }
556
+ });
557
+ }
558
+
559
+ // fetches statistical data from the API
560
+ function fetchStats() { fetch('/api/stats').then(res=>res.json()).then(data=>updateCharts(data)).catch(err=>console.error(err)); }
561
+
562
+ // updates the chart data and redraws the graphs
563
+ function updateCharts(data) { if(!typeChart || !urgencyChart) return; const types = data.disaster_types || {}; typeChart.data.labels = Object.keys(types); typeChart.data.datasets[0].data = Object.values(types); typeChart.update(); const levels = data.urgency_levels || {}; urgencyChart.data.datasets[0].data = [levels['High']||0, (levels['Low']||0)+(levels['Medium']||0)]; urgencyChart.update(); }
564
+
565
+ // fetches post data from the API based on current filter selections
566
+ function fetchPosts(q='') {
567
+ const sort = document.getElementById('sort-select')?.value||'newest';
568
+ const view = document.getElementById('view-select')?.value||'active';
569
+ const urgency = document.getElementById('urgency-select')?.value||'all';
570
+ const type = document.getElementById('type-select')?.value||'all';
571
+ const assist = document.getElementById('assist-select')?.value||'all';
572
+ const searchVal = document.querySelector('.search-input')?.value||'';
573
+ let url = `/api/posts?sort=${sort}&view=${view}&urgency=${urgency}&type=${type}&assist=${assist}`;
574
+ if(searchVal) url += `&query=${encodeURIComponent(searchVal)}`;
575
+ url += `&_=${new Date().getTime()}`;
576
+ fetch(url).then(r=>r.json()).then(d=>{renderSidebar(d); checkAudioAlert(d);}).catch(e=>console.error(e));
577
+ }
578
+
579
+ // checks for new high-urgency alerts and plays sound + shows notification
580
+ function checkAudioAlert(p){
581
+ const a=document.getElementById('alert-sound');
582
+ if(!a)return;
583
+ a.volume = 0.1;
584
+ let newAlertFound = false;
585
+
586
+ if (!isInitialLoadComplete) {
587
+ // 1. On the very first load after page navigation/refresh:
588
+ // Populate the known set with ALL current IDs and skip the alert.
589
+ p.forEach(x => knownPostIds.add(x.id));
590
+ isInitialLoadComplete = true;
591
+ return;
592
+ }
593
+
594
+ p.forEach(x => {
595
+ if(x.urgency_level === 'High' && !knownPostIds.has(x.id) && x.status !== 'Resolved') {
596
+ newAlertFound = true;
597
+ }
598
+ });
599
+
600
+ if(newAlertFound) {
601
+ a.play().catch(e=>{});
602
+ showNotification("NEW HIGH PRIORITY ALERT: Check Feed");
603
+ }
604
+
605
+ p.forEach(x => {
606
+ knownPostIds.add(x.id);
607
+ });
608
+ }
609
+
610
+ // sets up the search input logic with clear button
611
+ function setupSearch(){ const i=document.querySelector('.search-input'), c=document.querySelector('.clear-icon'); if(!i)return; i.addEventListener('input',()=>{if(i.value)c?.classList.remove('hidden');else{c?.classList.add('hidden');fetchPosts();}}); i.addEventListener('keydown',e=>{if(e.key==='Enter')fetchPosts();}); c?.addEventListener('click',()=>{i.value='';c.classList.add('hidden');fetchPosts();}); }
612
+
613
+ // Attach listeners once DOM is ready
614
+ (function () {
615
+ // Select all elements with data_tooltip attribute
616
+ const tooltipElements = document.querySelectorAll('[data_tooltip]');
617
+
618
+ tooltipElements.forEach(el => {
619
+ // When clicked: hide tooltip immediately by adding class
620
+ el.addEventListener('mousedown', (e) => {
621
+ // Add class so CSS hides tooltip; use mousedown for immediate feedback
622
+ el.classList.add('tooltip-hidden');
623
+
624
+ // Also remove focus so :focus doesn't keep hiding/showing unpredictably
625
+ if (typeof el.blur === 'function') {
626
+ el.blur();
627
+ }
628
+ });
629
+
630
+ // When mouse leaves the element: remove the hiding class so future hovers work
631
+ el.addEventListener('mouseleave', (e) => {
632
+ el.classList.remove('tooltip-hidden');
633
+ });
634
+
635
+ // Also remove hidden class on touchend for touch devices (optional)
636
+ el.addEventListener('touchend', () => {
637
+ el.classList.remove('tooltip-hidden');
638
+ });
639
+ });
640
+ })();
alisto_project/backend/style.css ADDED
@@ -0,0 +1,800 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* --- GLOBAL RESET & FONTS --- */
2
+ * { margin: 0; padding: 0; box-sizing: border-box; }
3
+
4
+ body {
5
+ font-family: 'Roboto', sans-serif;
6
+ color: #fff;
7
+ background: #28282B;
8
+ overflow: hidden;
9
+ height: 100vh;
10
+ }
11
+
12
+ h1, h2, h3 { font-family: 'Montserrat', sans-serif; }
13
+
14
+ /* --- HEADER --- */
15
+ /* --- HEADER STYLES --- */
16
+ .main-header {
17
+ position: fixed;
18
+ top: 0;
19
+ left: 0;
20
+ width: 100%;
21
+ height: 60px;
22
+ padding: 0 2vw;
23
+
24
+ background: rgba(0, 0, 0, 0.9);
25
+ backdrop-filter: blur(8px);
26
+ z-index: 999;
27
+
28
+ display: flex;
29
+ justify-content: space-between;
30
+ align-items: center;
31
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
32
+
33
+ /* FIX: Prevent layout breaking on very small screens */
34
+ min-width: 600px;
35
+ }
36
+
37
+ /* 1. LOGO (Locked) */
38
+ .logo {
39
+ display: flex;
40
+ flex-direction: column;
41
+ line-height: 1;
42
+ flex-shrink: 0; /* Never shrink */
43
+ margin-right: 20px;
44
+ }
45
+ .logo-main { font-family: 'Montserrat', sans-serif; font-size: 1.8em; font-weight: 900; color: #ed4801; }
46
+ .logo-sub { font-size: 0.65em; font-weight: 400; color: #ccc; letter-spacing: 2px; text-transform: uppercase; margin-top: 2px; }
47
+
48
+ /* 2. NAVIGATION (Locked) */
49
+ .main-nav {
50
+ display: flex;
51
+ align-items: center;
52
+ flex-shrink: 0; /* Never shrink */
53
+ margin-left: 20px;
54
+ }
55
+
56
+ .main-nav .nav-link {
57
+ color: #ccc;
58
+ text-decoration: none;
59
+ font-weight: 500;
60
+ font-size: 0.9em;
61
+ padding: 8px 15px;
62
+ border-radius: 6px;
63
+ transition: all 0.3s ease;
64
+ margin-left: 10px;
65
+
66
+ /* FIX: Keep text on one line */
67
+ white-space: nowrap;
68
+ }
69
+ .main-nav .nav-link {
70
+ color: #ccc;
71
+ text-decoration: none;
72
+ font-weight: 500;
73
+ font-size: 0.9em;
74
+ padding: 8px 15px;
75
+ border-radius: 6px;
76
+ transition: all 0.3s ease;
77
+ margin-left: 10px;
78
+
79
+ /* FIX: Keep text on one line */
80
+ white-space: nowrap;
81
+
82
+ /* FIX: Add invisible border so it doesn't jump when active */
83
+ border: 1px solid transparent;
84
+ }
85
+
86
+ .main-nav .nav-link:hover {
87
+ color: white;
88
+ background-color: rgba(255, 255, 255, 0.1);
89
+ }
90
+
91
+ .main-nav .nav-link.active {
92
+ color: #ed4801;
93
+ border: 1px solid #ed4801;
94
+ }
95
+
96
+ /* --- SEARCH BAR (Stabilized) --- */
97
+ .search-container {
98
+ display: flex;
99
+ align-items: center;
100
+ background: rgba(255, 255, 255, 0.1);
101
+ border-radius: 20px;
102
+ padding: 8px 15px;
103
+
104
+ /* FIX: Remove 'clamp'. Use standard width so it stops jittering. */
105
+ width: 100%;
106
+ max-width: 400px;
107
+
108
+ transition: background 0.3s ease;
109
+ }
110
+
111
+ .search-container:hover {
112
+ background: rgba(255, 255, 255, 0.15);
113
+ }
114
+
115
+ .search-input {
116
+ flex-grow: 1;
117
+ border: none;
118
+ background: transparent;
119
+ color: white;
120
+ font-size: 0.9em;
121
+ padding: 0;
122
+ outline: none;
123
+ }
124
+
125
+ .search-icon {
126
+ color: #ed4801;
127
+ margin-left: 5px;
128
+ cursor: pointer;
129
+ }
130
+
131
+ .clear-icon {
132
+ color: #ccc;
133
+ margin-right: 10px;
134
+ cursor: pointer;
135
+ }
136
+
137
+ .clear-icon.hidden {
138
+ display: none;
139
+ }
140
+
141
+ /* --- HERO --- */
142
+ .hero {
143
+ margin-top: 60px;
144
+ height: calc(100vh - 60px);
145
+ width: 100%;
146
+ background: url('images/bg2-logan.jpg') center/cover no-repeat fixed;
147
+
148
+ /* FLEXBOX MAGIC */
149
+ display: flex;
150
+ align-items: stretch; /* Make both columns full height */
151
+ flex-wrap: nowrap; /* Desktop: Side-by-side. Mobile: We change this via media query */
152
+
153
+ padding: 0;
154
+ gap: 0;
155
+ }
156
+ /* --- SIDEBAR WRAPPER --- */
157
+ .sidebar-wrapper {
158
+ display: flex;
159
+ flex-direction: column;
160
+ flex: 0 0 400px;
161
+ width: 400px;
162
+
163
+ height: 100%;
164
+
165
+ background: rgba(0, 0, 0, 0.6);
166
+ border-right: 1px solid rgba(255, 255, 255, 0.2);
167
+ backdrop-filter: blur(5px);
168
+ box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5);
169
+ /* overflow: hidden; */
170
+ z-index: 10;
171
+ }
172
+
173
+ /* --- FILTER BAR --- */
174
+ .filter-bar {
175
+ display: flex; gap: 8px; padding: 15px;
176
+ background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
177
+ align-items: center;
178
+ }
179
+
180
+ .filter-bar-c {
181
+ display: flex; gap: 8px; width: 100%;
182
+ justify-content: center; align-items: center; flex-direction: column;
183
+ }
184
+
185
+
186
+ .filter-bar-r {
187
+ display: flex; gap: 8px; width: 100%;
188
+ justify-content: space-between; align-items: center; flex-direction: row;
189
+ }
190
+
191
+ .filter-select { background: rgba(99, 97, 97, 0.1); color: #acabab; border: 1px solid #555; border-radius: 4px; padding: 5px; font-size: 0.8rem; outline: none; cursor: pointer; flex-grow: 1; min-width: 0; }
192
+ .pulse-btn, .icon-btn { flex-shrink: 0; }
193
+ .icon-btn { background: #ed4801; color: white; border: none; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
194
+ .pulse-btn { background: linear-gradient(135deg, #ed4801, #ff7e42); color: white; border: none; border-radius: 50%; width: 35px; height: 35px; cursor: pointer; box-shadow: 0 0 10px rgba(237, 72, 1, 0.5); display: flex; align-items: center; justify-content: center; animation: pulse-glow 2s infinite; }
195
+ @keyframes pulse-glow { 0% { box-shadow: 0 0 0 0 rgba(237, 72, 1, 0.7); } 70% { box-shadow: 0 0 0 10px rgba(237, 72, 1, 0); } }
196
+ .separator { width: 1px; background: rgba(255,255,255,0.2); margin: 0 5px; height: 20px; }
197
+
198
+ /* --- SIDEBAR LIST --- */
199
+ .sidebar {
200
+ width: 100%; height: 100%;
201
+ background: transparent; border: none; box-shadow: none;
202
+ padding: 10px; overflow-y: auto; overflow-x: hidden;
203
+ }
204
+ .sidebar::-webkit-scrollbar { width: 6px; }
205
+ .sidebar::-webkit-scrollbar-thumb { background: rgba(237, 72, 1, 0.5); border-radius: 10px; }
206
+
207
+ /* --- ALERT BOXES --- */
208
+ .alert-box { display: flex; align-items: center; background: rgba(255, 255, 255, 0.1); border-radius: 8px; padding: 16px; margin-bottom: 10px; cursor: pointer; transition: transform 0.2s ease, background 0.2s; border-left: 4px solid transparent; }
209
+ .alert-box:hover { background: rgba(255, 255, 255, 0.2); transform: translateX(5px); }
210
+ .alert-box.selected { background: rgba(237, 72, 1, 0.3); border-left: 4px solid #ed4801; }
211
+ .alert-box.unread { background: rgba(255, 255, 255, 0.15); border-left: 3px solid #ed4801; opacity: 1; }
212
+ .alert-icon { width: 12px; height: 12px; background-color: #ed4801; border-radius: 50%; margin-right: 15px; flex-shrink: 0; }
213
+ .box-text { display: flex; flex-direction: column; }
214
+ .box-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 4px; color: #fff; text-transform: uppercase; }
215
+ .box-subtitle { font-size: 0.8rem; color: #ccc; }
216
+ .active-alert-counter{ padding: 5px 10px 5px 10px; text-align: left; }
217
+
218
+
219
+ /* --- DETAIL BOX (ANIMATED) --- */
220
+ .detail-box {
221
+ display: flex;
222
+ flex-direction: column;
223
+
224
+ /* FIX: Take up all remaining space */
225
+ flex: 1;
226
+
227
+ /* FIX: Prevents the box from forcing the screen wider if text is long */
228
+ min-width: 0;
229
+
230
+ /* Keep it floating */
231
+ height: calc(100% - 40px); /* Full height minus margins */
232
+ margin: 20px;
233
+
234
+ background: rgba(0,0,0,0.9);
235
+ padding: 30px;
236
+ border-radius: 15px;
237
+ color: #fff;
238
+ border: 1px solid #ed4801;
239
+ z-index: 20;
240
+
241
+ /* Animation: Hidden by default */
242
+ opacity: 0;
243
+ transform-origin: top right;
244
+
245
+ transform: scale(0.9) translateX(0);
246
+ transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
247
+ pointer-events: none;
248
+ backdrop-filter: blur(5px);
249
+ }
250
+
251
+ /* Animation: Visible state */
252
+ .detail-box.active {
253
+ opacity: 1;
254
+ transform: scale(1) translateX(0);
255
+ pointer-events: auto;
256
+ box-shadow: -10px 10px 30px rgba(0,0,0,0.5);
257
+ }
258
+ /* --- DETAIL CONTENT --- */
259
+ .detail-header-r { display: flex; flex-direction: row; justify-content: space-between; margin: 0 0 10px; align-items: center; }
260
+ .detail-title { font-size: 1.8rem; font-weight: 700; margin: 0; width: 70%; }
261
+ .detail-redirect { font-size: 14px; color: #f6510a; text-decoration: none; border: 1px solid #f6510a; padding: 5px 10px; border-radius: 4px; transition: 0.3s; }
262
+ .detail-redirect:hover { background: #f6510a; color: white; }
263
+ .detail-line { border: none; border-top: 1px solid #ed4801; margin-bottom: 20px; }
264
+ .detail-important-r { display: flex; flex-direction: row; justify-content: space-between; margin-top: 10px; }
265
+ .detail-row1-c { display: flex; flex-direction: column; gap: 15px; flex: 1; }
266
+ .detail-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: #888; margin-bottom: 5px; }
267
+ .detail-text { font-size: 1.3rem; color: #ddd;}
268
+ .detail-content { margin-top: 0px; line-height: 1.6; font-size: 1.1rem; max-height: 400px; overflow-y: auto; color: #ddd; }
269
+ .detail-row1-col2-r { display: flex; flex-direction: row; gap: 20px; align-items: flex-start; }
270
+
271
+
272
+ .detail-content::-webkit-scrollbar { width: 6px; }
273
+ .detail-content::-webkit-scrollbar-track { background: transparent; }
274
+ .detail-content::-webkit-scrollbar-thumb { background: rgba(237, 72, 1, 0.5); border-radius: 10px; }
275
+ .detail-content::-webkit-scrollbar-thumb:hover { background: rgba(237, 72, 1, 1); }
276
+
277
+
278
+ /* Buttons */
279
+ .action-buttons { display: flex; gap: 10px; }
280
+ .action-btn { border: none; padding: 5px 12px; border-radius: 4px; color: white; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 5px; }
281
+ .verify-btn { background: #00C851; } .verify-btn:hover { background: #00e25b; }
282
+ .resolve-btn { background: #33b5e5; } .resolve-btn:hover { background: #4ec2ec; }
283
+ .detail-redirect { border: 1px solid #ed4801; color: #ed4801; padding: 5px 12px; border-radius: 4px; text-decoration: none; font-size: 0.8rem; display: flex; align-items: center; justify-content: center; }
284
+ .detail-redirect:hover { background: #ed4801; color: white; }
285
+ /* .status-badge { margin-top: 5px; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px; background: #555; display: inline-block; } */
286
+ .confirm-btn { background: #ed4801; } .resolve-btn:hover { background: #4ec2ec; }
287
+
288
+ /* --- STATUS BADGE COLOR CLASSES --- */
289
+ /* These classes are applied via JavaScript to synchronize with button clicks */
290
+
291
+ .status-badge {
292
+ margin-top: 5px;
293
+ font-size: 0.8rem;
294
+ padding: 2px 8px;
295
+ border-radius: 4px;
296
+ background: #555;
297
+ display: inline-block;
298
+ font-weight: bold; /* Added for clarity */
299
+ text-transform: uppercase;
300
+ transition: background-color 0.2s; /* Added transition */
301
+ color: white; /* Base text color */
302
+ }
303
+
304
+ /* Status: NEW (Default State/Unverified) */
305
+ .status-new {
306
+ background-color: #555; /* Amber/Yellow ffc107 333 */
307
+ color: #ffffff;
308
+ }
309
+
310
+ /* Status: VERIFIED */
311
+ .status-verified {
312
+ background-color: #ffc107; /* Green */
313
+ color: #ffffff;
314
+ }
315
+
316
+ /* Status: RESOLVED */
317
+ .status-resolved {
318
+ background-color: #28a745; /* Gray 28a745*/
319
+ color: #ffffff;
320
+ }
321
+
322
+ /* Base Style for all action buttons (like 'Post' or 'Verify') */
323
+ .action-btn1 {
324
+ /* Basic button styling (adjust padding, font, etc., as needed) */
325
+ padding: 6px 12px;
326
+ border-radius: 4px;
327
+ font-weight: 500;
328
+ cursor: pointer;
329
+ transition: background-color 0.2s, color 0.2s, border-color 0.2s;
330
+
331
+ /* Ensure icon and text are aligned */
332
+ display: inline-flex;
333
+ align-items: center;
334
+ gap: 5px; /* Spacing between icon and text */
335
+ }
336
+
337
+ /* 1. Default (Unverified) State: Green Outline only */
338
+ #verify-btn {
339
+ background-color: transparent;
340
+ color: #ffc107; /* Green text color */
341
+ border: 1px solid #ffc107; /* Green border (outline) */
342
+ }
343
+
344
+ /* 2. Verified State: Entirely Green */
345
+ #verify-btn.is-verified {
346
+ background-color: #ffc107; /* Solid Green background */
347
+ color: #ffffff; /* White text/icon */
348
+ border-color: #ffc107; /* Maintain border color */
349
+ }
350
+
351
+ /* 3. Hover States */
352
+ #verify-btn:hover:not(.is-verified) {
353
+ background-color: rgba(40, 167, 69, 0.1); /* Light green background on hover */
354
+ }
355
+
356
+ #verify-btn.is-verified:hover {
357
+ /* Make the button slightly darker green on hover when it's already verified */
358
+ background-color: #c77f0c;
359
+ }
360
+
361
+ /* --- RESOLVE BUTTON --- */
362
+ /* 1. Default (Unresolved) State: Blue Outline only */
363
+ #resolve-btn {
364
+ background-color: transparent;
365
+ color: #28a745; /* Blue text color */
366
+ border: 1px solid #28a745; /* Blue border (outline) */
367
+ }
368
+
369
+ /* 2. Resolved State: Entirely Blue */
370
+ #resolve-btn.is-resolved {
371
+ background-color: #28a745; /* Solid Blue background */
372
+ color: #ffffff; /* White text/icon */
373
+ border-color: #28a745;
374
+ }
375
+
376
+ /* 3. Hover States (When Unresolved) */
377
+ #resolve-btn:hover:not(.is-resolved) {
378
+ background-color: rgba(0, 123, 255, 0.1); /* Light blue background on hover */
379
+ }
380
+
381
+ /* 4. Hover States (When Already Resolved) */
382
+ #resolve-btn.is-resolved:hover {
383
+ /* Make the button slightly darker blue on hover when it's already resolved */
384
+ background-color: #157349;
385
+ }
386
+
387
+
388
+ /* --- MODAL --- */
389
+ .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(5px); z-index: 2000; display: flex; align-items: center; justify-content: center; opacity: 1; transition: opacity 0.3s ease; }
390
+ .modal-overlay.hidden { opacity: 0; pointer-events: none; display: none !important; }
391
+ .modal-content { background: #1a1a1a; border: 1px solid #444; padding: 30px; border-radius: 12px; width: 90%; max-width: 900px; }
392
+ .modal-header { display: flex; justify-content: space-between; margin-bottom: 20px; }
393
+ .charts-container { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; margin-top: 20px; }
394
+ .chart-wrapper { background: rgba(255, 255, 255, 0.05); border-radius: 12px; padding: 20px; flex: 1; min-width: 300px; max-height: 400px; display: flex; flex-direction: column; align-items: center; }
395
+ canvas { max-width: 100%; max-height: 300px; }
396
+ .icon-btn-ghost { background: transparent; border: none; color: #888; font-size: 1.5rem; cursor: pointer; }
397
+ .hidden { display: none !important; }
398
+
399
+ /* --- RESPONSIVE ADJUSTMENT --- */
400
+ @media (max-width: 850px) {
401
+ /* Unlock the body scrolling for small screens */
402
+ body {
403
+ overflow: auto;
404
+ height: auto;
405
+ }
406
+
407
+ .hero {
408
+ /* Stack vertically instead of side-by-side */
409
+ flex-direction: column;
410
+ height: auto;
411
+ min-height: 100vh;
412
+ }
413
+
414
+ .sidebar-wrapper {
415
+ /* Sidebar becomes full width banner at the top */
416
+ width: 100%;
417
+ flex: none; /* Turn off the fixed 350px logic */
418
+ height: 300px; /* Fixed height for list */
419
+ }
420
+
421
+ .detail-box {
422
+ /* Box sits below sidebar */
423
+ width: 95%; /* Almost full width */
424
+ margin: 10px auto; /* Center it */
425
+ height: 500px; /* Fixed height for content */
426
+ flex: none;
427
+
428
+ /* Reset transform for mobile so it doesn't fly in weirdly */
429
+ transform: none;
430
+ }
431
+
432
+ /* Ensure active state just shows opacity on mobile */
433
+ .detail-box.active {
434
+ transform: none;
435
+ }
436
+ }
437
+
438
+ /* --- LOGIN MODAL STYLES --- */
439
+ .small-modal { max-width: 400px; }
440
+
441
+ .login-form {
442
+ display: flex; flex-direction: column; gap: 15px;
443
+ }
444
+
445
+ .login-input {
446
+ background: rgba(255,255,255,0.1);
447
+ border: 1px solid #444;
448
+ color: white;
449
+ padding: 12px;
450
+ border-radius: 6px;
451
+ font-size: 1rem;
452
+ outline: none;
453
+ }
454
+ .login-input:focus { border-color: #ed4801; }
455
+
456
+ .full-width {
457
+ width: 100%;
458
+ justify-content: center;
459
+ padding: 12px;
460
+ font-size: 1rem;
461
+ }
462
+
463
+ /* --- SIDEBAR ASSISTANCE BADGE --- */
464
+ .assist-badge {
465
+ display: inline-block;
466
+ font-size: 0.7rem;
467
+ font-weight: 700;
468
+ text-transform: uppercase;
469
+ padding: 2px 6px;
470
+ border-radius: 4px;
471
+ margin-right: 6px;
472
+ background: rgba(255, 255, 255, 0.1);
473
+ color: #ccc;
474
+ border: 1px solid #555;
475
+ }
476
+
477
+ /* Specific Colors */
478
+ .assist-medical { color: #ff4444; border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
479
+ .assist-rescue { color: #ffbb33; border-color: #ffbb33; background: rgba(255, 187, 51, 0.1); }
480
+ .assist-food { color: #00C851; border-color: #00C851; background: rgba(0, 200, 81, 0.1); }
481
+ .assist-evac { color: #33b5e5; border-color: #33b5e5; background: rgba(51, 181, 229, 0.1); }
482
+
483
+
484
+ /* --- CUSTOM TOOLTIP STYLES --- */
485
+
486
+ /* 1. Base style for any element that has a tooltip */
487
+ [data_tooltip] {
488
+ position: relative;
489
+ cursor: pointer;
490
+ }
491
+
492
+ /* 2. Create the tooltip content (::after) ALWAYS, but invisible */
493
+ [data_tooltip]::after {
494
+ content: attr(data_tooltip);
495
+ position: absolute;
496
+ top: calc(100% + 15px);
497
+
498
+ /* DEFAULT POSITIONING: Centered over parent */
499
+ left: 50%;
500
+ right: auto;
501
+ transform: translateX(-50%);
502
+ margin: 0;
503
+
504
+ /* Appearance & Visibility */
505
+ background-color: #ed4801;
506
+ color: #fff;
507
+ white-space: nowrap;
508
+ padding: 6px 10px;
509
+ border-radius: 4px;
510
+ font-size: 0.75rem;
511
+ font-weight: 500;
512
+ opacity: 0;
513
+ pointer-events: none;
514
+ transition: opacity 0.2s;
515
+ transition-delay: 0s; /* Instant disappearance */
516
+ z-index: 9999;
517
+ }
518
+
519
+ /* 4a. LEFT EDGE OVERRIDE */
520
+ [data_tooltip_align="left"]::after {
521
+ left: 0;
522
+ right: auto;
523
+ transform: none;
524
+ margin-left: 10px;
525
+ }
526
+
527
+ /* 4b. RIGHT EDGE OVERRIDE */
528
+ [data_tooltip_align="right"]::after {
529
+ right: 0;
530
+ left: auto;
531
+ transform: none;
532
+ margin-left: 0;
533
+ margin-right: 10px;
534
+ }
535
+
536
+ /* 5. ON HOVER — animate in (Fade only) */
537
+ [data_tooltip]:hover::after {
538
+ opacity: 1;
539
+ transition-delay: 0.5s !important;
540
+ }
541
+
542
+ /* Add this to your existing CSS */
543
+ [data_tooltip].tooltip-hidden:hover::after {
544
+ opacity: 0 !important;
545
+ transition-delay: 0s !important;
546
+ }
547
+
548
+ /* 5. Tooltip visibility on hover */
549
+ /* [data_tooltip]:hover::after,
550
+ [data_tooltip]:hover::before {
551
+ opacity: 1;
552
+ } */
553
+
554
+ /* Arrow — ALSO always created */
555
+ /* [data_tooltip]::before {
556
+ content: '';
557
+ position: absolute;
558
+ top: 100%;
559
+ left: 50%;
560
+
561
+ transform: translateX(-50%);
562
+
563
+ border-left: 6px solid transparent;
564
+ border-right: 6px solid transparent;
565
+ border-bottom: 6px solid #ed4801;
566
+ opacity: 0;
567
+
568
+ transition: opacity 0.2s;
569
+ transition-delay: 0s;
570
+ z-index: 9999;
571
+ } */
572
+
573
+ /* Floating Sidebar for Map Page */
574
+ .map-sidebar {
575
+ position: absolute;
576
+ right: 20px;
577
+ top: 20px;
578
+ width: 250px;
579
+ background: rgba(0, 0, 0, 0.85);
580
+ padding: 20px;
581
+ border-radius: 12px;
582
+ border: 1px solid #ed4801;
583
+ z-index: 1000; /* Sit on top of map */
584
+ color: white;
585
+ backdrop-filter: blur(5px);
586
+ box-shadow: 0 4px 15px rgba(0,0,0,0.5);
587
+ }
588
+
589
+ .back-btn {
590
+ display: inline-block;
591
+ color: #ccc;
592
+ text-decoration: none;
593
+ margin-bottom: 15px;
594
+ font-size: 0.9em;
595
+ transition: color 0.2s;
596
+ }
597
+ .back-btn:hover { color: #ed4801; }
598
+
599
+ .stat-item {
600
+ margin-top: 5px;
601
+ font-size: 0.85rem;
602
+ color: #aaa;
603
+ }
604
+ .stat-highlight {
605
+ color: white;
606
+ font-weight: bold;
607
+ font-size: 1.1rem;
608
+ }
609
+
610
+ .profile-container {
611
+ position: relative;
612
+ margin-left: 10px;
613
+ height: 35px;
614
+ }
615
+ .profile-icon {
616
+ width: 35px;
617
+ height: 35px;
618
+ background: #ed4801; /* Orange profile background */
619
+ border-radius: 50%;
620
+ display: flex;
621
+ align-items: center;
622
+ justify-content: center;
623
+ color: white;
624
+ font-size: 1.1em;
625
+ cursor: pointer;
626
+ transition: box-shadow 0.2s;
627
+ }
628
+ .profile-icon:hover {
629
+ box-shadow: 0 0 0 4px rgba(237, 72, 1, 0.3);
630
+ }
631
+
632
+ /* Dropdown Menu Box */
633
+ .profile-dropdown {
634
+ position: absolute;
635
+ top: 45px;
636
+ right: 0;
637
+ width: 250px;
638
+ background: #1a1a1a;
639
+ border: 1px solid #333;
640
+ border-radius: 8px;
641
+ box-shadow: 0 8px 16px rgba(0,0,0,0.5);
642
+ z-index: 1000;
643
+ padding: 15px;
644
+ transform: translateY(10px);
645
+ transition: opacity 0.2s, transform 0.2s;
646
+ }
647
+ .profile-dropdown.hidden {
648
+ display: none;
649
+ }
650
+
651
+ /* Dropdown Header Info */
652
+ .profile-info-header {
653
+ display: flex;
654
+ flex-direction: column;
655
+ align-items: center;
656
+ padding-bottom: 10px;
657
+ }
658
+ .profile-icon-large {
659
+ width: 50px;
660
+ height: 50px;
661
+ background: #ff7e42;
662
+ border-radius: 50%;
663
+ display: flex;
664
+ align-items: center;
665
+ justify-content: center;
666
+ color: white;
667
+ font-size: 1.8em;
668
+ margin-bottom: 8px;
669
+ }
670
+ .dropdown-username {
671
+ font-weight: bold;
672
+ color: white;
673
+ font-size: 1.1em;
674
+ }
675
+
676
+ /* Logout Button */
677
+ .dropdown-separator {
678
+ border: none;
679
+ border-top: 1px solid #333;
680
+ margin: 10px 0;
681
+ }
682
+ .dropdown-logout-btn {
683
+ width: 100%;
684
+ background: #ed4801;
685
+ color: white;
686
+ border: none;
687
+ padding: 8px;
688
+ border-radius: 4px;
689
+ font-weight: 500;
690
+ cursor: pointer;
691
+ display: flex;
692
+ align-items: center;
693
+ justify-content: center;
694
+ gap: 8px;
695
+ }
696
+
697
+ @media (max-width: 1200px) {
698
+ .detail-header-r .action-buttons {
699
+ flex-direction: column;
700
+ align-items: stretch;
701
+ gap: 8px;
702
+ }
703
+
704
+ .detail-redirect {
705
+ justify-content: center;
706
+ }
707
+
708
+ .action-btn1 {
709
+ width: 100%;
710
+ }
711
+ }
712
+
713
+ .alert-popup {
714
+ position: fixed;
715
+ top: 70px;
716
+ right: 20px;
717
+ color: white;
718
+ padding: 15px 25px;
719
+ border-radius: 10px;
720
+ display: flex;
721
+ align-items: center;
722
+ gap: 12px;
723
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
724
+ z-index: 1000;
725
+
726
+ opacity: 0;
727
+ transform: translateX(100%);
728
+ transition: opacity 0.4s ease-out, transform 0.4s ease-out;
729
+ }
730
+
731
+ .alert-popup.visible {
732
+ opacity: 1;
733
+ transform: translateX(0);
734
+ }
735
+
736
+
737
+ .hidden {
738
+ display: none !important;
739
+ }
740
+
741
+ /* --- MAP COLLAPSIBLE SIDEBAR STYLES --- */
742
+
743
+ /* 1. Base style for the sidebar container */
744
+ .map-collapse-sidebar {
745
+ position: absolute;
746
+ top: 0;
747
+ left: 0;
748
+ height: 100vh;
749
+ width: 300px;
750
+ background: rgba(0, 0, 0, 0.85);
751
+ padding: 20px;
752
+ border-right: 2px solid #ed4801;
753
+ z-index: 1000;
754
+ color: white;
755
+ backdrop-filter: blur(5px);
756
+ box-shadow: 4px 0 15px rgba(0,0,0,0.5);
757
+ transform: translateX(0);
758
+ transition: transform 0.3s ease-out;
759
+ overflow-y: auto;
760
+ }
761
+ .map-collapse-sidebar::-webkit-scrollbar { width: 6px; }
762
+ .map-collapse-sidebar::-webkit-scrollbar-thumb { background: rgba(237, 72, 1, 0.5); border-radius: 10px; }
763
+
764
+
765
+ /* 2. Collapsed State: Move the sidebar off-screen */
766
+ .map-collapse-sidebar.collapsed {
767
+ transform: translateX(-300px);
768
+ }
769
+
770
+ /* 3. Toggle Button Style */
771
+ .sidebar-toggle-btn {
772
+ position: absolute;
773
+ top: 20px;
774
+ left: 320px;
775
+ width: 40px;
776
+ height: 40px;
777
+ background: #ed4801;
778
+ color: white;
779
+ border: none;
780
+ border-radius: 50%;
781
+ cursor: pointer;
782
+ box-shadow: 0 4px 10px rgba(0,0,0,0.4);
783
+ z-index: 1001;
784
+ display: flex;
785
+ align-items: center;
786
+ justify-content: center;
787
+ transition: all 0.3s ease-out;
788
+ }
789
+
790
+ /* 4. Reposition the button when sidebar is collapsed */
791
+ .map-collapse-sidebar.collapsed + .sidebar-toggle-btn {
792
+ left: 20px;
793
+ transform: rotate(180deg);
794
+ }
795
+
796
+ /* 5. Content within the sidebar (just for reset) */
797
+ .map-sidebar-content {
798
+ opacity: 1;
799
+ transition: opacity 0.3s;
800
+ }
alisto_project/backend/templates.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # templates.py
2
+
3
+ # LOCATIONS = [
4
+ # "Marikina", "Quezon City", "Manila", "Pasig", "Cainta", "Antipolo",
5
+ # "Batangas", "Tagaytay", "Davao", "Cebu", "Bicol", "Albay",
6
+ # "Pampanga", "Bulacan", "Laguna", "Cavite", "Tacloban", "Iligan",
7
+ # "Navotas", "Malabon", "San Mateo", "Rizal", "Valenzuela", "Muntinlupa",
8
+ # "Las Pinas", "Makati", "Mandaluyong", "San Juan", "Catanduanes",
9
+ # "Isabela", "Cagayan", "Iloilo", "Samar", "Leyte", "Mindoro",
10
+ # "General Trias", "Dasmarinas", "Bacoor", "Imus", "Tondo"
11
+ # ]
12
+
13
+ LOCATIONS = [
14
+ "Ilocos Norte", "Ilocos Sur", "La Union", "Pangasinan",
15
+ "Batanes", "Cagayan", "Isabela", "Nueva Vizcaya", "Quirino",
16
+ "Aurora", "Bataan", "Bulacan", "Nueva Ecija", "Pampanga", "Tarlac", "Zambales",
17
+ "Batangas", "Cavite", "Laguna", "Quezon", "Rizal",
18
+ "Marinduque", "Occidental Mindoro", "Oriental Mindoro", "Palawan", "Romblon",
19
+ "Albay", "Camarines Norte", "Camarines Sur", "Catanduanes", "Masbate", "Sorsogon",
20
+ "Abra", "Apayao", "Benguet", "Ifugao", "Kalinga", "Mountain Province",
21
+ "Aklan", "Antique", "Capiz", "Guimaras", "Iloilo", "Negros Occidental",
22
+ "Bohol", "Cebu", "Negros Oriental", "Siquijor",
23
+ "Biliran", "Eastern Samar", "Leyte", "Northern Samar", "Samar", "Southern Leyte",
24
+ "Zamboanga del Norte", "Zamboanga del Sur", "Zamboanga Sibugay",
25
+ "Bukidnon", "Camiguin", "Lanao del Norte", "Misamis Occidental", "Misamis Oriental",
26
+ "Davao de Oro", "Davao del Norte", "Davao del Sur", "Davao Oriental", "Davao Occidental",
27
+ "Cotabato", "Sarangani", "South Cotabato", "Sultan Kudarat",
28
+ "Agusan del Norte", "Agusan del Sur", "Dinagat Islands", "Surigao del Norte", "Surigao del Sur",
29
+ "Basilan", "Lanao del Sur", "Maguindanao del Norte", "Maguindanao del Sur", "Sulu", "Tawi-Tawi"
30
+ ]
31
+
32
+ DISASTERS = [
33
+ "baha", "flood", "flooding", "flash flood", "taas ng tubig",
34
+ "lindol", "earthquake", "quake", "aftershock", "yanig", "uga",
35
+ "bagyo", "typhoon", "storm", "winds", "malakas na hangin",
36
+ "sunog", "fire", "nasusunog", "apoy", "fire alert",
37
+ "landslide", "guho", "mudslide", "falling rocks",
38
+ "volcano", "ashfall", "eruption", "asupre", "smog"
39
+ ]
40
+
41
+ NEEDS = [
42
+ "rescue", "tulong", "help", "saklolo", "assist", "save us",
43
+ "food", "pagkain", "water", "tubig", "relief goods", "makakain",
44
+ "medical", "gamot", "medic", "doctor", "ambulance", "oxygen", "first aid",
45
+ "shelter", "evacuation", "matutuluyan", "tents", "bubong", "trapal",
46
+ "boat", "bangka", "firetruck", "bumbero", "rescuers"
47
+ ]
48
+
49
+ # POSITIVE FRAGMENTS
50
+ POS_INTROS = ["EMERGENCY!", "HELP!", "S.O.S.", "URGENT:", "Please help,", "Tulong po,", "Saklolo,", "Rescue needed!"]
51
+ POS_SITUATIONS = ["trapped kami sa loob", "stuck sa bubong", "water is rising fast", "nasusunog ang bahay", "injured ang kasama ko", "stranded kami", "collapsed ang pader", "lubog na ang first floor"]
52
+ POS_REQUESTS = ["we need {need} immediately", "send {need} ASAP", "paki-dala ng {need}", "kailangan namin ng {need}", "need {need} now!"]
53
+
54
+ # NEGATIVE FRAGMENTS
55
+ NEG_NEWS_INTROS = ["BREAKING:", "Just in:", "News Update:", "Report:", "FYI:", "Advisory:", "Headlines:", "According to news,"]
56
+ NEG_NEWS_BODIES = ["{disaster} hits {loc}", "death toll in {loc} rises", "classes suspended in {loc}", "state of calamity declared in {loc}"]
57
+
58
+ NEG_ACT_INTROS = ["I want to donate", "Looking for volunteers", "Donation drive for", "Salute to", "Praying for", "Condolence to"]
59
+ NEG_ACT_BODIES = ["victims of {disaster} in {loc}", "our heroes in {loc}", "the brave rescuers in {loc}", "{disaster} survivors in {loc}"]
60
+
61
+ NEG_DISCUSS_INTROS = ["Why is the mayor", "The corruption in", "Remembering the", "Throwback to", "Discussion:", "Opinion:", "Is it safe in"]
62
+ NEG_DISCUSS_BODIES = ["response to {disaster} in {loc} is slow", "1990 {disaster} in {loc}", "{loc} politicians are useless", "road to {loc} is passable"]