Initial commit

This commit is contained in:
Monarch055
2026-06-10 19:36:52 +07:00
commit 7ad037bf0a
69 changed files with 16579 additions and 0 deletions
+67
View File
@@ -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 });
}
}
+151
View File
@@ -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 });
}
}
+143
View File
@@ -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

+784
View File
@@ -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;
}
+26
View File
@@ -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>
);
}
+281
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+142
View File
@@ -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;
}
}