Initial commit
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Get Hub ID
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing hub id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Fetch Hub to identify connected users (pic_id & head_id)
|
||||
const { data: hub, error: fetchErr } = await supabaseAdmin
|
||||
.from('hubs')
|
||||
.select('pic_id, head_id')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (fetchErr) {
|
||||
console.error(`Failed to fetch hub ${id}:`, fetchErr);
|
||||
return NextResponse.json({ error: 'Hub not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Delete connected accounts if they exist
|
||||
if (hub.pic_id) {
|
||||
try {
|
||||
const { error: delPicErr } = await supabaseAdmin.auth.admin.deleteUser(hub.pic_id);
|
||||
if (delPicErr) console.error(`Error deleting PIC user ${hub.pic_id} from Auth:`, delPicErr);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete PIC user ${hub.pic_id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (hub.head_id) {
|
||||
try {
|
||||
const { error: delHeadErr } = await supabaseAdmin.auth.admin.deleteUser(hub.head_id);
|
||||
if (delHeadErr) console.error(`Error deleting Head user ${hub.head_id} from Auth:`, delHeadErr);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete Head user ${hub.head_id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete the Hub (cascades donations, sets household assignments to NULL)
|
||||
const { error: deleteErr } = await supabaseAdmin
|
||||
.from('hubs')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (deleteErr) throw deleteErr;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Hubs API DELETE Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { NextResponse } from 'next/server';
|
||||
import kecamatanData from '@/lib/Admin_Kecamatan.json';
|
||||
|
||||
const isPointInPolygon = (point, polygon) => {
|
||||
const x = point[0], y = point[1];
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0], yi = polygon[i][1];
|
||||
const xj = polygon[j][0], yj = polygon[j][1];
|
||||
const intersect = ((yi > y) !== (yj > y))
|
||||
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
const getKecamatanForPoint = (lat, lng) => {
|
||||
if (!kecamatanData || !kecamatanData.features) return null;
|
||||
for (const feature of kecamatanData.features) {
|
||||
const name = feature.properties?.Ket;
|
||||
const geom = feature.geometry;
|
||||
if (!name || !geom) continue;
|
||||
|
||||
if (geom.type === 'Polygon') {
|
||||
if (isPointInPolygon([lng, lat], geom.coordinates[0])) {
|
||||
return name;
|
||||
}
|
||||
} else if (geom.type === 'MultiPolygon') {
|
||||
for (const polygonCoords of geom.coordinates) {
|
||||
if (isPointInPolygon([lng, lat], polygonCoords[0])) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseLocation = (loc) => {
|
||||
if (!loc) return { lat: 0, lng: 0 };
|
||||
if (typeof loc === 'string') {
|
||||
const match = loc.match(/POINT\(([^ ]+) ([^ ]+)\)/);
|
||||
if (match) return { lng: parseFloat(match[1]), lat: parseFloat(match[2]) };
|
||||
} else if (loc.coordinates) {
|
||||
return { lng: loc.coordinates[0], lat: loc.coordinates[1] };
|
||||
}
|
||||
return { lat: 0, lng: 0 };
|
||||
};
|
||||
|
||||
// Calculate distance in meters between two points (Haversine formula approximation equivalent to Leaflet's distance)
|
||||
const distanceTo = (lat1, lon1, lat2, lon2) => {
|
||||
const R = 6371e3; // metres
|
||||
const φ1 = lat1 * Math.PI/180; // φ, λ in radians
|
||||
const φ2 = lat2 * Math.PI/180;
|
||||
const Δφ = (lat2-lat1) * Math.PI/180;
|
||||
const Δλ = (lon2-lon1) * Math.PI/180;
|
||||
|
||||
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) *
|
||||
Math.sin(Δλ/2) * Math.sin(Δλ/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
|
||||
return R * c;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fetch all hubs and households using admin privileges
|
||||
const { data: hubsData, error: hubsErr } = await supabaseAdmin.from('hubs').select('*');
|
||||
const { data: hhData, error: hhErr } = await supabaseAdmin.from('households').select('*');
|
||||
|
||||
if (hubsErr) throw hubsErr;
|
||||
if (hhErr) throw hhErr;
|
||||
|
||||
const parsedHubs = (hubsData || []).map(hub => {
|
||||
const { lat, lng } = parseLocation(hub.location);
|
||||
return { ...hub, lat, lng, radius: hub.radius_meter };
|
||||
});
|
||||
|
||||
let coveredCount = 0;
|
||||
const typeCounts = {};
|
||||
const subDistrictsMap = {};
|
||||
const subDistrictsList = ['Pontianak Barat', 'Pontianak Kota', 'Pontianak Selatan', 'Pontianak Tenggara', 'Pontianak Timur', 'Pontianak Utara'];
|
||||
|
||||
subDistrictsList.forEach(sub => {
|
||||
subDistrictsMap[sub] = { covered: 0, uncovered: 0 };
|
||||
});
|
||||
|
||||
(hhData || []).forEach((hh, idx) => {
|
||||
const { lat, lng } = parseLocation(hh.location);
|
||||
|
||||
let closestHub = null;
|
||||
let minDistance = Infinity;
|
||||
|
||||
parsedHubs.forEach(hub => {
|
||||
const dist = distanceTo(lat, lng, hub.lat, hub.lng);
|
||||
const hubRadius = parseInt(hub.radius) || 500;
|
||||
if (dist <= hubRadius && dist < minDistance) {
|
||||
minDistance = dist;
|
||||
closestHub = hub;
|
||||
}
|
||||
});
|
||||
|
||||
const covered = !!closestHub;
|
||||
if (covered) {
|
||||
coveredCount++;
|
||||
const type = closestHub.type;
|
||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Sub-district logic (now provided by PostGIS database trigger with dynamic backend JS fallback!)
|
||||
let sub = hh.sub_district;
|
||||
if (!sub || sub === 'Belum Terpetakan') {
|
||||
const coords = parseLocation(hh.location);
|
||||
sub = getKecamatanForPoint(coords.lat, coords.lng) || 'Belum Terpetakan';
|
||||
}
|
||||
|
||||
if (!subDistrictsMap[sub]) subDistrictsMap[sub] = { covered: 0, uncovered: 0 };
|
||||
|
||||
if (covered) subDistrictsMap[sub].covered++;
|
||||
else subDistrictsMap[sub].uncovered++;
|
||||
});
|
||||
|
||||
const total = hhData ? hhData.length : 0;
|
||||
const uncoveredCount = total - coveredCount;
|
||||
const equalityIndex = total > 0 ? Math.round((coveredCount / total) * 100) : 0;
|
||||
|
||||
return NextResponse.json({
|
||||
hubsCount: parsedHubs.length,
|
||||
coveredCount,
|
||||
uncoveredCount,
|
||||
equalityIndex,
|
||||
subDistrictsMap,
|
||||
typeCounts
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Stats API Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Fetch auth users
|
||||
const { data: authData, error: authErr } = await supabaseAdmin.auth.admin.listUsers();
|
||||
if (authErr) throw authErr;
|
||||
|
||||
// Fetch profiles
|
||||
const { data: profilesData, error: profErr } = await supabaseAdmin.from('profiles').select('*');
|
||||
if (profErr) throw profErr;
|
||||
|
||||
// Merge them
|
||||
const users = (authData.users || []).map(authUser => {
|
||||
const profile = profilesData.find(p => p.id === authUser.id) || {};
|
||||
return {
|
||||
id: authUser.id,
|
||||
email: authUser.email,
|
||||
full_name: profile.full_name || 'N/A',
|
||||
role: profile.role || 'public',
|
||||
password_changed: profile.password_changed,
|
||||
created_at: authUser.created_at
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('Users API GET Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||
const body = await request.json();
|
||||
const { email, password, full_name, role, hub_id } = body;
|
||||
|
||||
if (!email || !password || !full_name || !role) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Create user in Auth
|
||||
const { data: authData, error: authErr } = await supabaseAdmin.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: true, // Auto-confirm email
|
||||
});
|
||||
|
||||
if (authErr) throw authErr;
|
||||
|
||||
const newUserId = authData.user.id;
|
||||
|
||||
// 2. Insert into profiles (bypassing RLS with service role)
|
||||
const { error: profErr } = await supabaseAdmin.from('profiles').insert([{
|
||||
id: newUserId,
|
||||
full_name,
|
||||
role,
|
||||
password_changed: false // Force change password on first login
|
||||
}]);
|
||||
|
||||
// If profile insert fails, we rollback the auth user creation
|
||||
if (profErr) {
|
||||
await supabaseAdmin.auth.admin.deleteUser(newUserId);
|
||||
throw profErr;
|
||||
}
|
||||
|
||||
// 3. Assign user to worship hub (if role is pic or hub_head and hub_id is provided)
|
||||
if (hub_id && (role === 'pic' || role === 'hub_head')) {
|
||||
const updateData = {};
|
||||
if (role === 'pic') {
|
||||
updateData.pic_id = newUserId;
|
||||
} else if (role === 'hub_head') {
|
||||
updateData.head_id = newUserId;
|
||||
}
|
||||
|
||||
const { error: hubErr } = await supabaseAdmin
|
||||
.from('hubs')
|
||||
.update(updateData)
|
||||
.eq('id', hub_id);
|
||||
|
||||
if (hubErr) {
|
||||
// Rollback profile and auth user
|
||||
await supabaseAdmin.from('profiles').delete().eq('id', newUserId);
|
||||
await supabaseAdmin.auth.admin.deleteUser(newUserId);
|
||||
throw hubErr;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, user: authData.user });
|
||||
} catch (error) {
|
||||
console.error('Users API POST Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Service role key not configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Using searchParams to get ID
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'Missing user id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete from auth.users (Cascades to profiles table via foreign key)
|
||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Users API DELETE Error:', error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function ChangePassword() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [user, setUser] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getUser().then(({ data }) => {
|
||||
if (!data?.user) router.push('/login');
|
||||
else setUser(data.user);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
const handleUpdate = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error: authError } = await supabase.auth.updateUser({ password });
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ password_changed: true })
|
||||
.eq('id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
setError(profileError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f8fafc',
|
||||
fontFamily: '"Inter", sans-serif'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '40px',
|
||||
borderRadius: '24px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.05)',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#1e293b', fontSize: '1.5rem' }}>Rotasi Keamanan</h2>
|
||||
<p style={{ margin: '0 0 24px 0', color: '#ef4444', fontSize: '0.85rem', fontWeight: '500', background: '#fee2e2', padding: '10px', borderRadius: '8px' }}>
|
||||
<i className="fas fa-lock"></i> Untuk alasan keamanan, Anda wajib mengubah kata sandi default sebelum melanjutkan.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '10px', borderRadius: '8px', marginBottom: '16px', fontSize: '0.85rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleUpdate} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '500' }}>Kata Sandi Baru</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={6}
|
||||
style={{
|
||||
width: '100%', padding: '12px 44px 12px 16px', borderRadius: '12px',
|
||||
border: '1px solid #cbd5e1', background: 'white',
|
||||
outline: 'none', transition: 'border-color 0.2s', color: 'black',
|
||||
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1rem',
|
||||
outline: 'none'
|
||||
}}
|
||||
title={showPassword ? 'Sembunyikan Kata Sandi' : 'Tampilkan Kata Sandi'}
|
||||
>
|
||||
<i className={showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: '12px',
|
||||
background: '#10b981', color: 'white',
|
||||
border: 'none', fontWeight: '600', fontSize: '1rem',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
marginTop: '8px', transition: 'background 0.2s',
|
||||
opacity: loading ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{loading ? 'Menyimpan...' : 'Simpan & Lanjutkan'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,784 @@
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--dark: #0f172a;
|
||||
--panel-bg: rgba(255, 255, 255, 0.85); /* Slightly less transparent for readability */
|
||||
--panel-border: rgba(255, 255, 255, 0.5);
|
||||
--text-main: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
--glow: 0 0 40px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
body, html {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f0f0f0; /* Light background behind map */
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Glassmorphism Panels */
|
||||
.glass-panel {
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Top Header */
|
||||
.top-bar {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 96%;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.top-bar.hidden {
|
||||
transform: translate(-50%, -150%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo i {
|
||||
color: var(--primary);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 32px;
|
||||
padding: 10px 24px;
|
||||
width: 40%;
|
||||
max-width: 500px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.search-container input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
margin-left: 12px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-container input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: linear-gradient(135deg, var(--primary), #6366f1);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 32px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
/* Left Panel */
|
||||
.left-panel {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
left: 2%;
|
||||
width: 300px;
|
||||
padding: 24px;
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.left-panel.hidden {
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-title .title-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Dropdown & Buttons */
|
||||
.btn-group {
|
||||
position: relative;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% + 10px); /* Show on the right instead of covering the edit mode toggle */
|
||||
width: 220px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
z-index: 1001;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.submenu.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.submenu button {
|
||||
padding: 12px 16px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.submenu button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.toggle-group label {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
transition: .4s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Checkboxes */
|
||||
.filter-group h4 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.checkbox-item input {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-item input:checked {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.checkbox-item input:checked::after {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
font-size: 10px;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Right Panel */
|
||||
.right-panel {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
right: 2%;
|
||||
width: 380px;
|
||||
padding: 24px;
|
||||
max-height: calc(100vh - 120px);
|
||||
overflow-y: auto;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
.right-panel.hidden {
|
||||
transform: translateX(120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||
.right-panel::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
|
||||
|
||||
.btn-toggle-panel {
|
||||
background: rgba(0,0,0,0.05);
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-toggle-panel:hover {
|
||||
background: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.show-panel-btn {
|
||||
position: absolute;
|
||||
padding: 10px 18px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--text-main);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.show-panel-btn:hover {
|
||||
background: rgba(241, 245, 249, 0.95);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.show-panel-btn.hidden {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#showTopBarBtn { top: 20px; left: 50%; transform: translateX(-50%); }
|
||||
#showTopBarBtn:hover { transform: translateX(-50%) translateY(-2px); }
|
||||
#showControlsBtn { top: 100px; left: 2%; }
|
||||
#showStatsBtn { top: 100px; right: 2%; }
|
||||
#showLegendBtn { bottom: 30px; left: 2%; }
|
||||
|
||||
.kpi-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.kpi-box {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
padding: 16px 12px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.text-success { color: var(--success); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-primary { color: #60a5fa; }
|
||||
|
||||
.chart-box {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chart-box h4 {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Gauge */
|
||||
.gauge-container {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
width: 160px;
|
||||
height: 80px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-top-left-radius: 160px;
|
||||
border-top-right-radius: 160px;
|
||||
}
|
||||
|
||||
.gauge-fill {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, var(--danger), var(--warning), var(--success));
|
||||
transform-origin: center top;
|
||||
transform: rotate(0turn);
|
||||
transition: transform 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.gauge-cover {
|
||||
width: 130px;
|
||||
height: 65px;
|
||||
background: white;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 15px;
|
||||
border-top-left-radius: 130px;
|
||||
border-top-right-radius: 130px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.legend {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 2%;
|
||||
padding: 16px;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 1000;
|
||||
width: 230px;
|
||||
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.legend.hidden {
|
||||
transform: translateX(-120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.hub-legend-icon {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; color: white; border: 1px solid rgba(255,255,255,0.8);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Custom Map Icons & Popups */
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
.leaflet-popup-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.popup-slider-container {
|
||||
margin-top: 12px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.popup-slider-container label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.popup-slider-container input[type=range] {
|
||||
width: 100%;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
.popup-slider-container input[type=range]:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.citizen-modal {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.citizen-photo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: rgba(0,0,0,0.05);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(0,0,0,0.3);
|
||||
font-size: 2rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
.citizen-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-content {
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.5);
|
||||
transform: translateY(20px);
|
||||
transition: transform 0.3s ease;
|
||||
color: var(--text-main);
|
||||
}
|
||||
.modal-overlay.active .modal-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;
|
||||
}
|
||||
.close-btn {
|
||||
background: none; border: none; font-size: 1.5rem; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; font-size: 0.85rem; margin-bottom: 6px; color: var(--text-muted); }
|
||||
.form-control {
|
||||
width: 100%; padding: 10px 12px; background: rgba(0,0,0,0.05); border: 1px solid rgba(0,0,0,0.1);
|
||||
border-radius: 8px; color: var(--text-main); font-size: 0.95rem;
|
||||
}
|
||||
.form-control:focus { outline: none; border-color: var(--primary); }
|
||||
.modal-actions { display: flex; gap: 12px; margin-top: 24px; }
|
||||
.modal-actions .btn { flex: 1; }
|
||||
|
||||
.btn-success { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.4); }
|
||||
.btn-success:hover { background: rgba(16, 185, 129, 0.3); }
|
||||
|
||||
.placement-toast {
|
||||
position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%);
|
||||
padding: 12px 24px; border-radius: 30px; font-weight: 500;
|
||||
z-index: 1500; display: none; align-items: center; gap: 10px;
|
||||
}
|
||||
.placement-toast button {
|
||||
background: rgba(0,0,0,0.1); border: none; color: var(--text-main); padding: 6px 12px;
|
||||
border-radius: 12px; cursor: pointer; font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Login Specific Styles */
|
||||
.map-glow-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.25) 0%, rgba(248, 250, 252, 0.85) 100%);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px;
|
||||
background: var(--panel-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow), var(--glow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translate(-50%, -40%); }
|
||||
to { opacity: 1; transform: translate(-50%, -50%); }
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, var(--primary), #6366f1);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.6);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(120%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes fadeInScale {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Suppress native password reveal/clear buttons in IE/Edge to prevent double eye icon stacking */
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.8), 0 4px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0), 0 4px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0), 0 4px 10px rgba(0,0,0,0.4);
|
||||
}
|
||||
}
|
||||
.pulse-border {
|
||||
animation: pulse-border 1.8s infinite !important;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Outfit } from "next/font/google";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "./globals.css";
|
||||
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Sinergi Umat - Community Aid Distribution & Mapping System",
|
||||
description: "A spatial assignment application to map and manage distribution of community aid to underprivileged households.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
</head>
|
||||
<body className={outfit.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
// Dynamically load Leaflet for the background map
|
||||
import('leaflet').then((L) => {
|
||||
if (!active) return;
|
||||
|
||||
if (!mapInstanceRef.current && mapRef.current) {
|
||||
// Initialize map centered on Pontianak
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [-0.0227, 109.3340],
|
||||
zoom: 13,
|
||||
zoomControl: false,
|
||||
dragging: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
attributionControl: false
|
||||
});
|
||||
|
||||
// Use CartoDB Positron for a clean, light background
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
maxZoom: 19,
|
||||
subdomains: 'abcd'
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Force resize recalculation to ensure tiles render immediately
|
||||
setTimeout(() => {
|
||||
if (active && mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { data, error: authError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if they need password rotation
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('password_changed')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (profile && profile.password_changed === false) {
|
||||
router.push('/change-password');
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: '"Inter", sans-serif',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Return to Dashboard Button */}
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '24px',
|
||||
left: '24px',
|
||||
zIndex: 10,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.6)',
|
||||
borderRadius: '14px',
|
||||
padding: '10px 18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
color: '#1e293b',
|
||||
fontWeight: '600',
|
||||
fontSize: '0.9rem',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'all 0.2s',
|
||||
outline: 'none'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.85)';
|
||||
}}
|
||||
>
|
||||
<i className="fas fa-arrow-left" style={{ color: 'var(--primary, #3b82f6)' }}></i>
|
||||
<span>Kembali ke Dasbor</span>
|
||||
</button>
|
||||
|
||||
{/* Dynamic Leaflet Background */}
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 1,
|
||||
filter: 'brightness(0.95)'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glassmorphism Dark/Blur Overlay */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0, bottom: 0,
|
||||
zIndex: 2,
|
||||
background: 'rgba(15, 23, 42, 0.45)', // Slightly reduced opacity for a clearer view of the map
|
||||
backdropFilter: 'blur(4px)', // Reduced from 10px
|
||||
WebkitBackdropFilter: 'blur(4px)'
|
||||
}} />
|
||||
|
||||
{/* Login Card */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(8px)', // Reduced from 20px
|
||||
WebkitBackdropFilter: 'blur(8px)',
|
||||
padding: '40px',
|
||||
borderRadius: '24px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
textAlign: 'center',
|
||||
border: '1px solid rgba(255, 255, 255, 0.6)',
|
||||
zIndex: 10
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--primary, #3b82f6)',
|
||||
width: '60px', height: '60px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontSize: '24px',
|
||||
margin: '0 auto 20px auto',
|
||||
boxShadow: '0 10px 15px -3px rgba(59, 130, 246, 0.5)'
|
||||
}}>
|
||||
<i className="fas fa-hands-holding-circle"></i>
|
||||
</div>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#1e293b', fontSize: '1.8rem', fontWeight: '800' }}>Sinergi Umat</h2>
|
||||
<p style={{ margin: '0 0 24px 0', color: '#64748b', fontSize: '0.95rem' }}>Portal Manajemen Bantuan & Geotagging</p>
|
||||
|
||||
{error && (
|
||||
<div style={{ background: '#fee2e2', color: '#ef4444', padding: '10px', borderRadius: '8px', marginBottom: '16px', fontSize: '0.85rem', fontWeight: '500' }}>
|
||||
<i className="fas fa-exclamation-circle" style={{ marginRight: '5px' }}></i> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '600' }}>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '12px 16px', borderRadius: '12px',
|
||||
border: '1px solid #cbd5e1', background: 'white',
|
||||
outline: 'none', transition: 'all 0.2s', color: '#1e293b',
|
||||
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = 'var(--primary, #3b82f6)'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#cbd5e1'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.85rem', color: '#475569', marginBottom: '6px', fontWeight: '600' }}>Kata Sandi</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '12px 44px 12px 16px', borderRadius: '12px',
|
||||
border: '1px solid #cbd5e1', background: 'white',
|
||||
outline: 'none', transition: 'all 0.2s', color: '#1e293b',
|
||||
boxShadow: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onFocus={(e) => e.target.style.borderColor = 'var(--primary, #3b82f6)'}
|
||||
onBlur={(e) => e.target.style.borderColor = '#cbd5e1'}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '14px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1rem',
|
||||
outline: 'none'
|
||||
}}
|
||||
title={showPassword ? 'Sembunyikan Kata Sandi' : 'Tampilkan Kata Sandi'}
|
||||
>
|
||||
<i className={showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%', padding: '14px', borderRadius: '12px',
|
||||
background: 'var(--primary, #3b82f6)', color: 'white',
|
||||
border: 'none', fontWeight: '600', fontSize: '1rem',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
marginTop: '8px', transition: 'all 0.2s',
|
||||
opacity: loading ? 0.8 : 1,
|
||||
boxShadow: loading ? 'none' : '0 4px 6px -1px rgba(59, 130, 246, 0.5)'
|
||||
}}
|
||||
onMouseOver={(e) => { if(!loading) e.target.style.transform = 'translateY(-2px)' }}
|
||||
onMouseOut={(e) => { if(!loading) e.target.style.transform = 'translateY(0)' }}
|
||||
>
|
||||
{loading ? <><i className="fas fa-spinner fa-spin"></i> Memverifikasi...</> : 'Masuk ke Dasbor'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Load Leaflet map on client-side ONLY to bypass window-not-defined Node errors
|
||||
const Dashboard = dynamic(() => import('@/components/Dashboard'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||
<Dashboard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.page {
|
||||
--background: #fafafa;
|
||||
--foreground: #fff;
|
||||
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
--button-secondary-border: #ebebeb;
|
||||
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-geist-sans);
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background-color: var(--foreground);
|
||||
padding: 120px 60px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
max-width: 320px;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
letter-spacing: -2.4px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.intro p {
|
||||
max-width: 440px;
|
||||
font-size: 18px;
|
||||
line-height: 32px;
|
||||
text-wrap: balance;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.intro a {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
gap: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 128px;
|
||||
border: 1px solid transparent;
|
||||
transition: 0.2s;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--button-secondary-border);
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.main {
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.intro h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
letter-spacing: -1.92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
|
||||
.page {
|
||||
--background: #000;
|
||||
--foreground: #000;
|
||||
|
||||
--text-primary: #ededed;
|
||||
--text-secondary: #999;
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
--button-secondary-border: #1a1a1a;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user