From 31eda793ab0a5911a1fe2a16bb56a56903ca805c Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 26 Jan 2026 22:52:16 -0800 Subject: [PATCH] polished implementation of new artchitecture --- backend/controllers/households.controller.js | 10 +- backend/controllers/users.controller.js | 1 - backend/models/user.model.js | 5 +- backend/routes/admin.routes.js | 7 +- frontend/src/App.jsx | 2 +- .../src/components/admin/StoreManagement.jsx | 285 ++++++++++++++++++ .../src/components/common/UserRoleCard.jsx | 2 + frontend/src/components/layout/Navbar.jsx | 2 +- .../components/manage/CreateJoinHousehold.jsx | 7 +- .../src/components/manage/ManageHousehold.jsx | 36 ++- frontend/src/constants/roles.js | 2 + frontend/src/pages/AdminPanel.jsx | 47 ++- frontend/src/pages/GroceryList.jsx | 23 +- .../components/admin/StoreManagement.css | 278 +++++++++++++++++ .../components/manage/ManageHousehold.css | 11 +- frontend/src/styles/pages/AdminPanel.css | 110 ++++++- 16 files changed, 759 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/admin/StoreManagement.jsx create mode 100644 frontend/src/styles/components/admin/StoreManagement.css diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js index f67a16b..828fd8a 100644 --- a/backend/controllers/households.controller.js +++ b/backend/controllers/households.controller.js @@ -115,19 +115,15 @@ exports.refreshInviteCode = async (req, res) => { exports.joinHousehold = async (req, res) => { try { const { inviteCode } = req.params; - - if (!inviteCode) { - return res.status(400).json({ error: "Invite code is required" }); - } + if (!inviteCode) return res.status(400).json({ error: "Invite code is required" }); const result = await householdModel.joinHousehold( inviteCode.toUpperCase(), req.user.id ); - if (!result) { - return res.status(404).json({ error: "Invalid or expired invite code" }); - } + if (!result) return res.status(404).json({ error: "Invalid or expired invite code" }); + if (result.alreadyMember) { return res.status(200).json({ diff --git a/backend/controllers/users.controller.js b/backend/controllers/users.controller.js index 033a6c8..2633bd0 100644 --- a/backend/controllers/users.controller.js +++ b/backend/controllers/users.controller.js @@ -7,7 +7,6 @@ exports.test = async (req, res) => { }; exports.getAllUsers = async (req, res) => { - console.log(req); const users = await User.getAllUsers(); res.json(users); }; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 706b226..633aebe 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -1,9 +1,8 @@ const pool = require("../db/pool"); exports.ROLES = { - VIEWER: "viewer", - EDITOR: "editor", - ADMIN: "admin", + SYSTEM_ADMIN: "system_admin", + USER: "user", } exports.findByUsername = async (username) => { diff --git a/backend/routes/admin.routes.js b/backend/routes/admin.routes.js index 3dc9bd8..d1f1057 100644 --- a/backend/routes/admin.routes.js +++ b/backend/routes/admin.routes.js @@ -4,8 +4,9 @@ const requireRole = require("../middleware/rbac"); const usersController = require("../controllers/users.controller"); const { ROLES } = require("../models/user.model"); -router.get("/users", auth, requireRole(ROLES.ADMIN), usersController.getAllUsers); -router.put("/users", auth, requireRole(ROLES.ADMIN), usersController.updateUserRole); -router.delete("/users", auth, requireRole(ROLES.ADMIN), usersController.deleteUser); +// router.get("/users", auth, (req, res, next) => next(), usersController.getAllUsers); +router.get("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.getAllUsers); +router.put("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.updateUserRole); +router.delete("/users", auth, requireRole(ROLES.SYSTEM_ADMIN), usersController.deleteUser); module.exports = router; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fd75138..b11d53e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -48,7 +48,7 @@ function App() { + } diff --git a/frontend/src/components/admin/StoreManagement.jsx b/frontend/src/components/admin/StoreManagement.jsx new file mode 100644 index 0000000..86c808f --- /dev/null +++ b/frontend/src/components/admin/StoreManagement.jsx @@ -0,0 +1,285 @@ +import { useEffect, useState } from "react"; +import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores"; +import "../../styles/components/admin/StoreManagement.css"; + +export default function StoreManagement() { + const [stores, setStores] = useState([]); + const [loading, setLoading] = useState(true); + const [editingStore, setEditingStore] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [formData, setFormData] = useState({ + name: "", + zones: [] + }); + const [newZone, setNewZone] = useState(""); + + useEffect(() => { + loadStores(); + }, []); + + const loadStores = async () => { + setLoading(true); + try { + const response = await getAllStores(); + setStores(response.data); + } catch (error) { + console.error("Failed to load stores:", error); + alert("Failed to load stores"); + } finally { + setLoading(false); + } + }; + + const handleCreate = async (e) => { + e.preventDefault(); + if (!formData.name.trim()) return; + + try { + const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; + await createStore(formData.name, zonesJson); + await loadStores(); + setShowCreateForm(false); + setFormData({ name: "", zones: [] }); + setNewZone(""); + } catch (error) { + console.error("Failed to create store:", error); + alert(error.response?.data?.error || "Failed to create store"); + } + }; + + const handleUpdate = async (e) => { + e.preventDefault(); + if (!editingStore || !formData.name.trim()) return; + + try { + const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; + await updateStore(editingStore.id, formData.name, zonesJson); + await loadStores(); + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + } catch (error) { + console.error("Failed to update store:", error); + alert(error.response?.data?.error || "Failed to update store"); + } + }; + + const handleDelete = async (storeId, storeName) => { + if (!confirm(`Delete store "${storeName}"? This cannot be undone.`)) return; + + try { + await deleteStore(storeId); + await loadStores(); + } catch (error) { + console.error("Failed to delete store:", error); + alert(error.response?.data?.error || "Failed to delete store"); + } + }; + + const startEdit = (store) => { + console.log('Starting edit for store:', store); + setEditingStore(store); + let zones = []; + if (store.default_zones) { + try { + let parsed = typeof store.default_zones === 'string' + ? JSON.parse(store.default_zones) + : store.default_zones; + + // Handle both formats: direct array or object with zones property + if (Array.isArray(parsed)) { + zones = parsed; + } else if (parsed && Array.isArray(parsed.zones)) { + zones = parsed.zones; + } + } catch (e) { + console.error('Failed to parse zones:', e); + zones = []; + } + } + console.log('Parsed zones:', zones); + setFormData({ + name: store.name, + zones: zones + }); + setShowCreateForm(false); + }; + + const cancelEdit = () => { + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + }; + + const startCreate = () => { + setShowCreateForm(true); + setEditingStore(null); + setFormData({ name: "", zones: [] }); + setNewZone(""); + }; + + const addZone = () => { + const zone = newZone.trim(); + if (!zone) return; + if (formData.zones.includes(zone)) { + alert("Zone already exists"); + return; + } + setFormData({ ...formData, zones: [...formData.zones, zone] }); + setNewZone(""); + }; + + const removeZone = (index) => { + setFormData({ + ...formData, + zones: formData.zones.filter((_, i) => i !== index) + }); + }; + + const parseZones = (defaultZones) => { + if (!defaultZones) return []; + try { + let parsed = typeof defaultZones === 'string' ? JSON.parse(defaultZones) : defaultZones; + + // Handle both formats: direct array or object with zones property + if (Array.isArray(parsed)) { + return parsed; + } else if (parsed && Array.isArray(parsed.zones)) { + return parsed.zones; + } + return []; + } catch (e) { + return []; + } + }; + + return ( +
+
+

Store Management

+ {!showCreateForm && !editingStore && ( + + )} +
+ + {/* Create/Edit Form */} + {(showCreateForm || editingStore) && ( +
+

{editingStore ? "Edit Store" : "Create New Store"}

+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Costco Richmond" + required + autoFocus + /> +
+ +
+ +
+ setNewZone(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addZone())} + placeholder="Enter zone name and press Enter or click Add" + /> + +
+ {formData.zones.length > 0 && ( +
+ {formData.zones.map((zone, index) => ( +
+ {zone} + +
+ ))} +
+ )} +

+ Add zones that will be used for organizing items in this store +

+
+ +
+ + +
+
+
+ )} + + {/* Stores List */} +
+ {loading ? ( +

Loading stores...

+ ) : stores.length === 0 ? ( +

No stores found. Create one to get started.

+ ) : ( + stores.map((store) => ( +
+
+

{store.name}

+ {store.default_zones && parseZones(store.default_zones).length > 0 && ( +
+

Default Zones:

+
+ {parseZones(store.default_zones).map((zone, idx) => ( + {zone} + ))} +
+
+ )} +

ID: {store.id}

+
+
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/common/UserRoleCard.jsx b/frontend/src/components/common/UserRoleCard.jsx index 4e91bed..93c2fa0 100644 --- a/frontend/src/components/common/UserRoleCard.jsx +++ b/frontend/src/components/common/UserRoleCard.jsx @@ -1,6 +1,8 @@ import { ROLES } from "../../constants/roles"; export default function UserRoleCard({ user, onRoleChange }) { + console.log(user) + return (
diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx index 253e925..166c812 100644 --- a/frontend/src/components/layout/Navbar.jsx +++ b/frontend/src/components/layout/Navbar.jsx @@ -15,7 +15,7 @@ export default function Navbar() { Manage Settings - {role === "admin" && Admin} + {role === "system_admin" && Admin}
diff --git a/frontend/src/components/manage/CreateJoinHousehold.jsx b/frontend/src/components/manage/CreateJoinHousehold.jsx index fdfc42f..a1bf909 100644 --- a/frontend/src/components/manage/CreateJoinHousehold.jsx +++ b/frontend/src/components/manage/CreateJoinHousehold.jsx @@ -4,7 +4,7 @@ import { HouseholdContext } from "../../context/HouseholdContext"; import "../../styles/components/manage/CreateJoinHousehold.css"; export default function CreateJoinHousehold({ onClose }) { - const { loadHouseholds } = useContext(HouseholdContext); + const { refreshHouseholds } = useContext(HouseholdContext); const [mode, setMode] = useState("create"); // "create" or "join" const [householdName, setHouseholdName] = useState(""); const [inviteCode, setInviteCode] = useState(""); @@ -20,7 +20,7 @@ export default function CreateJoinHousehold({ onClose }) { try { await createHousehold(householdName); - await loadHouseholds(); + await refreshHouseholds(); onClose(); } catch (err) { console.error("Failed to create household:", err); @@ -38,8 +38,9 @@ export default function CreateJoinHousehold({ onClose }) { setError(""); try { + console.log("Joining household with invite code:", inviteCode); await joinHousehold(inviteCode); - await loadHouseholds(); + await refreshHouseholds(); onClose(); } catch (err) { console.error("Failed to join household:", err); diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index 3a63f7b..a593658 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { deleteHousehold, getHouseholdMembers, @@ -116,7 +116,7 @@ export default function ManageHousehold() { return (
{/* Household Name Section */} -
+

Household Name

{editingName ? (
@@ -150,7 +150,7 @@ export default function ManageHousehold() { {/* Invite Code Section */} {isAdmin && ( -
+

Invite Code

Share this code with others to invite them to your household. @@ -160,10 +160,10 @@ export default function ManageHousehold() { {showInviteCode ? "Hide Code" : "Show Code"} {showInviteCode && ( - <> + {activeHousehold.invite_code} - + )} + +

+ +
+ {activeTab === "users" && ( +
+ {users.map((user) => ( + + ))} +
+ )} + + {activeTab === "stores" && }
diff --git a/frontend/src/pages/GroceryList.jsx b/frontend/src/pages/GroceryList.jsx index ba10b05..34cd4bd 100644 --- a/frontend/src/pages/GroceryList.jsx +++ b/frontend/src/pages/GroceryList.jsx @@ -30,11 +30,14 @@ import { findSimilarItems } from "../utils/stringSimilarity"; export default function GroceryList() { - const { role } = useContext(AuthContext); + const { role: systemRole } = useContext(AuthContext); const { activeHousehold } = useContext(HouseholdContext); const { activeStore } = useContext(StoreContext); const { settings } = useContext(SettingsContext); + // Get household role for permissions + const householdRole = activeHousehold?.role; + // === State === // const [items, setItems] = useState([]); const [recentlyBoughtItems, setRecentlyBoughtItems] = useState([]); @@ -421,7 +424,7 @@ export default function GroceryList() { const handleLongPress = useCallback(async (item) => { - if (![ROLES.ADMIN, ROLES.EDITOR].includes(role)) return; + if (!householdRole || householdRole === 'viewer') return; if (!activeHousehold?.id || !activeStore?.id) return; try { @@ -436,7 +439,7 @@ export default function GroceryList() { setEditingItem({ ...item, classification: null }); setShowEditModal(true); } - }, [activeHousehold?.id, activeStore?.id, role]); + }, [activeHousehold?.id, activeStore?.id, householdRole]); // === Edit Modal Handlers === @@ -524,7 +527,7 @@ export default function GroceryList() { - {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && ( + {householdRole && householdRole !== 'viewer' && showAddForm && ( - [ROLES.ADMIN, ROLES.EDITOR].includes(role) && handleBought(id, quantity) + householdRole && householdRole !== 'viewer' && handleBought(id, quantity) } onImageAdded={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null + householdRole && householdRole !== 'viewer' ? handleImageAdded : null } onLongPress={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + householdRole && householdRole !== 'viewer' ? handleLongPress : null } /> ))} @@ -625,10 +628,10 @@ export default function GroceryList() { compact={settings.compactView} onClick={null} onImageAdded={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleImageAdded : null + householdRole && householdRole !== 'viewer' ? handleImageAdded : null } onLongPress={ - [ROLES.ADMIN, ROLES.EDITOR].includes(role) ? handleLongPress : null + householdRole && householdRole !== 'viewer' ? handleLongPress : null } /> ))} @@ -649,7 +652,7 @@ export default function GroceryList() { )} - {[ROLES.ADMIN, ROLES.EDITOR].includes(role) && ( + {householdRole && householdRole !== 'viewer' && ( setShowAddForm(!showAddForm)} diff --git a/frontend/src/styles/components/admin/StoreManagement.css b/frontend/src/styles/components/admin/StoreManagement.css new file mode 100644 index 0000000..60c4320 --- /dev/null +++ b/frontend/src/styles/components/admin/StoreManagement.css @@ -0,0 +1,278 @@ +/* Store Management Component */ +.store-management { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +} + +.store-management-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.store-management-header h2 { + font-size: 1.75rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +/* Form Card */ +.store-form-card { + background: var(--card-bg); + border: 2px solid var(--primary); + border-radius: 8px; + padding: 2rem; + margin-bottom: 2rem; +} + +.store-form-card h3 { + font-size: 1.3rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 1.5rem 0; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.5rem; + font-size: 0.95rem; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + background: var(--background); + color: var(--text-primary); + font-family: inherit; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +.form-group textarea { + resize: vertical; + font-family: 'Courier New', monospace; + font-size: 0.9rem; +} + +.form-hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +/* Zone Input Section */ +.zone-input-container { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.zone-input-container input { + flex: 1 1 300px; + width: auto !important; + min-width: 200px; + max-width: 100%; +} + +.zone-input-container .btn-small { + flex: 0 0 auto; + padding: 0.5rem 1rem; + font-size: 0.9rem; + white-space: nowrap; +} + +/* Zones List (Chips in Form) */ +.zones-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.75rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + min-height: 50px; +} + +.zone-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--color-primary); + color: white; + padding: 0.4rem 0.7rem; + border-radius: 16px; + font-size: 0.9rem; + line-height: 1; +} + +.zone-remove { + background: none; + border: none; + color: white; + font-size: 1.3rem; + line-height: 1; + cursor: pointer; + padding: 0; + margin: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background 0.2s; +} + +.zone-remove:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Store Zones Display (in cards) */ +.store-zones-display { + margin-top: 0.75rem; +} + +.zones-label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.zones-list-display { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.zone-badge { + display: inline-block; + background: var(--color-primary); + color: white; + padding: 0.3rem 0.7rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 500; +} + +.form-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 2rem; +} + +/* Stores Grid */ +.stores-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.store-admin-card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.store-admin-card:hover { + border-color: var(--primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.store-admin-info h3 { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.75rem 0; +} + +.store-zones { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0.5rem 0; + word-break: break-word; +} + +.store-meta { + color: var(--text-secondary); + font-size: 0.85rem; + margin: 0.5rem 0 0 0; + font-family: monospace; +} + +.store-admin-actions { + display: flex; + gap: 0.75rem; + margin-top: auto; +} + +.store-admin-actions button { + flex: 1; +} + +.empty-message { + text-align: center; + color: var(--text-secondary); + padding: 3rem; + grid-column: 1 / -1; +} + +/* Responsive */ +@media (max-width: 768px) { + .store-management { + padding: 1rem; + } + + .store-management-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .store-management-header button { + width: 100%; + } + + .stores-grid { + grid-template-columns: 1fr; + } + + .store-form-card { + padding: 1.5rem; + } + + .form-actions { + flex-direction: column-reverse; + } + + .form-actions button { + width: 100%; + } +} diff --git a/frontend/src/styles/components/manage/ManageHousehold.css b/frontend/src/styles/components/manage/ManageHousehold.css index d7caeed..5e60531 100644 --- a/frontend/src/styles/components/manage/ManageHousehold.css +++ b/frontend/src/styles/components/manage/ManageHousehold.css @@ -34,6 +34,7 @@ /* Household Name Section */ .name-display { display: flex; + flex-direction: column; align-items: center; justify-content: space-between; gap: 1rem; @@ -66,6 +67,7 @@ /* Invite Code Section */ .invite-actions { display: flex; + flex-direction: column; gap: 0.75rem; align-items: center; flex-wrap: wrap; @@ -92,6 +94,7 @@ .member-card { display: flex; + flex-direction: row; justify-content: space-between; align-items: center; padding: 1.25rem; @@ -125,18 +128,10 @@ border-radius: 4px; width: fit-content; text-transform: capitalize; -} - -.member-role.admin { background: var(--primary-light, rgba(0, 122, 255, 0.1)); color: var(--primary); } -.member-role.member { - background: var(--text-secondary-light, rgba(128, 128, 128, 0.1)); - color: var(--text-secondary); -} - .member-actions { display: flex; gap: 0.75rem; diff --git a/frontend/src/styles/pages/AdminPanel.css b/frontend/src/styles/pages/AdminPanel.css index 0afe84b..0d3caf4 100644 --- a/frontend/src/styles/pages/AdminPanel.css +++ b/frontend/src/styles/pages/AdminPanel.css @@ -1,9 +1,109 @@ -/* Admin Panel - uses utility classes */ -/* Responsive adjustments only */ +/* Admin Panel Layout */ +.admin-panel-body { + min-height: 100vh; + background: var(--background); + padding: 2rem 1rem; + display: flex; + justify-content: center; +} -@media (max-width: 768px) { - .admin-panel-page { - padding: var(--spacing-md) !important; +.admin-panel-container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.admin-panel-title { + font-size: 2rem; + font-weight: 600; + color: var(--text-primary); + text-align: center; + margin-bottom: 2rem; +} + +/* Tabs */ +.admin-tabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--border); + margin-bottom: 2rem; +} + +.admin-tab { + padding: 0.75rem 1.5rem; + background: none; + border: none; + border-bottom: 3px solid transparent; + color: var(--text-secondary); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.admin-tab:hover { + color: var(--text-primary); + background: var(--card-hover); +} + +.admin-tab.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Content Area */ +.admin-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); } } +/* Users Section */ +.users-section { + max-width: 800px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-panel-body { + padding: 1rem 0.75rem; + } + + .admin-panel-title { + font-size: 1.75rem; + } + + .admin-tab { + padding: 0.65rem 1rem; + font-size: 0.9rem; + } +} + +@media (max-width: 480px) { + .admin-panel-body { + padding: 1rem 0.5rem; + } + + .admin-panel-title { + font-size: 1.5rem; + } + + .admin-tab { + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + } +}