diff --git a/src/App.jsx b/src/App.jsx index 24ee150..efdf99b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,6 +18,7 @@ import AltTextRatingTool from './components/AltTextRating/AltTextRatingTool'; import UserProfile from './components/UserProfile/UserProfile'; import ZenPage from './components/ZenPage'; import CompareScores from './components/CompareScores/CompareScores'; +import AdminRoute from './components/Admin/AdminRoute'; import "./App.css"; const App = () => { @@ -27,29 +28,34 @@ const App = () => {
- - {/* All routes are now public */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* Handle both DIDs and regular usernames */} - } /> - {/* Default routes */} - } /> - } /> - + + {/* All routes are now public */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Admin Route */} + } /> + + {/* Handle both DIDs and regular usernames */} + } /> + + {/* Default routes */} + } /> + } /> +
diff --git a/src/components/Admin/AdminPanel.css b/src/components/Admin/AdminPanel.css new file mode 100644 index 0000000..5e8fc6d --- /dev/null +++ b/src/components/Admin/AdminPanel.css @@ -0,0 +1,405 @@ +/* src/components/Admin/AdminPanel.css */ +.admin-panel { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + color: #333; + } + + /* Header */ + .admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .admin-header h1 { + margin: 0; + color: #333; + font-size: 24px; + } + + /* Container layout */ + .admin-container { + display: grid; + grid-template-columns: 300px 1fr; + gap: 20px; + height: calc(100vh - 120px); + } + + /* Resources sidebar */ + .resources-sidebar { + background-color: #f8f9fa; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + + .sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + background-color: #f0f2f5; + border-bottom: 1px solid #ddd; + } + + .sidebar-header h2 { + margin: 0; + font-size: 18px; + } + + .resources-list { + overflow-y: auto; + flex-grow: 1; + } + + .resource-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 15px; + border-bottom: 1px solid #eee; + cursor: pointer; + transition: background-color 0.2s; + } + + .resource-item:hover { + background-color: #f0f2f5; + } + + .resource-item.selected { + background-color: #e6f7ff; + border-left: 3px solid #1890ff; + } + + .resource-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-grow: 1; + } + + .resource-item-actions { + display: flex; + gap: 5px; + visibility: hidden; + } + + .resource-item:hover .resource-item-actions { + visibility: visible; + } + + /* Resource editor */ + .resource-editor { + background-color: #fff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + padding: 20px; + overflow-y: auto; + height: 100%; + } + + .resource-editor h2 { + margin-top: 0; + margin-bottom: 20px; + color: #333; + } + + /* Form styling */ + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; + margin-bottom: 15px; + } + + .form-group { + margin-bottom: 15px; + } + + .form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #555; + } + + .form-group input[type="text"], + .form-group input[type="url"], + .form-group input[type="number"], + .form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + } + + .form-group textarea { + resize: vertical; + min-height: 100px; + } + + .checkbox-group { + display: flex; + align-items: center; + gap: 8px; + } + + .checkbox-group input[type="checkbox"] { + margin: 0; + } + + .checkbox-group label { + margin-bottom: 0; + } + + /* Categories and Tags sections */ + .categories-section, + .tags-section { + background-color: #f8f9fa; + border-radius: 6px; + padding: 15px; + border: 1px solid #eee; + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .section-header label { + font-weight: 600; + margin-bottom: 0; + } + + .checkbox-list { + max-height: 200px; + overflow-y: auto; + } + + .checkbox-item { + display: flex; + align-items: center; + margin-bottom: 8px; + gap: 8px; + } + + .checkbox-item label { + margin-bottom: 0; + font-weight: normal; + } + + /* Form actions */ + .form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; + } + + /* Buttons */ + button { + cursor: pointer; + border: none; + border-radius: 4px; + font-size: 14px; + transition: background-color 0.2s, opacity 0.2s; + } + + .add-new-button { + background-color: #1890ff; + color: white; + padding: 6px 12px; + font-size: 13px; + } + + .add-new-button:hover { + background-color: #40a9ff; + } + + .delete-button { + background: none; + padding: 3px 6px; + font-size: 16px; + opacity: 0.7; + } + + .delete-button:hover { + opacity: 1; + background-color: #ffebee; + } + + .add-item-button { + background-color: #f5f5f5; + color: #333; + padding: 4px 8px; + font-size: 12px; + border: 1px solid #ddd; + } + + .add-item-button:hover { + background-color: #e0e0e0; + } + + .save-button { + background-color: #52c41a; + color: white; + padding: 10px 20px; + } + + .save-button:hover { + background-color: #73d13d; + } + + .cancel-button { + background-color: #f5f5f5; + color: #333; + padding: 10px 20px; + border: 1px solid #d9d9d9; + } + + .cancel-button:hover { + background-color: #e6e6e6; + } + + .logout-button { + background-color: #f5f5f5; + color: #333; + padding: 8px 16px; + border: 1px solid #d9d9d9; + } + + .logout-button:hover { + background-color: #e6e6e6; + } + + /* Alert messages */ + .alert { + padding: 12px 15px; + margin-bottom: 20px; + border-radius: 4px; + font-weight: 500; + } + + .alert.success { + background-color: #f6ffed; + border: 1px solid #b7eb8f; + color: #52c41a; + } + + .alert.error { + background-color: #fff2f0; + border: 1px solid #ffccc7; + color: #f5222d; + } + + /* Loading spinner */ + .admin-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + } + + .loading-spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #1890ff; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + margin-bottom: 15px; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + /* Login form */ + .admin-login-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background-color: #f0f2f5; + } + + .admin-login-card { + background-color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + padding: 30px; + width: 100%; + max-width: 400px; + } + + .admin-login-card h2 { + margin-top: 0; + margin-bottom: 20px; + text-align: center; + color: #333; + } + + .login-button { + background-color: #1890ff; + color: white; + padding: 10px 0; + width: 100%; + font-size: 16px; + margin-top: 10px; + } + + .login-button:hover { + background-color: #40a9ff; + } + + .auth-error { + background-color: #fff2f0; + border: 1px solid #ffccc7; + color: #f5222d; + padding: 10px; + border-radius: 4px; + margin-bottom: 15px; + font-size: 14px; + } + + /* Accessibility */ + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + + /* Responsive design */ + @media (max-width: 768px) { + .admin-container { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .resources-sidebar { + height: 300px; + } + } \ No newline at end of file diff --git a/src/components/Admin/AdminPanel.js b/src/components/Admin/AdminPanel.js new file mode 100644 index 0000000..c13afa3 --- /dev/null +++ b/src/components/Admin/AdminPanel.js @@ -0,0 +1,685 @@ +// src/components/Admin/AdminPanel.jsx +import React, { useState, useEffect } from 'react'; +import { supabase } from '../../lib/supabase'; +import './AdminPanel.css'; + +const AdminPanel = () => { + // State management + const [resources, setResources] = useState([]); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); + const [selectedResource, setSelectedResource] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authError, setAuthError] = useState(null); + + // Login state + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + // New/Edit resource form state + const [formData, setFormData] = useState({ + name: '', + description: '', + url: '', + domain: '', + featured: false, + position: 0, + selectedCategories: [], + selectedTags: [] + }); + + // Alert state + const [alert, setAlert] = useState({ show: false, message: '', type: '' }); + + // Check authentication on mount + useEffect(() => { + const checkAuth = async () => { + const { data: { session } } = await supabase.auth.getSession(); + setIsAuthenticated(!!session); + + if (session) { + fetchAllData(); + } else { + setIsLoading(false); + } + }; + + checkAuth(); + }, []); + + // Login handler + const handleLogin = async (e) => { + e.preventDefault(); + setIsLoading(true); + setAuthError(null); + + try { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }); + + if (error) throw error; + + setIsAuthenticated(true); + fetchAllData(); + } catch (error) { + console.error('Error logging in:', error); + setAuthError(error.message); + setIsLoading(false); + } + }; + + // Logout handler + const handleLogout = async () => { + await supabase.auth.signOut(); + setIsAuthenticated(false); + }; + + // Fetch all required data from Supabase + const fetchAllData = async () => { + setIsLoading(true); + try { + // Fetch resources + const { data: resourcesData, error: resourcesError } = await supabase + .from('resources') + .select('*') + .order('position'); + + if (resourcesError) throw resourcesError; + + // Fetch categories + const { data: categoriesData, error: categoriesError } = await supabase + .from('categories') + .select('*') + .order('name'); + + if (categoriesError) throw categoriesError; + + // Fetch tags + const { data: tagsData, error: tagsError } = await supabase + .from('tags') + .select('*') + .order('name'); + + if (tagsError) throw tagsError; + + // Fetch resource-category associations + const { data: resourceCategories, error: rcError } = await supabase + .from('resource_categories') + .select('*'); + + if (rcError) throw rcError; + + // Fetch resource-tag associations + const { data: resourceTags, error: rtError } = await supabase + .from('resource_tags') + .select('*'); + + if (rtError) throw rtError; + + // Enhance resources with their associated categories and tags + const enhancedResources = resourcesData.map(resource => { + const resourceCats = resourceCategories + .filter(rc => rc.resource_id === resource.id) + .map(rc => rc.category_id); + + const resourceTs = resourceTags + .filter(rt => rt.resource_id === resource.id) + .map(rt => rt.tag_id); + + return { + ...resource, + categoryIds: resourceCats, + tagIds: resourceTs + }; + }); + + // Update state + setResources(enhancedResources); + setCategories(categoriesData); + setTags(tagsData); + } catch (error) { + console.error('Error fetching data:', error); + showAlert(`Error: ${error.message}`, 'error'); + } finally { + setIsLoading(false); + } + }; + + // Handle resource selection + const handleSelectResource = (resource) => { + setSelectedResource(resource); + setFormData({ + name: resource.name || '', + description: resource.description || '', + url: resource.url || '', + domain: resource.domain || '', + featured: resource.featured || false, + position: resource.position || 0, + selectedCategories: resource.categoryIds || [], + selectedTags: resource.tagIds || [] + }); + }; + + // Handle form input changes + const handleInputChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData({ + ...formData, + [name]: type === 'checkbox' ? checked : value + }); + }; + + // Handle category selection changes + const handleCategoryChange = (categoryId) => { + setFormData(prevData => { + const selectedCategories = [...prevData.selectedCategories]; + + if (selectedCategories.includes(categoryId)) { + // Remove category if already selected + return { + ...prevData, + selectedCategories: selectedCategories.filter(id => id !== categoryId) + }; + } else { + // Add category if not already selected + return { + ...prevData, + selectedCategories: [...selectedCategories, categoryId] + }; + } + }); + }; + + // Handle tag selection changes + const handleTagChange = (tagId) => { + setFormData(prevData => { + const selectedTags = [...prevData.selectedTags]; + + if (selectedTags.includes(tagId)) { + // Remove tag if already selected + return { + ...prevData, + selectedTags: selectedTags.filter(id => id !== tagId) + }; + } else { + // Add tag if not already selected + return { + ...prevData, + selectedTags: [...selectedTags, tagId] + }; + } + }); + }; + + // Clear form and selected resource + const handleClearForm = () => { + setSelectedResource(null); + setFormData({ + name: '', + description: '', + url: '', + domain: '', + featured: false, + position: resources.length + 1, + selectedCategories: [], + selectedTags: [] + }); + }; + + // Show alert message + const showAlert = (message, type = 'success') => { + setAlert({ show: true, message, type }); + setTimeout(() => { + setAlert({ show: false, message: '', type: '' }); + }, 5000); + }; + + // Save resource changes + const handleSaveResource = async (e) => { + e.preventDefault(); + setIsLoading(true); + + try { + const resourceData = { + name: formData.name, + description: formData.description, + url: formData.url, + domain: formData.domain, + featured: formData.featured, + position: formData.position, + updated_at: new Date().toISOString() + }; + + let resourceId; + + if (selectedResource) { + // Update existing resource + const { error } = await supabase + .from('resources') + .update(resourceData) + .eq('id', selectedResource.id); + + if (error) throw error; + resourceId = selectedResource.id; + + // Delete existing category and tag associations + await supabase + .from('resource_categories') + .delete() + .eq('resource_id', resourceId); + + await supabase + .from('resource_tags') + .delete() + .eq('resource_id', resourceId); + + showAlert(`Resource "${formData.name}" updated successfully!`); + } else { + // Add new resource + resourceData.created_at = new Date().toISOString(); + + const { data, error } = await supabase + .from('resources') + .insert(resourceData) + .select(); + + if (error) throw error; + resourceId = data[0].id; + + showAlert(`Resource "${formData.name}" created successfully!`); + } + + // Add category associations + if (formData.selectedCategories.length > 0) { + const categoryAssociations = formData.selectedCategories.map(categoryId => ({ + resource_id: resourceId, + category_id: categoryId + })); + + const { error: categoryError } = await supabase + .from('resource_categories') + .insert(categoryAssociations); + + if (categoryError) throw categoryError; + } + + // Add tag associations + if (formData.selectedTags.length > 0) { + const tagAssociations = formData.selectedTags.map(tagId => ({ + resource_id: resourceId, + tag_id: tagId + })); + + const { error: tagError } = await supabase + .from('resource_tags') + .insert(tagAssociations); + + if (tagError) throw tagError; + } + + // Refresh data + fetchAllData(); + handleClearForm(); + } catch (error) { + console.error('Error saving resource:', error); + showAlert(`Error: ${error.message}`, 'error'); + } finally { + setIsLoading(false); + } + }; + + // Delete resource + const handleDeleteResource = async (resourceId, resourceName) => { + if (!window.confirm(`Are you sure you want to delete "${resourceName}"?`)) { + return; + } + + setIsLoading(true); + + try { + // Delete associated records first (foreign key constraints) + await supabase + .from('resource_categories') + .delete() + .eq('resource_id', resourceId); + + await supabase + .from('resource_tags') + .delete() + .eq('resource_id', resourceId); + + // Delete the resource + const { error } = await supabase + .from('resources') + .delete() + .eq('id', resourceId); + + if (error) throw error; + + showAlert(`Resource "${resourceName}" deleted successfully!`); + + // Refresh data + fetchAllData(); + + // Clear form if the deleted resource was selected + if (selectedResource && selectedResource.id === resourceId) { + handleClearForm(); + } + } catch (error) { + console.error('Error deleting resource:', error); + showAlert(`Error: ${error.message}`, 'error'); + } finally { + setIsLoading(false); + } + }; + + // Create new category + const handleCreateCategory = async () => { + const categoryName = prompt('Enter the new category name:'); + if (!categoryName) return; + + const emoji = prompt('Enter an emoji for this category:'); + if (!emoji) return; + + setIsLoading(true); + + try { + const { data, error } = await supabase + .from('categories') + .insert({ + name: categoryName, + emoji: emoji, + created_at: new Date().toISOString() + }) + .select(); + + if (error) throw error; + + showAlert(`Category "${categoryName}" created successfully!`); + fetchAllData(); + } catch (error) { + console.error('Error creating category:', error); + showAlert(`Error: ${error.message}`, 'error'); + } finally { + setIsLoading(false); + } + }; + + // Create new tag + const handleCreateTag = async () => { + const tagName = prompt('Enter the new tag name:'); + if (!tagName) return; + + setIsLoading(true); + + try { + const { data, error } = await supabase + .from('tags') + .insert({ + name: tagName, + created_at: new Date().toISOString() + }) + .select(); + + if (error) throw error; + + showAlert(`Tag "${tagName}" created successfully!`); + fetchAllData(); + } catch (error) { + console.error('Error creating tag:', error); + showAlert(`Error: ${error.message}`, 'error'); + } finally { + setIsLoading(false); + } + }; + + // Render loading spinner + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + // Render login form if not authenticated + if (!isAuthenticated) { + return ( +
+
+

Admin Login

+ {authError &&
{authError}
} +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+
+
+ ); + } + + // Main admin panel UI + return ( +
+ {/* Header */} +
+

Resources Admin Panel

+ +
+ + {/* Alert message */} + {alert.show && ( +
+ {alert.message} +
+ )} + +
+ {/* Resources list sidebar */} +
+
+

Resources

+ +
+
+ {resources.map(resource => ( +
handleSelectResource(resource)} + > +
{resource.name}
+
+ +
+
+ ))} +
+
+ + {/* Resource edit form */} +
+

{selectedResource ? 'Edit Resource' : 'Add New Resource'}

+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ {/* Categories selection */} +
+
+ + +
+
+ {categories.map(category => ( +
+ handleCategoryChange(category.id)} + /> + +
+ ))} +
+
+ + {/* Tags selection */} +
+
+ + +
+
+ {tags.map(tag => ( +
+ handleTagChange(tag.id)} + /> + +
+ ))} +
+
+
+ +
+ + +
+
+
+
+
+ ); +}; + +export default AdminPanel; \ No newline at end of file diff --git a/src/components/Admin/AdminRoute.js b/src/components/Admin/AdminRoute.js new file mode 100644 index 0000000..84da411 --- /dev/null +++ b/src/components/Admin/AdminRoute.js @@ -0,0 +1,57 @@ +// src/components/Admin/AdminRoute.jsx +import React, { useState, useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import { supabase } from '../../lib/supabase'; +import AdminPanel from './AdminPanel'; + +// This component protects the admin route by checking authentication +const AdminRoute = () => { + const [isAuthenticated, setIsAuthenticated] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const checkAuth = async () => { + try { + const { data: { session } } = await supabase.auth.getSession(); + setIsAuthenticated(!!session); + } catch (error) { + console.error('Error checking auth status:', error); + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }; + + checkAuth(); + + // Listen for auth changes + const { data: { subscription } } = supabase.auth.onAuthStateChange( + (_event, session) => { + setIsAuthenticated(!!session); + } + ); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + if (isLoading) { + return ( +
+
+

Loading...

+
+ ); + } + + // If not authenticated, redirect to home page + if (isAuthenticated === false) { + return ; + } + + // If authenticated, render the admin panel + return ; +}; + +export default AdminRoute; \ No newline at end of file