feat: add Jordan 3D topographic platform, Siro Map cinematic orbit studio, sun-shadow simulation, and sovereign intelligence security gating

This commit is contained in:
Hamza-Ayed
2026-09-19 12:34:26 +03:00
parent 91e9e71ce4
commit 0bf13824c9
180 changed files with 245224 additions and 704 deletions
+179 -3
View File
@@ -4,6 +4,9 @@ import { Navigation, Compass, Activity, BarChart3, MapPin, Eye, Shield, Gauge, C
import { decodePolyline } from './utils/polyline';
import WeatherPanel from './components/WeatherPanel';
import { LineOfSightTool } from './components/LineOfSightTool';
import { LandmarkCard, HeritageLandmarkData } from './components/LandmarkCard';
import { SubmitContributionModal } from './components/SubmitContributionModal';
import { GuidesModerationPanel } from './components/GuidesModerationPanel';
const DEFAULT_API_KEY = (import.meta as any).env.VITE_API_KEY || localStorage.getItem('intaleq_api_key') || '';
@@ -30,6 +33,14 @@ function App() {
const [weatherCity, setWeatherCity] = useState<any>(null);
const [weatherAlerts, setWeatherAlerts] = useState<any[]>([]);
// Tourism & Heritage State
const [tourismMode, setTourismMode] = useState(true);
const [heritageLandmarks, setHeritageLandmarks] = useState<HeritageLandmarkData[]>([]);
const [selectedLandmark, setSelectedLandmark] = useState<HeritageLandmarkData | null>(null);
const [showContributeModal, setShowContributeModal] = useState(false);
const [contributeTargetLandmark, setContributeTargetLandmark] = useState<HeritageLandmarkData | null>(null);
const [showGuidesPanel, setShowGuidesPanel] = useState(false);
// Geocoding State
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
@@ -40,6 +51,36 @@ function App() {
const [originText, setOriginText] = useState('31.9539, 35.9106');
const [destText, setDestText] = useState('32.0608, 36.1032');
const fetchHeritageLandmarks = async () => {
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const res = await fetch(`${apiUrl}/v1/heritage/landmarks`, {
headers: { 'x-api-key': DEFAULT_API_KEY }
});
if (res.ok) {
const data = await res.json();
setHeritageLandmarks(Array.isArray(data) ? data : []);
}
} catch (e) {
console.error('Failed to fetch heritage landmarks', e);
}
};
useEffect(() => {
fetchHeritageLandmarks();
}, []);
const handleNavigateToGate = (lat: number, lng: number, name: string) => {
setDestText(`${lat.toFixed(5)}, ${lng.toFixed(5)}`);
if (map) {
const center = map.getCenter();
setOriginText(`${center.lat.toFixed(5)}, ${center.lng.toFixed(5)}`);
}
setTimeout(() => {
calculateRoute();
}, 150);
};
const regions = [
{
name: 'Syria',
@@ -59,6 +100,15 @@ function App() {
defaultOrigin: '31.9539, 35.9106', // Amman
defaultDest: '32.0608, 36.1032' // Zarqa
},
{
name: 'Iraq',
name_ar: 'العراق',
center: [44.3661, 33.3152],
zoom: 11,
flag: '🇮🇶',
defaultOrigin: '33.3152, 44.3661', // Baghdad
defaultDest: '32.0000, 44.4000' // Karbala / Babil
},
{
name: 'Egypt',
name_ar: 'مصر',
@@ -112,12 +162,24 @@ function App() {
headers: { 'x-api-key': DEFAULT_API_KEY }
});
const data = await response.json();
let results = data.results || [];
setSearchResults(data.results || []);
// If no local results within 50km, fall back to nationwide/global search
if (results.length === 0) {
const fallbackRes = await fetch(`${apiUrl}/geocoding/search?q=${encodeURIComponent(searchQuery)}`, {
headers: { 'x-api-key': DEFAULT_API_KEY }
});
const fallbackData = await fallbackRes.json();
if (fallbackData.results && fallbackData.results.length > 0) {
results = fallbackData.results;
}
}
setSearchResults(results);
setShowResults(true);
if (data.results && data.results.length > 0 && map) {
const place = data.results[0];
if (results && results.length > 0 && map) {
const place = results[0];
const placeLng = parseFloat(place.longitude);
const placeLat = parseFloat(place.latitude);
if (!isNaN(placeLat) && !isNaN(placeLng)) {
@@ -277,6 +339,76 @@ function App() {
<h1>{currentRegion} Maps SaaS</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>Intaleq Mobility & Maps Cloud</p>
{/* Siro Tourism & Heritage Mode Toggle */}
<div style={{ display: 'flex', gap: '8px', margin: '14px 0 6px' }}>
<button
onClick={() => setTourismMode(!tourismMode)}
style={{
flex: 1.2,
padding: '10px 8px',
borderRadius: '10px',
border: tourismMode ? '1.5px solid #d4af37' : '1px solid var(--glass-border)',
background: tourismMode
? 'linear-gradient(135deg, rgba(181, 146, 71, 0.3) 0%, rgba(212, 175, 55, 0.15) 100%)'
: 'rgba(255,255,255,0.04)',
color: tourismMode ? '#fef08a' : '#e2e8f0',
fontWeight: 800,
fontSize: '0.78rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
transition: 'all 0.2s',
boxShadow: tourismMode ? '0 4px 14px rgba(212, 175, 55, 0.25)' : 'none',
}}
>
<span>🏛️</span>
<span>{tourismMode ? 'الخريطة السياحية (مفعلة)' : 'النمط السياحي والآثار'}</span>
</button>
<button
onClick={() => setShowGuidesPanel(true)}
style={{
flex: 0.8,
padding: '10px 8px',
borderRadius: '10px',
border: '1px solid var(--glass-border)',
background: 'rgba(255,255,255,0.04)',
color: '#38bdf8',
fontWeight: 700,
fontSize: '0.78rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
}}
title="مجتمع المرشدين وطابور المراجعة"
>
<span>🌟</span>
<span>المرشدين</span>
</button>
</div>
{tourismMode && (
<div style={{
background: 'rgba(181, 146, 71, 0.1)',
border: '1px solid rgba(181, 146, 71, 0.3)',
borderRadius: '10px',
padding: '8px 10px',
fontSize: '0.75rem',
color: '#fef08a',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span>✨ معالم التراث والآثار ({heritageLandmarks.length})</span>
<span style={{ fontSize: '0.7rem', color: '#cbd5e1' }}>انقر لأي معلم لمشاهدة البوابات</span>
</div>
)}
{/* Region Selector */}
<div className="region-selector" style={{ display: 'flex', gap: '8px', margin: '15px 0' }}>
{regions.map(r => (
@@ -621,8 +753,52 @@ function App() {
losActive={showLOS}
losPointA={losPointA}
losPointB={losPointB}
tourismMode={tourismMode}
heritageLandmarks={heritageLandmarks}
selectedLandmark={selectedLandmark}
onSelectLandmark={setSelectedLandmark}
/>
{/* Interactive Landmark Card */}
{selectedLandmark && (
<LandmarkCard
landmark={selectedLandmark}
onClose={() => setSelectedLandmark(null)}
onNavigateToGate={handleNavigateToGate}
onOpenContribute={(lm) => {
setContributeTargetLandmark(lm);
setShowContributeModal(true);
}}
/>
)}
{/* Field Contributor Submission Modal */}
{showContributeModal && (
<SubmitContributionModal
landmark={contributeTargetLandmark}
mapCenter={map ? map.getCenter() : undefined}
apiKey={DEFAULT_API_KEY}
onClose={() => {
setShowContributeModal(false);
setContributeTargetLandmark(null);
}}
onSuccess={() => {
fetchHeritageLandmarks();
}}
/>
)}
{/* Community Guides & Moderation Slide-Over */}
{showGuidesPanel && (
<GuidesModerationPanel
apiKey={DEFAULT_API_KEY}
onClose={() => setShowGuidesPanel(false)}
onContributionApproved={() => {
fetchHeritageLandmarks();
}}
/>
)}
{/* Tactical Line of Sight Tool Overlay */}
<LineOfSightTool
active={showLOS}
@@ -0,0 +1,432 @@
import React, { useState, useEffect } from 'react';
import { X, Check, Trash2, Award, Shield, UserCheck, RefreshCw, Star, Layers, MapPin } from 'lucide-react';
interface GuidesModerationPanelProps {
apiKey: string;
onClose: () => void;
onContributionApproved?: () => void;
}
export const GuidesModerationPanel: React.FC<GuidesModerationPanelProps> = ({
apiKey,
onClose,
onContributionApproved,
}) => {
const [activeTab, setActiveTab] = useState<'moderation' | 'leaderboard'>('moderation');
const [pendingList, setPendingList] = useState<any[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);
const [badges, setBadges] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<number | null>(null);
const fetchPending = async () => {
setLoading(true);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const res = await fetch(`${apiUrl}/v1/admin/moderation/pending`, {
headers: { 'x-api-key': apiKey },
});
if (res.ok) {
const data = await res.json();
setPendingList(Array.isArray(data) ? data : []);
}
} catch (e) {
console.error('Failed to fetch pending contributions', e);
} finally {
setLoading(false);
}
};
const fetchLeaderboard = async () => {
setLoading(true);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const [lbRes, bgRes] = await Promise.all([
fetch(`${apiUrl}/v1/community/leaderboard?limit=20`, { headers: { 'x-api-key': apiKey } }),
fetch(`${apiUrl}/v1/community/badges`, { headers: { 'x-api-key': apiKey } }),
]);
if (lbRes.ok) {
const lbData = await lbRes.json();
setLeaderboard(Array.isArray(lbData) ? lbData : []);
}
if (bgRes.ok) {
const bgData = await bgRes.json();
setBadges(Array.isArray(bgData) ? bgData : []);
}
} catch (e) {
console.error('Failed to fetch leaderboard/badges', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (activeTab === 'moderation') {
fetchPending();
} else {
fetchLeaderboard();
}
}, [activeTab]);
const handleReview = async (contributionId: number, action: 'APPROVE' | 'REJECT') => {
setActionLoading(contributionId);
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const res = await fetch(`${apiUrl}/v1/admin/moderation/review`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify({
contributionId,
action,
reviewerName: 'Hamza Ayed (Admin / Tech Architect)',
reviewerNotes: action === 'APPROVE' ? 'تم التحقق من دقة البوابة ومطابقتها' : 'الموقع غير دقيق',
}),
});
if (res.ok) {
setPendingList((prev) => prev.filter((item) => item.id !== contributionId));
if (action === 'APPROVE' && onContributionApproved) {
onContributionApproved();
}
}
} catch (e) {
console.error('Review action failed', e);
} finally {
setActionLoading(null);
}
};
return (
<div
dir="rtl"
style={{
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
width: 'min(480px, 100vw)',
background: '#ffffff',
boxShadow: '20px 0 50px rgba(0, 0, 0, 0.25)',
zIndex: 2100,
display: 'flex',
flexDirection: 'column',
fontFamily: "'Tajawal', 'Noto Sans Arabic', system-ui, sans-serif",
animation: 'slideInLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Panel Header */}
<div
style={{
background: 'linear-gradient(135deg, #0b2547 0%, #153e75 100%)',
color: '#ffffff',
padding: '24px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#dfc28a', fontSize: '13px', fontWeight: 700, marginBottom: '4px' }}>
<Shield size={16} /> الإشراف والاعتماد السيادي
</div>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 800 }}>
مجتمع المرشدين وإدارة المعالم
</h2>
</div>
<button
onClick={onClose}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: 'none',
borderRadius: '50%',
width: '34px',
height: '34px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
cursor: 'pointer',
}}
>
<X size={18} />
</button>
</div>
{/* Tabs */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
background: '#f1f5f9',
borderBottom: '1px solid #e2e8f0',
padding: '4px',
}}
>
<button
onClick={() => setActiveTab('moderation')}
style={{
padding: '12px',
background: activeTab === 'moderation' ? '#ffffff' : 'transparent',
border: 'none',
borderRadius: '10px',
fontWeight: 800,
fontSize: '14px',
color: activeTab === 'moderation' ? '#0b2547' : '#64748b',
cursor: 'pointer',
boxShadow: activeTab === 'moderation' ? '0 2px 8px rgba(0,0,0,0.06)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<Layers size={16} /> طابور المراجعة ({pendingList.length})
</button>
<button
onClick={() => setActiveTab('leaderboard')}
style={{
padding: '12px',
background: activeTab === 'leaderboard' ? '#ffffff' : 'transparent',
border: 'none',
borderRadius: '10px',
fontWeight: 800,
fontSize: '14px',
color: activeTab === 'leaderboard' ? '#0b2547' : '#64748b',
cursor: 'pointer',
boxShadow: activeTab === 'leaderboard' ? '0 2px 8px rgba(0,0,0,0.06)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
}}
>
<Award size={16} /> المتصدرون والشارات
</button>
</div>
{/* Content Area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px' }}>
{activeTab === 'moderation' ? (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<span style={{ fontSize: '13px', color: '#64748b' }}>
المساهمات الميدانية بانتظار الاعتماد
</span>
<button
onClick={fetchPending}
style={{
background: 'none',
border: 'none',
color: '#2563eb',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<RefreshCw size={12} /> تحديث
</button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#94a3b8' }}>
جارٍ جلب طابور المساهمات...
</div>
) : pendingList.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '50px 20px',
background: '#f8fafc',
borderRadius: '16px',
border: '1px dashed #cbd5e1',
}}
>
<UserCheck size={40} color="#16a34a" style={{ margin: '0 auto 12px' }} />
<h4 style={{ margin: '0 0 6px', fontSize: '16px', color: '#1e293b' }}>
طابور المراجعة فارغ تماماً!
</h4>
<p style={{ margin: 0, fontSize: '13px', color: '#64748b' }}>
تم اعتماد ومطابقة جميع المساهمات الميدانية السابقة.
</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
{pendingList.map((item) => (
<div
key={item.id}
style={{
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: '14px',
padding: '16px',
boxShadow: '0 4px 12px rgba(0,0,0,0.03)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
<div>
<span
style={{
fontSize: '11px',
fontWeight: 700,
padding: '2px 8px',
borderRadius: '12px',
background: '#fef3c7',
color: '#b45309',
}}
>
{item.contribution_type === 'CONFIRM_GATE' ? '🎟️ تأكيد بوابة' : item.contribution_type}
</span>
<h4 style={{ margin: '6px 0 2px', fontSize: '15px', fontWeight: 800, color: '#0f172a' }}>
{item.place_name || 'معلم أثري'}
</h4>
</div>
<span style={{ fontSize: '11px', color: '#94a3b8' }}>#{item.id}</span>
</div>
<div style={{ fontSize: '13px', color: '#475569', marginBottom: '6px' }}>
المساهم: <strong>{item.guide?.display_name || 'مرشد محلي'}</strong>
</div>
{item.notes && (
<p style={{ margin: '0 0 10px', fontSize: '12px', color: '#64748b', background: '#f8fafc', padding: '8px 10px', borderRadius: '8px' }}>
«{item.notes}»
</p>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button
onClick={() => handleReview(item.id, 'APPROVE')}
disabled={actionLoading === item.id}
style={{
flex: 1,
background: '#16a34a',
color: '#ffffff',
border: 'none',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
}}
>
<Check size={14} /> اعتماد ومنح النقاط
</button>
<button
onClick={() => handleReview(item.id, 'REJECT')}
disabled={actionLoading === item.id}
style={{
background: '#fef2f2',
color: '#dc2626',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '12px',
fontWeight: 700,
cursor: 'pointer',
}}
>
رفض
</button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div style={{ marginBottom: '24px' }}>
<h3 style={{ margin: '0 0 12px', fontSize: '15px', fontWeight: 800, color: '#0f172a' }}>
🏅 الشارات التقديرية للأدلاء والمساهمين
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
{badges.length > 0 ? (
badges.map((b) => (
<div
key={b.id}
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '12px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{b.icon || '🎖️'}</div>
<div style={{ fontSize: '13px', fontWeight: 800, color: '#0f172a' }}>{b.name_ar}</div>
<div style={{ fontSize: '11px', color: '#64748b', marginTop: '2px' }}>{b.description_ar}</div>
</div>
))
) : (
<div style={{ color: '#94a3b8', fontSize: '13px' }}>جارٍ تحميل الشارات...</div>
)}
</div>
</div>
<div>
<h3 style={{ margin: '0 0 12px', fontSize: '15px', fontWeight: 800, color: '#0f172a' }}>
🏆 المتصدرون في توثيق البوابات
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{leaderboard.map((guide, idx) => (
<div
key={guide.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 14px',
background: idx === 0 ? '#fefce8' : '#ffffff',
border: idx === 0 ? '1px solid #fef08a' : '1px solid #e2e8f0',
borderRadius: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: idx === 0 ? '#eab308' : '#e2e8f0',
color: idx === 0 ? '#ffffff' : '#475569',
fontSize: '12px',
fontWeight: 800,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{idx + 1}
</span>
<div>
<div style={{ fontSize: '14px', fontWeight: 800, color: '#0f172a' }}>
{guide.display_name}
</div>
<div style={{ fontSize: '11px', color: '#64748b' }}>
المستوى: {guide.level} ({guide.verified_gates_count} بوابة موثقة)
</div>
</div>
</div>
<span style={{ fontSize: '13px', fontWeight: 800, color: '#b59247' }}>
{guide.points} نقطة
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
};
+516
View File
@@ -0,0 +1,516 @@
import React from 'react';
import { Navigation, MapPin, ShieldCheck, Clock, Ticket, Car, Volume2, Edit3, X } from 'lucide-react';
export interface HeritageLandmarkData {
id: number;
slug: string;
name_ar: string;
name_en?: string;
category: string;
era?: string;
narrative_ar?: string;
audio_url?: string;
image_url?: string;
governorate?: string;
is_verified?: boolean;
metadata?: Record<string, any>;
coordinates: {
centroid: { lat: number; lng: number };
access_gate?: { lat: number; lng: number };
parking?: { lat: number; lng: number };
ticket_office?: { lat: number; lng: number };
};
}
interface LandmarkCardProps {
landmark: HeritageLandmarkData;
onClose: () => void;
onNavigateToGate: (lat: number, lng: number, name: string) => void;
onOpenContribute: (landmark: HeritageLandmarkData) => void;
}
export const LandmarkCard: React.FC<LandmarkCardProps> = ({
landmark,
onClose,
onNavigateToGate,
onOpenContribute,
}) => {
const [activeImageIdx, setActiveImageIdx] = React.useState(0);
const gate = landmark.coordinates.access_gate;
const parking = landmark.coordinates.parking;
const ticket = landmark.coordinates.ticket_office;
const gallery: Array<{
url: string;
caption_ar?: string;
author?: string;
license?: string;
source_url?: string;
}> =
landmark.metadata?.gallery && Array.isArray(landmark.metadata.gallery) && landmark.metadata.gallery.length > 0
? landmark.metadata.gallery
: landmark.image_url
? [{ url: landmark.image_url, caption_ar: landmark.name_ar, author: 'الموسوعة المفتوحة', license: 'CC BY-SA 4.0' }]
: [];
const currentImage = gallery[activeImageIdx];
return (
<div
dir="rtl"
style={{
position: 'absolute',
bottom: '24px',
right: '24px',
width: 'min(440px, calc(100vw - 48px))',
maxHeight: '88vh',
overflowY: 'auto',
background: 'rgba(255, 255, 255, 0.98)',
backdropFilter: 'blur(20px)',
borderRadius: '20px',
boxShadow: '0 20px 50px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(181, 146, 71, 0.3)',
zIndex: 1000,
fontFamily: "'Tajawal', 'Noto Sans Arabic', system-ui, sans-serif",
color: '#1e293b',
border: '1px solid rgba(226, 232, 240, 0.8)',
animation: 'slideUpCard 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Photo Gallery Banner */}
{gallery.length > 0 && currentImage && (
<div style={{ position: 'relative', width: '100%', height: '210px', background: '#0f172a', overflow: 'hidden', borderTopLeftRadius: '19px', borderTopRightRadius: '19px' }}>
<img
src={currentImage.url}
alt={currentImage.caption_ar || landmark.name_ar}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.3s ease',
}}
/>
{/* Dark gradient shadow */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to bottom, rgba(11,37,71,0.4) 0%, transparent 40%, rgba(11,37,71,0.85) 100%)',
pointerEvents: 'none',
}}
/>
{/* Close button on image */}
<button
onClick={onClose}
style={{
position: 'absolute',
top: '12px',
left: '12px',
background: 'rgba(15, 23, 42, 0.65)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
cursor: 'pointer',
zIndex: 5,
}}
title="إغلاق"
>
<X size={16} />
</button>
{/* Multi-image navigation controls */}
{gallery.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
setActiveImageIdx((prev) => (prev > 0 ? prev - 1 : gallery.length - 1));
}}
style={{
position: 'absolute',
top: '50%',
right: '10px',
transform: 'translateY(-50%)',
background: 'rgba(15, 23, 42, 0.6)',
color: '#ffffff',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
zIndex: 4,
}}
>
›
</button>
<button
onClick={(e) => {
e.stopPropagation();
setActiveImageIdx((prev) => (prev < gallery.length - 1 ? prev + 1 : 0));
}}
style={{
position: 'absolute',
top: '50%',
left: '10px',
transform: 'translateY(-50%)',
background: 'rgba(15, 23, 42, 0.6)',
color: '#ffffff',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
zIndex: 4,
}}
>
‹
</button>
{/* Counter Indicator */}
<div
style={{
position: 'absolute',
top: '12px',
right: '12px',
background: 'rgba(15, 23, 42, 0.7)',
color: '#fde047',
fontSize: '11px',
fontWeight: 700,
padding: '2px 8px',
borderRadius: '12px',
border: '1px solid rgba(253, 224, 71, 0.3)',
}}
>
{activeImageIdx + 1} / {gallery.length}
</div>
</>
)}
{/* Legal Attribution & Caption Bar */}
<div
style={{
position: 'absolute',
bottom: '8px',
right: '12px',
left: '12px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11px',
color: '#e2e8f0',
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
}}
>
<span style={{ fontWeight: 600, maxWidth: '65%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{currentImage.caption_ar || landmark.name_ar}
</span>
<span style={{ fontSize: '10px', color: '#cbd5e1', opacity: 0.9 }}>
{currentImage.author ? `📷 ${currentImage.author}` : 'مشاع إبداعي'}
{currentImage.license ? ` (${currentImage.license})` : ''}
</span>
</div>
</div>
)}
{/* Header Banner with Antique Gold Accent */}
<div
style={{
position: 'relative',
background: gallery.length > 0 ? '#ffffff' : 'linear-gradient(135deg, #0b2547 0%, #153e75 100%)',
color: gallery.length > 0 ? '#0f172a' : '#ffffff',
padding: '18px 20px 14px',
borderTopLeftRadius: gallery.length > 0 ? '0' : '19px',
borderTopRightRadius: gallery.length > 0 ? '0' : '19px',
borderBottom: gallery.length > 0 ? '1px solid #e2e8f0' : 'none',
}}
>
{gallery.length === 0 && (
<button
onClick={onClose}
style={{
position: 'absolute',
top: '16px',
left: '16px',
background: 'rgba(255, 255, 255, 0.15)',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
cursor: 'pointer',
transition: 'background 0.2s',
}}
title="إغلاق"
>
<X size={18} />
</button>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span
style={{
fontSize: '12px',
fontWeight: 700,
letterSpacing: '1px',
color: '#dfc28a',
background: 'rgba(223, 194, 138, 0.15)',
padding: '3px 10px',
borderRadius: '20px',
border: '1px solid rgba(223, 194, 138, 0.3)',
}}
>
🏛️ معلم أثري وتراثي
</span>
{landmark.era && (
<span
style={{
fontSize: '12px',
color: '#cbd5e1',
background: 'rgba(255, 255, 255, 0.1)',
padding: '3px 10px',
borderRadius: '20px',
}}
>
{landmark.era}
</span>
)}
</div>
<h3 style={{ margin: '0 0 4px', fontSize: '22px', fontWeight: 800, lineHeight: 1.3 }}>
{landmark.name_ar}
</h3>
{landmark.name_en && (
<p
dir="ltr"
style={{
margin: 0,
fontSize: '13px',
color: '#94a3b8',
letterSpacing: '0.3px',
textAlign: 'right',
}}
>
{landmark.name_en}
</p>
)}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginTop: '12px',
fontSize: '12px',
color: '#cbd5e1',
}}
>
{landmark.governorate && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<MapPin size={13} color="#dfc28a" /> {landmark.governorate}
</span>
)}
{landmark.is_verified && (
<span style={{ display: 'flex', alignItems: 'center', gap: '4px', color: '#6ee7b7' }}>
<ShieldCheck size={14} /> موثّق معتمد
</span>
)}
</div>
</div>
{/* Narrative & Story Section */}
<div style={{ padding: '20px' }}>
{landmark.narrative_ar && (
<div style={{ marginBottom: '20px' }}>
<div
style={{
fontSize: '13px',
fontWeight: 700,
color: '#b59247',
marginBottom: '6px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Volume2 size={15} /> سردية المكان في 60 ثانية
</div>
<p
style={{
margin: 0,
fontSize: '14px',
lineHeight: 1.8,
color: '#334155',
background: '#f8fafc',
padding: '14px',
borderRadius: '12px',
border: '1px solid #e2e8f0',
}}
>
{landmark.narrative_ar}
</p>
</div>
)}
{/* Precision Gate & Access Details */}
<div style={{ marginBottom: '20px' }}>
<div
style={{
fontSize: '13px',
fontWeight: 700,
color: '#475569',
marginBottom: '10px',
letterSpacing: '0.5px',
}}
>
📍 نقاط الوصول الميدانية الدقيقة
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
{gate ? (
<div
style={{
background: '#f0fdf4',
border: '1px solid #bbf7d0',
padding: '10px 12px',
borderRadius: '10px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#166534', fontWeight: 700, fontSize: '12px' }}>
<Ticket size={14} /> بوابة الدخول
</div>
<div style={{ fontSize: '11px', color: '#15803d', marginTop: '3px' }}>
{gate.lat.toFixed(4)}, {gate.lng.toFixed(4)}
</div>
</div>
) : (
<div
style={{
background: '#fffbeb',
border: '1px dashed #fde68a',
padding: '10px 12px',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '12px', color: '#b45309', fontWeight: 600 }}>
⚠️ لم تُحدد البوابة
</div>
<div style={{ fontSize: '10px', color: '#d97706', marginTop: '2px' }}>
يمكن للمرشدين إضافتها
</div>
</div>
)}
{parking ? (
<div
style={{
background: '#eff6ff',
border: '1px solid #bfdbfe',
padding: '10px 12px',
borderRadius: '10px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#1e40af', fontWeight: 700, fontSize: '12px' }}>
<Car size={14} /> مواقف الزوار
</div>
<div style={{ fontSize: '11px', color: '#2563eb', marginTop: '3px' }}>
{parking.lat.toFixed(4)}, {parking.lng.toFixed(4)}
</div>
</div>
) : (
<div
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
padding: '10px 12px',
borderRadius: '10px',
}}
>
<div style={{ fontSize: '12px', color: '#64748b', fontWeight: 600 }}>
🅿️ مواقف قريبة
</div>
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '2px' }}>
في محيط الموقع
</div>
</div>
)}
</div>
</div>
{/* Primary Action Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<button
onClick={() => {
const target = gate || landmark.coordinates.centroid;
onNavigateToGate(target.lat, target.lng, landmark.name_ar);
}}
style={{
width: '100%',
background: 'linear-gradient(135deg, #b59247 0%, #9a7833 100%)',
color: '#ffffff',
border: 'none',
borderRadius: '12px',
padding: '14px',
fontSize: '15px',
fontWeight: 800,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
boxShadow: '0 6px 20px rgba(181, 146, 71, 0.35)',
transition: 'transform 0.15s, box-shadow 0.15s',
}}
onMouseOver={(e) => (e.currentTarget.style.transform = 'translateY(-1px)')}
onMouseOut={(e) => (e.currentTarget.style.transform = 'translateY(0)')}
>
<Navigation size={18} /> انطلق إلى بوابة الدخول الفعلية
</button>
<button
onClick={() => onOpenContribute(landmark)}
style={{
width: '100%',
background: '#f8fafc',
color: '#475569',
border: '1px solid #cbd5e1',
borderRadius: '12px',
padding: '12px',
fontSize: '13px',
fontWeight: 700,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background 0.2s',
}}
onMouseOver={(e) => (e.currentTarget.style.background = '#f1f5f9')}
onMouseOut={(e) => (e.currentTarget.style.background = '#f8fafc')}
>
<Edit3 size={15} color="#b59247" /> اقترح تصحيحاً أو إضافة بوابة (مساهمة ميدانية)
</button>
</div>
</div>
</div>
);
};
+215 -4
View File
@@ -4,6 +4,29 @@ import 'maplibre-gl/dist/maplibre-gl.css';
import { attachIconLoader } from '../utils/mapIcons';
import { getSharedDemSource } from '../utils/demSource';
export interface MapProps {
onMapLoad?: (map: maplibregl.Map) => void;
onMapClick?: (e: maplibregl.MapMouseEvent) => void;
show3D?: boolean;
showPOIs?: boolean;
showTerrain?: boolean;
showContours?: boolean;
showAdminBoundaries?: boolean;
showWeather?: boolean;
currentRegion?: string;
onCityClick?: (city: any) => void;
losActive?: boolean;
losPointA?: [number, number] | null;
losPointB?: [number, number] | null;
tourismMode?: boolean;
heritageLandmarks?: any[];
selectedLandmark?: any;
onSelectLandmark?: (landmark: any) => void;
styleUrl?: string;
initialCenter?: [number, number];
initialZoom?: number;
}
const MapComponent: React.FC<MapProps> = ({
onMapLoad,
onMapClick,
@@ -18,9 +41,17 @@ const MapComponent: React.FC<MapProps> = ({
losActive = false,
losPointA = null,
losPointB = null,
tourismMode = false,
heritageLandmarks = [],
selectedLandmark = null,
onSelectLandmark,
styleUrl,
initialCenter,
initialZoom,
}) => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const landmarkMarkersRef = useRef<maplibregl.Marker[]>([]);
// Synchronize Line of Sight (LOS) Tactical Layer
useEffect(() => {
@@ -176,7 +207,186 @@ const MapComponent: React.FC<MapProps> = ({
map.current!.setLayoutProperty(layerId, 'visibility', showWeather ? 'visible' : 'none');
}
});
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries, showWeather]);
// In tourism mode, suppress dense noise layers and contour lines
const noiseLayers = [
'places-iraq-labels',
'places-iraq-icons',
'places-syria-labels',
'places-jordan-labels',
'places-egypt-labels',
'building-number-point',
'poi-pharmacy',
'poi-shop',
'poi-fuel',
'poi-bank',
'poi-school',
'poi-transit-station',
];
if (tourismMode) {
noiseLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', 'none');
}
});
// Force hide contours in tourism mode
contourLayers.forEach(layerId => {
if (map.current!.getLayer(layerId)) {
map.current!.setLayoutProperty(layerId, 'visibility', 'none');
}
});
if (map.current.getLayer('background')) {
map.current.setPaintProperty('background', 'background-color', '#FAF6F0');
}
} else {
if (map.current.getLayer('background')) {
map.current.setPaintProperty('background', 'background-color', '#F6F4F0');
}
}
}, [show3D, showPOIs, showTerrain, showContours, showAdminBoundaries, showWeather, tourismMode]);
// Synchronize Interactive Heritage Markers
useEffect(() => {
if (!map.current) return;
// Clear previous landmark markers
landmarkMarkersRef.current.forEach(m => m.remove());
landmarkMarkersRef.current = [];
if (!tourismMode || !heritageLandmarks || heritageLandmarks.length === 0) {
return;
}
heritageLandmarks.forEach((lm) => {
const coords = lm.coordinates?.centroid;
if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) return;
const isSelected = selectedLandmark && selectedLandmark.id === lm.id;
const el = document.createElement('div');
el.className = 'heritage-map-marker';
el.style.display = 'flex';
el.style.flexDirection = 'column';
el.style.alignItems = 'center';
el.style.cursor = 'pointer';
el.style.zIndex = isSelected ? '100' : '50';
el.style.transition = 'transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)';
el.innerHTML = `
<div style="
background: ${isSelected ? 'linear-gradient(135deg, #b59247 0%, #d4af37 100%)' : 'linear-gradient(135deg, #0b2547 0%, #153e75 100%)'};
color: white;
width: ${isSelected ? '44px' : '36px'};
height: ${isSelected ? '44px' : '36px'};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px ${isSelected ? 'rgba(212, 175, 55, 0.6)' : 'rgba(11, 37, 71, 0.4)'};
border: 2.5px solid #ffffff;
font-size: ${isSelected ? '20px' : '17px'};
">
🏛️
</div>
<div style="
margin-top: 4px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
color: #0f172a;
font-size: 11px;
font-weight: 800;
padding: 2px 8px;
border-radius: 12px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
border: 1px solid rgba(181, 146, 71, 0.4);
pointer-events: none;
">
${lm.name_ar}
</div>
`;
el.addEventListener('click', (e) => {
e.stopPropagation();
if (onSelectLandmark) {
onSelectLandmark(lm);
}
if (map.current) {
map.current.flyTo({
center: [coords.lng, coords.lat],
zoom: 15,
duration: 1500,
essential: true,
});
}
});
const marker = new maplibregl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([coords.lng, coords.lat])
.addTo(map.current);
landmarkMarkersRef.current.push(marker);
// Also if this landmark is selected, add sub-pins for access gate and parking!
if (isSelected && lm.coordinates.access_gate) {
const gateCoords = lm.coordinates.access_gate;
const gateEl = document.createElement('div');
gateEl.innerHTML = `
<div style="
background: #16a34a;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 800;
display: flex;
align-items: center;
gap: 4px;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
">
🎟️ بوابة الدخول
</div>
`;
const gateMarker = new maplibregl.Marker({ element: gateEl, anchor: 'center' })
.setLngLat([gateCoords.lng, gateCoords.lat])
.addTo(map.current);
landmarkMarkersRef.current.push(gateMarker);
}
if (isSelected && lm.coordinates.parking) {
const parkCoords = lm.coordinates.parking;
const parkEl = document.createElement('div');
parkEl.innerHTML = `
<div style="
background: #2563eb;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 800;
display: flex;
align-items: center;
gap: 4px;
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
">
🅿️ مواقف الزوار
</div>
`;
const parkMarker = new maplibregl.Marker({ element: parkEl, anchor: 'center' })
.setLngLat([parkCoords.lng, parkCoords.lat])
.addTo(map.current);
landmarkMarkersRef.current.push(parkMarker);
}
});
return () => {
landmarkMarkersRef.current.forEach(m => m.remove());
landmarkMarkersRef.current = [];
};
}, [tourismMode, heritageLandmarks, selectedLandmark, onSelectLandmark]);
// Weather data fetching and vector rendering
useEffect(() => {
@@ -425,9 +635,9 @@ const MapComponent: React.FC<MapProps> = ({
try {
const initialMap = new maplibregl.Map({
container: mapContainer.current,
style: '/style.json',
center: [35.9106, 31.9539],
zoom: 12,
style: styleUrl || (tourismMode ? '/style-tourism.json' : '/style.json'),
center: initialCenter || (tourismMode ? [44.4210, 32.5422] : [35.9106, 31.9539]),
zoom: initialZoom || (tourismMode ? 6 : 12),
attributionControl: false
});
@@ -756,4 +966,5 @@ const MapComponent: React.FC<MapProps> = ({
);
};
export { MapComponent };
export default MapComponent;
@@ -0,0 +1,347 @@
import React, { useState } from 'react';
import { X, Send, MapPin, CheckCircle2, AlertCircle, Sparkles, Shield } from 'lucide-react';
import { HeritageLandmarkData } from './LandmarkCard';
interface SubmitContributionModalProps {
landmark?: HeritageLandmarkData | null;
mapCenter?: { lat: number; lng: number };
apiKey?: string;
onClose: () => void;
onSuccess?: () => void;
}
export const SubmitContributionModal: React.FC<SubmitContributionModalProps> = ({
landmark,
mapCenter,
apiKey = '',
onClose,
onSuccess,
}) => {
const [displayName, setDisplayName] = useState('المرشد الميداني (حمزة)');
const [contributionType, setContributionType] = useState('CONFIRM_GATE');
const [lat, setLat] = useState(landmark?.coordinates.access_gate?.lat ?? mapCenter?.lat ?? 31.9539);
const [lng, setLng] = useState(landmark?.coordinates.access_gate?.lng ?? mapCenter?.lng ?? 35.9106);
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const apiUrl = (import.meta as any).env.VITE_API_URL || '/api';
const payload = {
userId: 'guide_' + encodeURIComponent(displayName.trim().toLowerCase().replace(/\s+/g, '_')),
displayName: displayName.trim(),
contributionType,
targetTable: 'heritage.landmarks',
targetId: landmark?.id ?? null,
placeName: landmark?.name_ar || 'معلم أثري ميداني',
lat: parseFloat(lat as any),
lng: parseFloat(lng as any),
suggestedData: {
gate_type: 'main_tourist_entry',
submitted_at: new Date().toISOString(),
},
notes: notes.trim(),
};
const res = await fetch(`${apiUrl}/v1/community/contribute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errJson = await res.json().catch(() => ({}));
throw new Error(errJson.message || `فشل إرسال المساهمة (${res.status})`);
}
setSubmitted(true);
if (onSuccess) onSuccess();
} catch (err: any) {
setError(err.message || 'حدث خطأ أثناء التواصل مع الخادم.');
} finally {
setLoading(false);
}
};
return (
<div
dir="rtl"
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(8px)',
zIndex: 2000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
fontFamily: "'Tajawal', 'Noto Sans Arabic', system-ui, sans-serif",
}}
>
<div
style={{
width: '100%',
maxWidth: '520px',
background: '#ffffff',
borderRadius: '24px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
overflow: 'hidden',
border: '1px solid rgba(226, 232, 240, 0.8)',
animation: 'scaleUpModal 0.25s cubic-bezier(0.16, 1, 0.3, 1)',
}}
>
{/* Header */}
<div
style={{
background: 'linear-gradient(135deg, #0b2547 0%, #173b6c 100%)',
color: '#ffffff',
padding: '24px',
position: 'relative',
}}
>
<button
onClick={onClose}
style={{
position: 'absolute',
top: '20px',
left: '20px',
background: 'rgba(255, 255, 255, 0.15)',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
cursor: 'pointer',
}}
>
<X size={18} />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#dfc28a', fontSize: '13px', fontWeight: 700, marginBottom: '6px' }}>
<Sparkles size={16} /> شبكة المساهمين والمرشدين المحليين
</div>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 800 }}>
{landmark ? `اقتراح وتوثيق: ${landmark.name_ar}` : 'إضافة وتوثيق بوابة سياحية جديدة'}
</h2>
<p style={{ margin: '6px 0 0', fontSize: '13px', color: '#94a3b8' }}>
تخضع المساهمات لطابور المراجعة المعتمد قبل نشرها للجمهور وتمنحك نقاط المرشد الموثوق.
</p>
</div>
{/* Content */}
<div style={{ padding: '24px' }}>
{submitted ? (
<div style={{ textAlign: 'center', padding: '30px 10px' }}>
<div
style={{
width: '64px',
height: '64px',
background: '#f0fdf4',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
color: '#16a34a',
}}
>
<CheckCircle2 size={36} />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: '20px', fontWeight: 800, color: '#0f172a' }}>
تم استلام مساهمتك الميدانية بنجاح!
</h3>
<p style={{ margin: '0 0 24px', fontSize: '14px', color: '#64748b', lineHeight: 1.7 }}>
أُضيفت المساهمة إلى **طابور مراجعة المشرفين (Pending Queue)**. بعد التدقيق ومطابقة صور الأقمار الصناعية، ستُعتمد وتُضاف إلى رصيدك **15 نقطة** وشارة «صائد البوابات».
</p>
<button
onClick={onClose}
style={{
background: '#0b2547',
color: '#ffffff',
border: 'none',
borderRadius: '12px',
padding: '12px 28px',
fontSize: '14px',
fontWeight: 700,
cursor: 'pointer',
}}
>
إغلاق والعودة للخريطة
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
{error && (
<div
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#991b1b',
padding: '12px',
borderRadius: '10px',
fontSize: '13px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<AlertCircle size={16} /> {error}
</div>
)}
{/* Contributor Name */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#334155', marginBottom: '6px' }}>
اسم المساهم / المرشد:
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
fontSize: '14px',
boxSizing: 'border-box',
}}
/>
</div>
{/* Contribution Type */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#334155', marginBottom: '6px' }}>
نوع المساهمة الميدانية:
</label>
<select
value={contributionType}
onChange={(e) => setContributionType(e.target.value)}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
fontSize: '14px',
background: '#ffffff',
boxSizing: 'border-box',
}}
>
<option value="CONFIRM_GATE">🎟️ تأكيد / إضافة بوابة دخول رئيسية (Gate)</option>
<option value="ADD_PARKING">🅿️ إضافة موقف سيارات معتمد (Parking)</option>
<option value="ORAL_HISTORY">📜 توثيق سردية أو رواية شفوية (Oral History)</option>
<option value="CORRECT_INFO">✍️ تصحيح إحداثيات أو اسم المعلم</option>
</select>
</div>
{/* Coordinates */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 700, color: '#475569', marginBottom: '4px' }}>
دائرة العرض (Latitude):
</label>
<input
type="number"
step="any"
value={lat}
onChange={(e) => setLat(parseFloat(e.target.value))}
required
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '13px',
boxSizing: 'border-box',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 700, color: '#475569', marginBottom: '4px' }}>
خط الطول (Longitude):
</label>
<input
type="number"
step="any"
value={lng}
onChange={(e) => setLng(parseFloat(e.target.value))}
required
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '13px',
boxSizing: 'border-box',
}}
/>
</div>
</div>
{/* Notes */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', fontSize: '13px', fontWeight: 700, color: '#334155', marginBottom: '6px' }}>
ملاحظات المعاينة الميدانية أو الرواية:
</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="مثال: البوابة الجنوبية المقابلة للشارع الرئيسي، وتتوفر كافتيريا وشباك تذاكر بجانبها..."
style={{
width: '100%',
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid #cbd5e1',
fontSize: '13px',
boxSizing: 'border-box',
fontFamily: 'inherit',
}}
/>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
style={{
width: '100%',
background: 'linear-gradient(135deg, #0b2547 0%, #153e75 100%)',
color: '#ffffff',
border: 'none',
borderRadius: '12px',
padding: '14px',
fontSize: '15px',
fontWeight: 800,
cursor: loading ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
boxShadow: '0 4px 14px rgba(11, 37, 71, 0.25)',
}}
>
{loading ? 'جارٍ الإرسال والتسجيل...' : <><Send size={16} /> إرسال المساهمة لطابور الاعتماد</>}
</button>
</form>
)}
</div>
</div>
</div>
);
};
+10
View File
@@ -228,3 +228,13 @@ input:focus {
border-radius: 10px;
font-size: 0.7rem;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin-anim {
animation: spin 3s linear infinite;
}
+49 -17
View File
@@ -6,24 +6,34 @@ import CompareView from './pages/CompareView'
import IntelligenceDashboard from './pages/IntelligenceDashboard'
import { ExecutiveShowcase } from './pages/ExecutiveShowcase'
import { TacticalDefenseView } from './pages/TacticalDefenseView'
import { TourismMapView } from './pages/TourismMapView'
import { Terrain3DView } from './pages/Terrain3DView'
import './index.css'
maplibregl.setRTLTextPlugin(
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js',
(err) => { if (err) console.error(err); },
true
);
try {
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
maplibregl.setRTLTextPlugin(
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js',
(err) => { if (err) console.error(err); },
true
);
}
} catch (e) {
console.warn('RTL plugin already initialized or warning:', e);
}
// Lightweight hash router with Sovereign Executive Styling
const NAV = [
{ hash: '#tactical', label: 'المنظومة التكتيكية العسكرية', icon: '🎖️' },
{ hash: '#map', label: 'الخريطة والطقس والملاحة', icon: '🗺️' },
{ hash: '#map', label: 'الخريطة والملاحة الشاملة', icon: '🗺️' },
{ hash: '#terrain3d', label: 'الخريطة ثلاثية الأبعاد والتضاريس', icon: '🏔️' },
{ hash: '#tourism', label: 'الخريطة السياحية والتراثية', icon: '🏛️' },
{ hash: '#tactical', label: 'المنظومة التكتيكية', icon: '🎖️' },
{ hash: '#compare', label: 'مقارنة العمالقة والأسعار', icon: '⚖️' },
{ hash: '#review', label: 'تدقيق الطرق الذكي', icon: '🔍' },
]
function MasterHeader({ hash }: { hash: string }) {
const currentHash = hash || '#tactical'
const currentHash = hash || '#map'
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = () => {
@@ -82,17 +92,17 @@ function MasterHeader({ hash }: { hash: string }) {
alignItems: 'center',
gap: 6
}}>
<span>منظومة انطلاق المكانية</span>
<span>خرائط سيرو المكانية والذكاء التكتيكي</span>
<span style={{
fontSize: '9.5px',
background: 'rgba(0, 113, 227, 0.25)',
border: '1px solid rgba(0, 113, 227, 0.5)',
color: '#38bdf8',
background: 'rgba(234, 88, 12, 0.25)',
border: '1px solid rgba(234, 88, 12, 0.5)',
color: '#fb923c',
padding: '1px 5px',
borderRadius: 4,
fontWeight: 700
}}>
Sovereign Cloud v3.2
Siro Tactical Intel v3.5
</span>
</div>
</div>
@@ -244,11 +254,19 @@ class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { has
}
function Root() {
const [hash, setHash] = useState(window.location.hash || '#tactical');
const getInitialHash = () => {
const p = window.location.pathname.toLowerCase();
if (p.includes('tourism') || p.includes('heritage') || p.includes('tourist') || window.location.href.includes('سياح')) {
return '#tourism';
}
return window.location.hash || '#map';
};
const [hash, setHash] = useState(getInitialHash);
useEffect(() => {
const on = () => {
const h = window.location.hash || '#tactical';
const h = window.location.hash || getInitialHash();
setHash(h);
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
@@ -258,11 +276,13 @@ function Root() {
return () => window.removeEventListener('hashchange', on);
}, []);
const isTactical = hash === '#tactical' || hash === '#military' || hash === '' || hash === '#siro';
const isTourism = hash === '#tourism' || hash === '#heritage' || hash === '#tourist';
const isTactical = hash === '#tactical' || hash === '#military' || hash === '#siro';
const isTerrain3D = hash === '#terrain3d' || hash === '#3d' || hash === '#terrain';
const isExecutive = hash === '#showcase' || hash === '#pitch';
const isCompare = hash === '#compare';
const isReview = hash === '#review';
const isMap = hash === '#map';
const isMap = hash === '#map' || (!isTourism && !isTactical && !isTerrain3D && !isExecutive && !isCompare && !isReview);
return (
<ErrorBoundary>
@@ -278,6 +298,18 @@ function Root() {
height: 'calc(100vh - 52px)',
overflow: 'hidden'
}}>
{isTerrain3D && (
<div style={{ width: '100%', height: '100%' }}>
<Terrain3DView />
</div>
)}
{isTourism && (
<div style={{ width: '100%', height: '100%' }}>
<TourismMapView />
</div>
)}
{isTactical && (
<div style={{ width: '100%', height: '100%' }}>
<TacticalDefenseView />
+113 -17
View File
@@ -51,8 +51,20 @@ const IntelligenceDashboard: React.FC = () => {
const [msg, setMsg] = useState('');
const [tab, setTab] = useState<'summary' | 'candidates' | 'closures'>('candidates');
const [loading, setLoading] = useState(true);
const [apiKey, setApiKey] = useState(localStorage.getItem('map_admin_key') || DEFAULT_SYSTEM_KEY);
const [needsAuth, setNeedsAuth] = useState(false);
const [apiKey, setApiKey] = useState<string>(() => {
return new URLSearchParams(window.location.search).get('key') ||
new URLSearchParams(window.location.search).get('api_key') ||
sessionStorage.getItem('tactical_api_key') ||
localStorage.getItem('map_admin_key') || '';
});
const [needsAuth, setNeedsAuth] = useState<boolean>(() => {
const existing = new URLSearchParams(window.location.search).get('key') ||
new URLSearchParams(window.location.search).get('api_key') ||
sessionStorage.getItem('tactical_api_key') ||
localStorage.getItem('map_admin_key');
return !existing || !existing.trim();
});
const [authInputKey, setAuthInputKey] = useState<string>('');
const [sel, setSel] = useState<string | null>(null);
// Right Map State
@@ -542,28 +554,112 @@ const IntelligenceDashboard: React.FC = () => {
if (needsAuth) {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#0f172a', color: 'white', fontFamily: 'system-ui' }}>
<div style={{ background: '#1e293b', padding: '2rem', borderRadius: '12px', border: '1px solid #334155', width: '100%', maxWidth: '400px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '1.5rem' }}>
<div style={{ background: '#3b82f6', borderRadius: '8px', display: 'flex', padding: '8px' }}><ShieldAlert size={24} color="white" /></div>
<h2 style={{ margin: 0, fontSize: '1.2rem' }}>Admin Access Required</h2>
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(5, 8, 15, 0.96)',
backdropFilter: 'blur(25px)',
color: 'white',
fontFamily: "'IBM Plex Sans Arabic', Inter, system-ui, sans-serif",
direction: 'rtl',
padding: '20px'
}}>
<div style={{
background: 'linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(11, 19, 38, 0.98))',
border: '1px solid rgba(56, 189, 248, 0.3)',
borderRadius: 18,
padding: '32px 28px',
width: '100%',
maxWidth: 480,
textAlign: 'center',
boxShadow: '0 0 50px rgba(14, 165, 233, 0.2), 0 25px 50px -12px rgba(0, 0, 0, 0.8)'
}}>
<div style={{
width: 60,
height: 60,
borderRadius: 18,
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.2), rgba(99, 102, 241, 0.3))',
border: '1px solid rgba(56, 189, 248, 0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
boxShadow: '0 0 25px rgba(56, 189, 248, 0.35)'
}}>
<ShieldAlert size={30} color="#38bdf8" />
</div>
<p style={{ color: '#94a3b8', fontSize: '0.9rem', marginBottom: '1rem' }}>Please enter your Enterprise API key to access the Intelligence Dashboard.</p>
<h2 style={{ margin: '0 0 8px 0', fontSize: '1.25rem', fontWeight: 800, color: '#f8fafc' }}>
منظومة تدقيق الطرق والذكاء المكاني
</h2>
<p style={{ color: '#94a3b8', fontSize: '0.84rem', lineHeight: 1.6, marginBottom: '20px' }}>
بوابة التدقيق السيادية — تتطلب أدوات التحقق واكتشاف الطرق الجديدة بالأقمار الصناعية ومطابقة التتبع مفتاح تصريح معتمد (<span style={{ color: '#38bdf8', fontFamily: 'monospace' }}>API Key</span>).
</p>
<input
type="password"
placeholder="API Key (e.g. intaleq_secret_...)"
placeholder="أدخل مفتاح التصريح (API Key)..."
value={authInputKey}
onChange={(e) => setAuthInputKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = e.currentTarget.value;
if (val) {
localStorage.setItem('map_admin_key', val);
setApiKey(val);
}
if (e.key === 'Enter' && authInputKey.trim()) {
const val = authInputKey.trim();
localStorage.setItem('map_admin_key', val);
sessionStorage.setItem('tactical_api_key', val);
setApiKey(val);
setNeedsAuth(false);
}
}}
style={{ width: '100%', padding: '12px', background: '#0f172a', border: '1px solid #3b82f6', borderRadius: '8px', color: 'white', outline: 'none', marginBottom: '1rem', boxSizing: 'border-box' }}
style={{
width: '100%',
padding: '13px 16px',
background: '#090e1a',
border: '1px solid rgba(56, 189, 248, 0.4)',
borderRadius: 10,
color: 'white',
fontSize: '0.9rem',
outline: 'none',
marginBottom: '14px',
boxSizing: 'border-box',
direction: 'ltr',
textAlign: 'center'
}}
/>
{msg && <div style={{ color: '#ef4444', fontSize: '0.85rem', marginBottom: '1rem' }}>{msg}</div>}
<button
onClick={() => {
if (authInputKey.trim()) {
const val = authInputKey.trim();
localStorage.setItem('map_admin_key', val);
sessionStorage.setItem('tactical_api_key', val);
setApiKey(val);
setNeedsAuth(false);
}
}}
style={{
width: '100%',
padding: '13px',
background: 'linear-gradient(135deg, #0284c7, #2563eb)',
color: '#ffffff',
border: 'none',
borderRadius: 10,
fontWeight: 800,
fontSize: '0.92rem',
cursor: 'pointer',
boxShadow: '0 4px 18px rgba(2, 132, 199, 0.4)'
}}
>
تأكيد التصريح والدخول للمنظومة
</button>
{msg && (
<div style={{ color: '#ef4444', fontSize: '0.82rem', marginTop: '14px' }}>
{msg}
</div>
)}
</div>
</div>
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff