polished implementation of new artchitecture

This commit is contained in:
Nico 2026-01-26 22:52:16 -08:00
parent 213134c4a5
commit 31eda793ab
16 changed files with 759 additions and 69 deletions

View File

@ -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({

View File

@ -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);
};

View File

@ -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) => {

View File

@ -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;

View File

@ -48,7 +48,7 @@ function App() {
<Route
path="/admin"
element={
<RoleGuard allowed={[ROLES.ADMIN]}>
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
<AdminPanel />
</RoleGuard>
}

View File

@ -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 (
<div className="store-management">
<div className="store-management-header">
<h2>Store Management</h2>
{!showCreateForm && !editingStore && (
<button onClick={startCreate} className="btn-primary">
+ Create Store
</button>
)}
</div>
{/* Create/Edit Form */}
{(showCreateForm || editingStore) && (
<div className="store-form-card">
<h3>{editingStore ? "Edit Store" : "Create New Store"}</h3>
<form onSubmit={editingStore ? handleUpdate : handleCreate}>
<div className="form-group">
<label htmlFor="storeName">Store Name *</label>
<input
id="storeName"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Costco Richmond"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="defaultZones">Default Zones</label>
<div className="zone-input-container">
<input
id="zoneInput"
type="text"
value={newZone}
onChange={(e) => setNewZone(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addZone())}
placeholder="Enter zone name and press Enter or click Add"
/>
<button
type="button"
onClick={addZone}
className="btn-secondary btn-small"
>
Add Zone
</button>
</div>
{formData.zones.length > 0 && (
<div className="zones-list">
{formData.zones.map((zone, index) => (
<div key={index} className="zone-chip">
<span>{zone}</span>
<button
type="button"
onClick={() => removeZone(index)}
className="zone-remove"
aria-label="Remove zone"
>
×
</button>
</div>
))}
</div>
)}
<p className="form-hint">
Add zones that will be used for organizing items in this store
</p>
</div>
<div className="form-actions">
<button
type="button"
onClick={editingStore ? cancelEdit : () => setShowCreateForm(false)}
className="btn-secondary"
>
Cancel
</button>
<button type="submit" className="btn-primary">
{editingStore ? "Update Store" : "Create Store"}
</button>
</div>
</form>
</div>
)}
{/* Stores List */}
<div className="stores-grid">
{loading ? (
<p>Loading stores...</p>
) : stores.length === 0 ? (
<p className="empty-message">No stores found. Create one to get started.</p>
) : (
stores.map((store) => (
<div key={store.id} className="store-admin-card">
<div className="store-admin-info">
<h3>{store.name}</h3>
{store.default_zones && parseZones(store.default_zones).length > 0 && (
<div className="store-zones-display">
<p className="zones-label">Default Zones:</p>
<div className="zones-list-display">
{parseZones(store.default_zones).map((zone, idx) => (
<span key={idx} className="zone-badge">{zone}</span>
))}
</div>
</div>
)}
<p className="store-meta">ID: {store.id}</p>
</div>
<div className="store-admin-actions">
<button
onClick={() => startEdit(store)}
className="btn-secondary btn-small"
>
Edit
</button>
<button
onClick={() => handleDelete(store.id, store.name)}
className="btn-danger btn-small"
>
Delete
</button>
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@ -1,6 +1,8 @@
import { ROLES } from "../../constants/roles";
export default function UserRoleCard({ user, onRoleChange }) {
console.log(user)
return (
<div className="card flex-between p-3 my-2 shadow-sm" style={{ transition: 'var(--transition-base)' }}>
<div className="user-info">

View File

@ -15,7 +15,7 @@ export default function Navbar() {
<Link to="/manage">Manage</Link>
<Link to="/settings">Settings</Link>
{role === "admin" && <Link to="/admin">Admin</Link>}
{role === "system_admin" && <Link to="/admin">Admin</Link>}
</div>
<HouseholdSwitcher />

View File

@ -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);

View File

@ -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 (
<div className="manage-household">
{/* Household Name Section */}
<section className="manage-section">
<section key="household-name" className="manage-section">
<h2>Household Name</h2>
{editingName ? (
<div className="edit-name-form">
@ -150,7 +150,7 @@ export default function ManageHousehold() {
{/* Invite Code Section */}
{isAdmin && (
<section className="manage-section">
<section key="invite-code" className="manage-section">
<h2>Invite Code</h2>
<p className="section-description">
Share this code with others to invite them to your household.
@ -160,10 +160,10 @@ export default function ManageHousehold() {
{showInviteCode ? "Hide Code" : "Show Code"}
</button>
{showInviteCode && (
<>
<React.Fragment key="invite-code-display">
<code className="invite-code">{activeHousehold.invite_code}</code>
<button onClick={copyInviteCode} className="btn-secondary">Copy</button>
</>
</React.Fragment>
)}
<button onClick={handleRefreshInvite} className="btn-secondary">
Generate New Code
@ -173,33 +173,37 @@ export default function ManageHousehold() {
)}
{/* Members Section */}
<section className="manage-section">
<section key="members" className="manage-section">
<h2>Members ({members.length})</h2>
{loading ? (
<p>Loading members...</p>
) : (
<div className="members-list">
{members.map((member) => (
<div key={member.user_id} className="member-card">
<div key={member.id} className="member-card">
<div className="member-info">
<span className="member-name">
{member.username}
{member.user_id === parseInt(userId) && " (You)"}
</span>
<span className={`member-role ${member.role}`}>
<span className="member-role">
{member.role}
</span>
<span className="member-name">
{`
${member.username}
[${member.id}]
${(member.id === parseInt(userId) ? " (You)" : "")}
`}
</span>
</div>
{isAdmin && member.user_id !== parseInt(userId) && (
{isAdmin && member.id !== parseInt(userId) && (
<div className="member-actions">
<button
onClick={() => handleUpdateRole(member.user_id, member.role)}
onClick={() => handleUpdateRole(member.id, member.role)}
className="btn-secondary btn-small"
>
{member.role === "admin" ? "Make Member" : "Make Admin"}
</button>
<button
onClick={() => handleRemoveMember(member.user_id, member.username)}
onClick={() => handleRemoveMember(member.id, member.username)}
className="btn-danger btn-small"
>
Remove
@ -214,7 +218,7 @@ export default function ManageHousehold() {
{/* Danger Zone */}
{isAdmin && (
<section className="manage-section danger-zone">
<section key="danger-zone" className="manage-section danger-zone">
<h2>Danger Zone</h2>
<p className="section-description">
Deleting a household is permanent and will delete all lists, items, and history.

View File

@ -2,5 +2,7 @@ export const ROLES = {
VIEWER: "viewer",
EDITOR: "editor",
ADMIN: "admin",
SYSTEM_ADMIN: "system_admin",
USER: "user",
UP_TO_ADMIN: ["viewer", "editor", "admin"],
};

View File

@ -1,14 +1,17 @@
import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users";
import StoreManagement from "../components/admin/StoreManagement";
import UserRoleCard from "../components/common/UserRoleCard";
import "../styles/UserRoleCard.css";
import "../styles/pages/AdminPanel.css";
export default function AdminPanel() {
const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState("users");
async function loadUsers() {
const allUsers = await getAllUsers();
console.log("Users found:", users);
setUsers(allUsers.data);
}
@ -23,17 +26,39 @@ export default function AdminPanel() {
}
return (
<div className="p-4" style={{ minHeight: '100vh' }}>
<div className="card" style={{ maxWidth: '800px', margin: '0 auto' }}>
<h1 className="text-center text-3xl font-bold mb-4">Admin Panel</h1>
<div className="mt-4">
{users.map((user) => (
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
<div className="admin-panel-body">
<div className="admin-panel-container">
<h1 className="admin-panel-title">Admin Panel</h1>
<div className="admin-tabs">
<button
className={`admin-tab ${activeTab === "users" ? "active" : ""}`}
onClick={() => setActiveTab("users")}
>
User Management
</button>
<button
className={`admin-tab ${activeTab === "stores" ? "active" : ""}`}
onClick={() => setActiveTab("stores")}
>
Store Management
</button>
</div>
<div className="admin-content">
{activeTab === "users" && (
<div className="users-section">
{users.map((user) => (
<UserRoleCard
key={user.id}
user={user}
onRoleChange={changeRole}
/>
))}
</div>
)}
{activeTab === "stores" && <StoreManagement />}
</div>
</div>
</div>

View File

@ -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() {
<StoreTabs />
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && showAddForm && (
{householdRole && householdRole !== 'viewer' && showAddForm && (
<AddItemForm
onAdd={handleAdd}
onSuggest={handleSuggest}
@ -564,13 +567,13 @@ export default function GroceryList() {
allItems={sortedItems}
compact={settings.compactView}
onClick={(id, quantity) =>
[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() {
)}
</div>
{[ROLES.ADMIN, ROLES.EDITOR].includes(role) && (
{householdRole && householdRole !== 'viewer' && (
<FloatingActionButton
isOpen={showAddForm}
onClick={() => setShowAddForm(!showAddForm)}

View File

@ -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%;
}
}

View File

@ -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;

View File

@ -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;
}
}