Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Termsandconditions from './pages/termsandconditions';
import ContactPage from './pages/ContactPage';
import NotFound from './pages/NotFound';
import { LoadingScreen } from './components/LoadingScreen';
import ReportInfo from './pages/ReportInfo';

function App() {
const [isLoading, setIsLoading] = useState(true);
Expand Down Expand Up @@ -65,6 +66,7 @@ function App() {
<Route path="/PrivacyPolicy" element={<PrivacyPolicy />} />
<Route path="/termsandconditions" element={<Termsandconditions />} />
<Route path="/contact" element={<ContactPage />} />
<Route path="/report/:id" element={<ReportInfo />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
Expand Down
123 changes: 121 additions & 2 deletions src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ interface ApprovedNGO {
};
}

interface Report {
id: string;
story_id: string;
story_title: string;
reason: string;
details: string;
reported_at: any;
user_id: string;
status: 'pending';
}

interface Story {
id: string;
title: string;
Expand All @@ -62,6 +73,7 @@ export default function AdminDashboard() {
const [pendingNGOs, setPendingNGOs] = useState<NGORequest[]>([]);
const [approvedNGOs, setApprovedNGOs] = useState<ApprovedNGO[]>([]);
const [stories, setStories] = useState<Story[]>([]);
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
const [isAuthorized, setIsAuthorized] = useState(false);

Expand Down Expand Up @@ -93,14 +105,19 @@ export default function AdminDashboard() {
// Fetch stories and their report counts
const fetchStoriesPromise = getDocs(query(collection(db, 'stories'), orderBy('created_at', 'desc')));

// Fetch pending reports
const fetchReportsPromise = getDocs(query(collection(db, 'reports'), where('status', '==', 'pending')));

const [
requestsSnapshot,
approvedSnapshot,
storiesSnapshot
storiesSnapshot,
reportsSnapshot
] = await Promise.all([
fetchPendingPromise,
fetchApprovedPromise,
fetchStoriesPromise
fetchStoriesPromise,
fetchReportsPromise
]);

const requestsList = requestsSnapshot.docs.map(
Expand All @@ -121,11 +138,34 @@ export default function AdminDashboard() {
);
setApprovedNGOs(approvedList);

// Process reports and calculate reportCount map
const reportsCountMap: Record<string, number> = {};
const reportsList = reportsSnapshot.docs.map(doc => {
const data = doc.data();
const sId = data.story_id;
if (sId) {
reportsCountMap[sId] = (reportsCountMap[sId] || 0) + 1;
}
return {
id: doc.id,
...data
} as Report;
});

// Sort reports in memory by reported_at desc
reportsList.sort((a, b) => {
const timeA = a.reported_at?.seconds || 0;
const timeB = b.reported_at?.seconds || 0;
return timeB - timeA;
});
setReports(reportsList);

// Process stories and fetch report counts for each
const storiesList = storiesSnapshot.docs.map(doc => ({
id: doc.id,
...doc.data(),
risk_level: doc.data().risk_level || 'LOW',
reportCount: reportsCountMap[doc.id] || 0,
} as Story));

const riskOrder: Record<string, number> = { HIGH: 0, MEDIUM: 1, LOW: 2 };
Expand Down Expand Up @@ -232,12 +272,41 @@ export default function AdminDashboard() {

toast.success(`Story "${storyTitle}" has been deleted.`, { id: toastId });
setStories(prev => prev.filter(item => item.id !== storyId));
setReports(prev => prev.filter(item => item.story_id !== storyId));
} catch (error) {
console.error('Error deleting story: ', error);
toast.error('Failed to delete story.', { id: toastId });
}
};

const handleDismissReport = async (reportId: string) => {
if (!window.confirm("Are you sure you want to dismiss this report?")) return;

const toastId = toast.loading("Dismissing report...");
try {
await deleteDoc(doc(db, 'reports', reportId));
toast.success("Report dismissed.", { id: toastId });

const dismissedReport = reports.find(r => r.id === reportId);
if (dismissedReport) {
setStories(prevStories => prevStories.map(story => {
if (story.id === dismissedReport.story_id) {
return {
...story,
reportCount: Math.max(0, (story.reportCount || 1) - 1)
};
}
return story;
}));
}

setReports(prev => prev.filter(item => item.id !== reportId));
} catch (error) {
console.error("Error dismissing report: ", error);
toast.error("Failed to dismiss report.", { id: toastId });
}
};

if (loading) {
return <div className="text-center p-10 text-gray-900 dark:text-white">Loading Admin Panel...</div>;
}
Expand Down Expand Up @@ -318,6 +387,56 @@ export default function AdminDashboard() {
)}
</section>

{/* Reported Stories Section */}
<section className="mb-16">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Reported Stories</h2>
{reports.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">No pending reports.</p>
) : (
<div className="space-y-6">
{reports.map(report => (
<div key={report.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-start flex-wrap gap-4 mb-4">
<div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 border border-red-200 dark:border-red-800/60 mb-2">
{report.reason}
</span>
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Story: <span className="italic">"{report.story_title}"</span>
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Reported on {report.reported_at ? new Date(report.reported_at.seconds * 1000).toLocaleString() : 'N/A'}
</p>
</div>
</div>

<div className="bg-gray-55 dark:bg-gray-900/35 p-4 rounded-lg border border-gray-100 dark:border-gray-700 mb-4">
<span className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider block mb-1">Reason Description / Additional Details</span>
<p className="text-gray-600 dark:text-gray-300 text-sm italic">
{report.details ? `"${report.details}"` : 'No additional details provided.'}
</p>
</div>

<div className="flex space-x-4">
<button
onClick={() => handleDeleteStory(report.story_id, report.story_title)}
className="inline-flex items-center bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 transition-colors text-sm font-medium"
>
<Trash2 className="h-4 w-4 mr-2" /> Delete Story
</button>
<button
onClick={() => handleDismissReport(report.id)}
className="inline-flex items-center bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm font-medium"
>
<CheckCircle className="h-4 w-4 mr-2" /> Dismiss Report
</button>
</div>
</div>
))}
</div>
)}
</section>

{/* Story Moderation Section */}
<section>
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-6">Story Moderation</h2>
Expand Down
191 changes: 191 additions & 0 deletions src/pages/ReportInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { auth } from '../lib/firebase';
import { getFirestore, doc, getDoc, collection, addDoc, serverTimestamp } from 'firebase/firestore';
import { toast } from 'react-hot-toast';
import { Loader2, ArrowLeft, ShieldAlert } from 'lucide-react';

const db = getFirestore();

const REPORT_CATEGORIES = [
'Harassment',
'Hate Speech',
'Inappropriate Media',
'Spam',
'Doxxing'
];

export default function ReportInfo() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [storyTitle, setStoryTitle] = useState('');
const [storyContent, setStoryContent] = useState('');
const [loadingStory, setLoadingStory] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [reason, setReason] = useState('');
const [details, setDetails] = useState('');
const [user, setUser] = useState(auth.currentUser);

useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((currentUser) => {
setUser(currentUser);
if (!currentUser) {
toast.error('Please sign in to report a story');
navigate('/auth');
}
});
return () => unsubscribe();
}, [navigate]);

useEffect(() => {
const fetchStory = async () => {
if (!id) return;
try {
const storyDoc = await getDoc(doc(db, 'stories', id));
if (storyDoc.exists()) {
setStoryTitle(storyDoc.data().title || '');
setStoryContent(storyDoc.data().content || '');
} else {
toast.error('Story not found');
navigate('/stories');
}
} catch (error) {
console.error('Error fetching story:', error);
toast.error('Failed to load story details');
navigate('/stories');
} finally {
setLoadingStory(false);
}
};

fetchStory();
}, [id, navigate]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!reason) {
toast.error('Please select a reason for reporting');
return;
}

if (!user) {
toast.error('Please sign in to report a story');
navigate('/auth');
return;
}

setSubmitting(true);
try {
await addDoc(collection(db, 'reports'), {
story_id: id,
story_title: storyTitle,
reason,
details,
reported_at: serverTimestamp(),
user_id: user.uid,
status: 'pending'
});

toast.success('Story reported. Thank you for keeping SafeVoice safe.');
navigate('/stories');
} catch (error) {
console.error('Error submitting report:', error);
toast.error('Failed to submit report. Please try again.');
} finally {
setSubmitting(false);
}
};

if (loadingStory) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white">
<Loader2 className="animate-spin h-10 w-10 text-pink-500 mb-4" />
<p className="text-gray-500 dark:text-gray-400">Loading story details...</p>
</div>
);
}

return (
<div className="max-w-2xl mx-auto px-4 py-8 pt-24 bg-gray-50 dark:bg-gray-900 min-h-screen">
<button
onClick={() => navigate(-1)}
className="inline-flex items-center text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 mb-6 transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-2" /> Back
</button>

<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8 border border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3 mb-6">
<div className="bg-red-100 dark:bg-red-950 p-3 rounded-full text-red-600 dark:text-red-400">
<ShieldAlert className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Report Story</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">Help us keep our community safe and supportive.</p>
</div>
</div>

{/* Story Preview */}
<div className="bg-gray-55 dark:bg-gray-900/50 p-4 rounded-xl mb-8 border border-gray-150 dark:border-gray-750">
<span className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider block mb-1">Story Title</span>
<h2 className="text-lg font-semibold text-gray-800 dark:text-white mb-2">{storyTitle}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 leading-relaxed">{storyContent}</p>
</div>

<form onSubmit={handleSubmit} className="space-y-6">
{/* Reason Selection */}
<div>
<label htmlFor="reason" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Reason for reporting <span className="text-red-500">*</span>
</label>
<select
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 px-4 py-2.5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-shadow"
required
>
<option value="" disabled>Select a category...</option>
{REPORT_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>

{/* Additional Details */}
<div>
<label htmlFor="details" className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Additional Details (Optional)
</label>
<textarea
id="details"
rows={5}
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="Please provide any additional context or specific details about why you are reporting this story..."
className="w-full rounded-xl border border-gray-300 dark:border-gray-600 px-4 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-shadow resize-none"
/>
</div>

{/* Submit Button */}
<div className="pt-2">
<button
type="submit"
disabled={submitting}
className="w-full inline-flex items-center justify-center bg-pink-500 hover:bg-pink-600 text-white font-semibold py-3 px-6 rounded-xl transition-colors shadow-lg shadow-pink-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="animate-spin -ml-1 mr-2 h-5 w-5" />
Submitting Report...
</>
) : (
'Submit Report'
)}
</button>
</div>
</form>
</div>
</div>
);
}
Loading
Loading