Merge branch 'master' of ssh://stash.dyson.global.corp:7999/~thowitz/tremor-tracker
This commit is contained in:
commit
0b6167ed4b
359
src/app/administrator/page.tsx
Normal file
359
src/app/administrator/page.tsx
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
type Role = "admin" | "user" | "editor";
|
||||||
|
type User = {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialUsers: User[] = [ // todo - add user reading function
|
||||||
|
{ email: "john@example.com", name: "John Doe", role: "admin", password: "secret1" },
|
||||||
|
{ email: "jane@example.com", name: "Jane Smith", role: "user", password: "secret2" },
|
||||||
|
{ email: "bob@example.com", name: "Bob Brown", role: "editor", password: "secret3" },
|
||||||
|
{ email: "alice@example.com", name: "Alice Johnson", role: "user", password: "secret4" },
|
||||||
|
{ email: "eve@example.com", name: "Eve Black", role: "admin", password: "secret5" },
|
||||||
|
{ email: "dave@example.com", name: "Dave Clark", role: "user", password: "pw" },
|
||||||
|
{ email: "fred@example.com", name: "Fred Fox", role: "user", password: "pw" },
|
||||||
|
{ email: "ginny@example.com", name: "Ginny Hall", role: "editor", password: "pw" },
|
||||||
|
{ email: "harry@example.com", name: "Harry Lee", role: "admin", password: "pw" },
|
||||||
|
{ email: "ivy@example.com", name: "Ivy Volt", role: "admin", password: "pw" },
|
||||||
|
{ email: "kate@example.com", name: "Kate Moss", role: "editor", password: "pw" },
|
||||||
|
{ email: "leo@example.com", name: "Leo Garrison", role: "user", password: "pw" },
|
||||||
|
{ email: "isaac@example.com", name: "Isaac Yang", role: "user", password: "pw" },
|
||||||
|
];
|
||||||
|
const sortFields = [ // Sort box options
|
||||||
|
{ label: "Name", value: "name" },
|
||||||
|
{ label: "Email", value: "email" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SortField = typeof sortFields[number]["value"];
|
||||||
|
type SortDir = "asc" | "desc";
|
||||||
|
const dirLabels: Record<SortDir, string> = { asc: "ascending", desc: "descending" };
|
||||||
|
const fieldLabels: Record<SortField, string> = { name: "Name", email: "Email" };
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Local edit state for editor form
|
||||||
|
const [editUser, setEditUser] = useState<User | null>(null);
|
||||||
|
// Reset editUser when the selected user changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedEmail) setEditUser(null);
|
||||||
|
else {
|
||||||
|
const user = users.find(u => u.email === selectedEmail);
|
||||||
|
setEditUser(user ? { ...user } : null);
|
||||||
|
}
|
||||||
|
}, [selectedEmail, users]);
|
||||||
|
|
||||||
|
// Search/filter/sort state
|
||||||
|
const [searchField, setSearchField] = useState<"name" | "email">("name");
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [roleFilter, setRoleFilter] = useState<Role | "all">("all");
|
||||||
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
||||||
|
// Dropdown states
|
||||||
|
const [filterDropdownOpen, setFilterDropdownOpen] = useState(false);
|
||||||
|
const [sortDropdownOpen, setSortDropdownOpen] = useState(false);
|
||||||
|
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sortDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
filterDropdownRef.current && !filterDropdownRef.current.contains(e.target as Node)
|
||||||
|
) setFilterDropdownOpen(false);
|
||||||
|
if (
|
||||||
|
sortDropdownRef.current && !sortDropdownRef.current.contains(e.target as Node)
|
||||||
|
) setSortDropdownOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filtering, searching, sorting logic
|
||||||
|
const filteredUsers = users.filter(
|
||||||
|
(user) => roleFilter === "all" || user.role === roleFilter
|
||||||
|
);
|
||||||
|
const searchedUsers = filteredUsers.filter(user =>
|
||||||
|
user[searchField].toLowerCase().includes(searchText.toLowerCase())
|
||||||
|
);
|
||||||
|
const sortedUsers = [...searchedUsers].sort((a, b) => {
|
||||||
|
let cmp = a[sortField].localeCompare(b[sortField]);
|
||||||
|
return sortDir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form input change handler
|
||||||
|
const handleEditChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
if (!editUser) return;
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setEditUser(prev =>
|
||||||
|
prev ? { ...prev, [name]: value } : null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update button logic (compare original selectedUser and editUser)
|
||||||
|
const selectedUser = users.find((u) => u.email === selectedEmail);
|
||||||
|
const isEditChanged = React.useMemo(() => {
|
||||||
|
if (!editUser || !selectedUser) return false;
|
||||||
|
// Compare primitive fields
|
||||||
|
return (
|
||||||
|
editUser.name !== selectedUser.name ||
|
||||||
|
editUser.role !== selectedUser.role ||
|
||||||
|
editUser.password !== selectedUser.password
|
||||||
|
);
|
||||||
|
}, [editUser, selectedUser]);
|
||||||
|
|
||||||
|
// Update/save changes
|
||||||
|
const handleUpdate = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editUser) return;
|
||||||
|
setUsers(prev =>
|
||||||
|
prev.map(u =>
|
||||||
|
u.email === editUser.email ? { ...editUser } : u
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// After successful update, update selectedUser local state
|
||||||
|
// (editUser will auto-sync due to useEffect on users)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete user logic
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
if (!window.confirm(`Are you sure you want to delete "${selectedUser.name}"? This cannot be undone.`)) return;
|
||||||
|
setUsers(prev => prev.filter(u => u.email !== selectedUser.email));
|
||||||
|
setSelectedEmail(null);
|
||||||
|
setEditUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const allRoles: Role[] = ["admin", "user", "editor"];
|
||||||
|
|
||||||
|
// Tooltip handling for email field
|
||||||
|
const [showEmailTooltip, setShowEmailTooltip] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex h-full overflow-hidden bg-gray-50">
|
||||||
|
{/* SIDEBAR */}
|
||||||
|
<div className="w-80 h-full border-r border-neutral-200 bg-neutral-100 flex flex-col rounded-l-xl shadow-sm">
|
||||||
|
<div className="p-4 flex flex-col h-full">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 border rounded-lg px-2 py-1 text-sm"
|
||||||
|
placeholder={`Search by ${searchField}`}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={searchField}
|
||||||
|
onChange={e => setSearchField(e.target.value as "name" | "email")}
|
||||||
|
className="border rounded-lg px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/* Filter and Sort Buttons */}
|
||||||
|
<div className="flex gap-2 items-center mb-2">
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1 rounded-lg border font-semibold flex items-center transition
|
||||||
|
${roleFilter !== "all"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 hover:bg-blue-700"
|
||||||
|
: "bg-white text-gray-700 border hover:bg-neutral-200"}
|
||||||
|
`}
|
||||||
|
onClick={() => setFilterDropdownOpen(v => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Filter <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
{filterDropdownOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setRoleFilter("all"); setFilterDropdownOpen(false); }}
|
||||||
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${roleFilter==="all" ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{allRoles.map(role => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
onClick={() => { setRoleFilter(role); setFilterDropdownOpen(false); }}
|
||||||
|
className={`w-full text-left px-3 py-1 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${roleFilter===role ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Sort */}
|
||||||
|
<div className="relative" ref={sortDropdownRef}>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
|
onClick={() => setSortDropdownOpen(v => !v)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Sort <svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||||
|
</button>
|
||||||
|
{sortDropdownOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 left-0 bg-white border rounded-lg shadow-sm w-28 ">
|
||||||
|
{sortFields.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
setSortField(opt.value);
|
||||||
|
setSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 hover:bg-blue-50 border-b border-gray-100 last:border-0
|
||||||
|
${sortField===opt.value ? "font-bold text-blue-600" : ""}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Asc/Desc Toggle */}
|
||||||
|
<button
|
||||||
|
className="ml-2 px-2 py-1 rounded-lg bg-white border text-gray-700 font-semibold flex items-center hover:bg-neutral-200"
|
||||||
|
onClick={() => setSortDir(d => (d === "asc" ? "desc" : "asc"))}
|
||||||
|
title={sortDir === "asc" ? "Ascending" : "Descending"}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{sortDir === "asc" ? "↑" : "↓"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Sort status text */}
|
||||||
|
<small className="text-xs text-gray-500 mb-2 px-1">
|
||||||
|
Users sorted by {fieldLabels[sortField]} {dirLabels[sortDir]}
|
||||||
|
</small>
|
||||||
|
{/* USERS LIST: full height, scrollable */}
|
||||||
|
<ul className="overflow-y-auto flex-1 pr-1">
|
||||||
|
{sortedUsers.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.email}
|
||||||
|
onClick={() => setSelectedEmail(user.email)}
|
||||||
|
className={`rounded-lg cursor-pointer border
|
||||||
|
${selectedEmail === user.email ? "bg-blue-100 border-blue-400" : "hover:bg-gray-200 border-transparent"}
|
||||||
|
transition px-2 py-1 mb-1`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium truncate">{user.name}</span>
|
||||||
|
<span className="ml-1 text-xs px-2 py-0.5 rounded-lg bg-gray-200 text-gray-700">{user.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
|
<span className="text-xs text-gray-600 truncate">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{sortedUsers.length === 0 && (
|
||||||
|
<li className="text-gray-400 text-center py-6">No users found.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* MAIN PANEL */}
|
||||||
|
<div className="flex-1 p-10 bg-white overflow-y-auto">
|
||||||
|
{editUser ? (
|
||||||
|
<div className="max-w-lg mx-auto bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-lg font-bold mb-6">Edit User</h2>
|
||||||
|
<form className="space-y-4" onSubmit={handleUpdate}>
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email (unique):
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none bg-gray-100 cursor-not-allowed"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value={editUser.email}
|
||||||
|
readOnly
|
||||||
|
onMouseEnter={() => setShowEmailTooltip(true)}
|
||||||
|
onMouseLeave={() => setShowEmailTooltip(false)}
|
||||||
|
/>
|
||||||
|
{/* Custom tooltip */}
|
||||||
|
{showEmailTooltip && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-10 w-max max-w-xs bg-gray-800 text-gray-100 text-xs px-3 py-2 rounded-md shadow-lg border border-gray-700">
|
||||||
|
This field cannot be changed. <br />
|
||||||
|
To change the email, delete and re-add the user.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
value={editUser.name}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Role:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
name="role"
|
||||||
|
value={editUser.role}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
>
|
||||||
|
{allRoles.map((role) => (
|
||||||
|
<option key={role} value={role}>{role}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="w-full border px-3 py-2 rounded-lg outline-none focus:ring-2 focus:ring-blue-300"
|
||||||
|
type="text"
|
||||||
|
name="password"
|
||||||
|
value={editUser.password}
|
||||||
|
onChange={handleEditChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg shadow transition"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`px-4 py-2 rounded-lg font-semibold transition
|
||||||
|
${isEditChanged
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white shadow"
|
||||||
|
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
disabled={!isEditChanged}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 mt-16 text-lg">
|
||||||
|
Select a user...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import Map from "@components/Map";
|
import Map from "@components/map";
|
||||||
import Sidebar from "@components/Sidebar";
|
import Sidebar from "@components/Sidebar";
|
||||||
import { fetcher } from "@utils/fetcher";
|
import { fetcher } from "@utils/fetcher";
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { action, createStore, StoreProvider } from 'easy-peasy';
|
|||||||
import { Inter } from 'next/font/google';
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
import { StoreModel } from '@appTypes/StoreModel';
|
import { StoreModel } from '@appTypes/StoreModel';
|
||||||
import Navbar from '@components/Navbar';
|
import Navbar from '@components/navbar';
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import Map from "@components/Map";
|
import Map from "@components/map";
|
||||||
import { fetcher } from "@utils/fetcher";
|
import { fetcher } from "@utils/fetcher";
|
||||||
|
|
||||||
export default function Observatories() {
|
export default function Observatories() {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export default function Navbar({}: // currencySelector,
|
|||||||
const user = useStoreState((state) => state.user);
|
const user = useStoreState((state) => state.user);
|
||||||
const selectedCurrency = useStoreState((state) => state.currency.selectedCurrency);
|
const selectedCurrency = useStoreState((state) => state.currency.selectedCurrency);
|
||||||
const setSelectedCurrency = useStoreActions((actions) => actions.currency.setSelectedCurrency);
|
const setSelectedCurrency = useStoreActions((actions) => actions.currency.setSelectedCurrency);
|
||||||
const navOptions = useMemo(() => ["Earthquakes", "Observatories", "Shop"], []);
|
const navOptions = useMemo(() => ["Earthquakes", "Observatories", "Shop","Administrator"], []);
|
||||||
// const navOptions = useMemo(() => ["Earthquakes"], []);
|
// const navOptions = useMemo(() => ["Earthquakes"], []);
|
||||||
const aboutDropdown = ["Contact Us", "Our Mission", "The Team"];
|
const aboutDropdown = ["Contact Us", "Our Mission", "The Team"];
|
||||||
// { label: "Our Mission", path: "/our-mission" },
|
// { label: "Our Mission", path: "/our-mission" },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user