feat: add Jordan 3D topographic platform, Siro Map cinematic orbit studio, sun-shadow simulation, and sovereign intelligence security gating
This commit is contained in:
+179
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user