Admin role added, dev side has hot reload again, characters AND groups tied to accounts now.
This commit is contained in:
7
os-league-tools-master/.env.development
Normal file
7
os-league-tools-master/.env.development
Normal file
@@ -0,0 +1,7 @@
|
||||
# Development environment variables
|
||||
|
||||
# API server - use same domain, nginx proxies /api to backend
|
||||
REACT_APP_RELDO_URL=
|
||||
|
||||
# Google Analytics (optional - leave empty for dev)
|
||||
REACT_APP_GA_MID=
|
||||
5
os-league-tools-master/.env.example
Normal file
5
os-league-tools-master/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
# API server URL
|
||||
REACT_APP_RELDO_URL=http://localhost:3001
|
||||
|
||||
# Google Analytics tracking ID (optional)
|
||||
REACT_APP_GA_MID=
|
||||
7
os-league-tools-master/.env.production
Normal file
7
os-league-tools-master/.env.production
Normal file
@@ -0,0 +1,7 @@
|
||||
# Production environment variables
|
||||
|
||||
# Production API server (same origin - served by Node.js)
|
||||
REACT_APP_RELDO_URL=
|
||||
|
||||
# Google Analytics
|
||||
REACT_APP_GA_MID=
|
||||
19
os-league-tools-master/leagues-tools-frontend-dev.service
Normal file
19
os-league-tools-master/leagues-tools-frontend-dev.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Leagues Tools Frontend Dev Server (Hot Reload)
|
||||
After=network.target leagues-tools-dev.service
|
||||
Wants=leagues-tools-dev.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=sonder
|
||||
WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=leagues-tools-frontend-dev
|
||||
Environment=NODE_ENV=development
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
62
os-league-tools-master/package-lock.json
generated
62
os-league-tools-master/package-lock.json
generated
@@ -96,6 +96,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^6.2.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.4.7",
|
||||
"lint-staged": "^12.1.5",
|
||||
@@ -10254,26 +10255,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
|
||||
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/express": {
|
||||
"optional": true
|
||||
}
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
@@ -10858,6 +10854,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-port-reachable": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
|
||||
@@ -19831,6 +19837,30 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/express": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"gh-pages": "^6.2.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.4.7",
|
||||
"lint-staged": "^12.1.5",
|
||||
|
||||
@@ -19,6 +19,7 @@ import Faq from './pages/Faq';
|
||||
import ViewCharacter from './pages/ViewCharacter';
|
||||
import Groups from './pages/Groups';
|
||||
import Planner from './pages/Planner';
|
||||
import Admin from './pages/Admin';
|
||||
import { submitRenderError } from './client/feedback-client';
|
||||
import { ErrorPage } from './components/common/util/ErrorBoundary';
|
||||
|
||||
@@ -45,6 +46,7 @@ history.listen(() => {
|
||||
const isDevEnvironment = () =>
|
||||
window.location.hostname.startsWith('dev.') ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.port === '3000' ||
|
||||
window.location.port === '3001';
|
||||
|
||||
export default function App() {
|
||||
@@ -111,6 +113,7 @@ export default function App() {
|
||||
<Route path='about' element={<About />} />
|
||||
<Route path='settings' element={<Settings />} />
|
||||
<Route path='faq' element={<Faq />} />
|
||||
<Route path='admin' element={<Admin />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Auth0Provider>
|
||||
|
||||
95
os-league-tools-master/src/client/admin-client.js
Normal file
95
os-league-tools-master/src/client/admin-client.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => ({
|
||||
success: false,
|
||||
error: data.message || data.error || 'An error occurred',
|
||||
}));
|
||||
}
|
||||
return response.json().then(data => ({
|
||||
success: true,
|
||||
value: data,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.warn(error);
|
||||
return { success: false, error: error.message || 'Network error' };
|
||||
}
|
||||
|
||||
export function getAdminStats() {
|
||||
return fetch(`${BASE_URL}/api/admin/stats`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getUsers() {
|
||||
return fetch(`${BASE_URL}/api/admin/users`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function getUser(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function updateUser(id, data) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function resetUserPassword(id, password) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}/password`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function deleteUser(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
export function invalidateUserSessions(id) {
|
||||
return fetch(`${BASE_URL}/api/admin/users/${id}/sessions`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
98
os-league-tools-master/src/client/character-client.js
Normal file
98
os-league-tools-master/src/client/character-client.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
function handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => ({
|
||||
success: false,
|
||||
error: data.message || data.error || 'An error occurred',
|
||||
}));
|
||||
}
|
||||
return response.json().then(data => ({
|
||||
success: true,
|
||||
value: data,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleError(error) {
|
||||
console.warn(error);
|
||||
return { success: false, error: error.message || 'Network error' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all characters for the authenticated user
|
||||
*/
|
||||
export function getCharacters() {
|
||||
return fetch(`${BASE_URL}/api/characters`, {
|
||||
method: 'GET',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new character
|
||||
* @param {string} rsn - RuneScape Name
|
||||
* @param {boolean} setActive - Whether to set this character as active
|
||||
*/
|
||||
export function createCharacter(rsn, setActive = false) {
|
||||
return fetch(`${BASE_URL}/api/characters`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ rsn, setActive }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a character (rename, set active, or sync data)
|
||||
* @param {number} id - Character ID
|
||||
* @param {object} updates - Fields to update (rsn, isActive, tasksData, unlocksData, notesData)
|
||||
*/
|
||||
export function updateCharacter(id, updates) {
|
||||
return fetch(`${BASE_URL}/api/characters/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a character
|
||||
* @param {number} id - Character ID
|
||||
*/
|
||||
export function deleteCharacter(id) {
|
||||
return fetch(`${BASE_URL}/api/characters/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk sync characters (used on login to merge local data with server)
|
||||
* @param {Array} characters - Array of character objects or RSN strings
|
||||
* @param {number} activeIndex - Index of the active character
|
||||
*/
|
||||
export function syncCharacters(characters, activeIndex) {
|
||||
return fetch(`${BASE_URL}/api/characters/sync`, {
|
||||
method: 'POST',
|
||||
headers: DEFAULT_HEADERS,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ characters, activeIndex }),
|
||||
})
|
||||
.then(handleResponse)
|
||||
.catch(handleError);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
|
||||
const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
|
||||
|
||||
export default async function getHiscores(rsn, handleResultCallback) {
|
||||
if (!rsn) {
|
||||
|
||||
@@ -20,11 +20,11 @@ export default function PageWrapper({ children }) {
|
||||
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
|
||||
|
||||
const navItems = [
|
||||
new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
|
||||
new NavItem('WIP-Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('WIP-Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('WIP-Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('WIP-Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('WIP-Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
|
||||
new NavItem('Settings', 'overflow', 3, 1).withRouterLink('/settings').withIconFont('settings'),
|
||||
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
|
||||
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Spinner from '../common/Spinner';
|
||||
import Dropdown from '../common/Dropdown';
|
||||
import useAccount from '../../hooks/useAccount';
|
||||
@@ -13,6 +14,7 @@ function NavBarItem() {
|
||||
isAuthenticating,
|
||||
username,
|
||||
userEmail,
|
||||
isAdmin,
|
||||
openAuthModal,
|
||||
logout,
|
||||
authModalOpen,
|
||||
@@ -70,6 +72,13 @@ function NavBarItem() {
|
||||
{userEmail && <p className='text-xs text-secondary font-normal'>{userEmail}</p>}
|
||||
</Dropdown.Text>
|
||||
<Dropdown.Separator />
|
||||
{isAdmin && (
|
||||
<Link to='/admin' onClick={() => setExpanded(false)}>
|
||||
<Dropdown.Button className='text-left' icon='admin_panel_settings'>
|
||||
Admin
|
||||
</Dropdown.Button>
|
||||
</Link>
|
||||
)}
|
||||
<Dropdown.Button
|
||||
className='text-left'
|
||||
icon='logout'
|
||||
@@ -141,6 +150,7 @@ function SideBarItem({ isCollapsed, onNavigate }) {
|
||||
isAuthenticating,
|
||||
username,
|
||||
userEmail,
|
||||
isAdmin,
|
||||
openAuthModal,
|
||||
logout,
|
||||
authModalOpen,
|
||||
@@ -205,6 +215,21 @@ function SideBarItem({ isCollapsed, onNavigate }) {
|
||||
<span className='sidebar-nav-link-label text-xs'>{userEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link
|
||||
to='/admin'
|
||||
className='sidebar-nav-link w-full text-left text-sm'
|
||||
onClick={() => {
|
||||
setExpanded(false);
|
||||
if (onNavigate) {
|
||||
onNavigate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>admin_panel_settings</span>
|
||||
<span className='sidebar-nav-link-label'>Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className='sidebar-nav-link w-full text-left text-sm'
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { resetState } from '../store/common';
|
||||
import { setLoggedIn, setLoggedOut } from '../store/user/account';
|
||||
import { syncFromServer } from '../store/user/character';
|
||||
import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client';
|
||||
import { syncCharacters, getCharacters } from '../client/character-client';
|
||||
|
||||
export default function useAccount() {
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const hasSyncedRef = useRef(false);
|
||||
const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn);
|
||||
const isChecking = useSelector(state => state.account.accountCache.isChecking);
|
||||
const username = useSelector(state => state.account.accountCache.username);
|
||||
const userEmail = useSelector(state => state.account.accountCache.userEmail);
|
||||
const userRole = useSelector(state => state.account.accountCache.userRole);
|
||||
const isAdmin = userRole === 'ADMIN';
|
||||
const localCharacters = useSelector(state => state.character.characters);
|
||||
const localActiveCharacter = useSelector(state => state.character.activeCharacter);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,6 +27,7 @@ export default function useAccount() {
|
||||
dispatch(setLoggedIn({
|
||||
username: userResult.value.username,
|
||||
email: userResult.value.email,
|
||||
role: userResult.value.role,
|
||||
}));
|
||||
} else {
|
||||
dispatch(setLoggedOut());
|
||||
@@ -31,11 +39,52 @@ export default function useAccount() {
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
// Sync characters once when user logs in
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !hasSyncedRef.current) {
|
||||
hasSyncedRef.current = true;
|
||||
|
||||
const doSync = async () => {
|
||||
if (localCharacters.length > 0) {
|
||||
const result = await syncCharacters(
|
||||
localCharacters.map(rsn => ({ rsn })),
|
||||
localActiveCharacter
|
||||
);
|
||||
if (result.success) {
|
||||
const activeIndex = result.value.characters.findIndex(c => c.isActive);
|
||||
dispatch(syncFromServer({
|
||||
characters: result.value.characters,
|
||||
activeIndex: activeIndex >= 0 ? activeIndex : 0,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const result = await getCharacters();
|
||||
if (result.success && result.value.characters.length > 0) {
|
||||
const activeIndex = result.value.characters.findIndex(c => c.isActive);
|
||||
dispatch(syncFromServer({
|
||||
characters: result.value.characters,
|
||||
activeIndex: activeIndex >= 0 ? activeIndex : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
doSync();
|
||||
}
|
||||
|
||||
// Reset sync flag on logout
|
||||
if (!isLoggedIn) {
|
||||
hasSyncedRef.current = false;
|
||||
}
|
||||
}, [isLoggedIn, localCharacters, localActiveCharacter, dispatch]);
|
||||
|
||||
const handleAuthSuccess = userData => {
|
||||
dispatch(setLoggedIn({
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
}));
|
||||
// Character sync will be triggered by the isLoggedIn effect
|
||||
};
|
||||
|
||||
const openAuthModal = () => {
|
||||
@@ -59,6 +108,8 @@ export default function useAccount() {
|
||||
isAuthenticating: isChecking,
|
||||
username,
|
||||
userEmail,
|
||||
userRole,
|
||||
isAdmin,
|
||||
authModalOpen,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
|
||||
334
os-league-tools-master/src/pages/Admin.js
Normal file
334
os-league-tools-master/src/pages/Admin.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import PageWrapper from '../components/PageWrapper';
|
||||
import useAccount from '../hooks/useAccount';
|
||||
import {
|
||||
getAdminStats,
|
||||
getUsers,
|
||||
updateUser,
|
||||
resetUserPassword,
|
||||
deleteUser,
|
||||
invalidateUserSessions,
|
||||
} from '../client/admin-client';
|
||||
|
||||
function StatCard({ label, value, icon }) {
|
||||
return (
|
||||
<div className='bg-secondary p-4 rounded-lg'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='icon-lg text-primary-alt'>{icon}</span>
|
||||
<div>
|
||||
<p className='text-2xl font-bold text-accent'>{value}</p>
|
||||
<p className='text-sm text-secondary'>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({ user, onUpdate, onDelete }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editRole, setEditRole] = useState(user.role);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
setLoading(true);
|
||||
const result = await updateUser(user.id, { role: editRole });
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onUpdate();
|
||||
setIsEditing(false);
|
||||
setMessage({ type: 'success', text: 'Role updated' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handlePasswordReset = async () => {
|
||||
if (newPassword.length < 8) {
|
||||
setMessage({ type: 'error', text: 'Password must be at least 8 characters' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await resetUserPassword(user.id, newPassword);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setShowPasswordReset(false);
|
||||
setNewPassword('');
|
||||
setMessage({ type: 'success', text: 'Password reset and sessions invalidated' });
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleInvalidateSessions = async () => {
|
||||
setLoading(true);
|
||||
const result = await invalidateUserSessions(user.id);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.value.message });
|
||||
onUpdate();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
}
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Are you sure you want to delete user "${user.username}"?`)) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await deleteUser(user.id);
|
||||
setLoading(false);
|
||||
if (result.success) {
|
||||
onDelete();
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className='border-b border-primary'>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='font-medium text-accent'>{user.username}</div>
|
||||
<div className='text-xs text-secondary'>{user.email}</div>
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
{isEditing ? (
|
||||
<select
|
||||
value={editRole}
|
||||
onChange={e => setEditRole(e.target.value)}
|
||||
className='input-primary text-sm'
|
||||
>
|
||||
<option value='USER'>USER</option>
|
||||
<option value='ADMIN'>ADMIN</option>
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.role === 'ADMIN' ? 'bg-accent text-black' : 'bg-secondary text-primary'
|
||||
}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className='py-3 px-4 text-sm text-secondary'>{user.sessionCount}</td>
|
||||
<td className='py-3 px-4 text-sm text-secondary'>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className='py-3 px-4'>
|
||||
<div className='flex gap-2 flex-wrap'>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRoleChange}
|
||||
disabled={loading}
|
||||
className='btn-primary text-xs px-2 py-1'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditRole(user.role);
|
||||
}}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setIsEditing(true)}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Edit role'
|
||||
>
|
||||
<span className='icon-sm'>edit</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPasswordReset(!showPasswordReset)}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Reset password'
|
||||
>
|
||||
<span className='icon-sm'>key</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleInvalidateSessions}
|
||||
disabled={loading}
|
||||
className='btn-secondary text-xs px-2 py-1'
|
||||
title='Invalidate sessions'
|
||||
>
|
||||
<span className='icon-sm'>logout</span>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className='btn-danger text-xs px-2 py-1'
|
||||
title='Delete user'
|
||||
>
|
||||
<span className='icon-sm'>delete</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showPasswordReset && (
|
||||
<div className='mt-2 flex gap-2'>
|
||||
<input
|
||||
type='password'
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder='New password'
|
||||
className='input-primary text-sm flex-1'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePasswordReset}
|
||||
disabled={loading}
|
||||
className='btn-primary text-xs px-2 py-1'
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div
|
||||
className={`mt-2 text-xs ${message.type === 'error' ? 'text-error' : 'text-success'}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const { isLoggedIn, isAuthenticating, isAdmin } = useAccount();
|
||||
const [stats, setStats] = useState(null);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const [statsResult, usersResult] = await Promise.all([getAdminStats(), getUsers()]);
|
||||
|
||||
if (statsResult.success) {
|
||||
setStats(statsResult.value);
|
||||
} else {
|
||||
setError(statsResult.error);
|
||||
}
|
||||
|
||||
if (usersResult.success) {
|
||||
setUsers(usersResult.value.users);
|
||||
} else {
|
||||
setError(usersResult.error);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// Redirect non-admins
|
||||
if (!isAuthenticating && (!isLoggedIn || !isAdmin)) {
|
||||
return <Navigate to='/' replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className='max-w-6xl mx-auto p-4'>
|
||||
<h1 className='text-2xl font-bold text-accent mb-6 flex items-center gap-2'>
|
||||
<span className='icon-lg'>admin_panel_settings</span>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
|
||||
{loading && (
|
||||
<div className='text-center py-8'>
|
||||
<span className='icon-lg animate-spin'>sync</span>
|
||||
<p className='text-secondary mt-2'>Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className='bg-error/20 border border-error rounded p-4 mb-6'>
|
||||
<p className='text-error'>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && stats && (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className='grid grid-cols-2 md:grid-cols-4 gap-4 mb-8'>
|
||||
<StatCard label='Users' value={stats.users} icon='people' />
|
||||
<StatCard label='Active Sessions' value={stats.activeSessions} icon='key' />
|
||||
<StatCard label='Groups' value={stats.groups} icon='groups' />
|
||||
<StatCard label='Members' value={stats.members} icon='person' />
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className='bg-secondary rounded-lg overflow-hidden'>
|
||||
<div className='p-4 border-b border-primary'>
|
||||
<h2 className='text-lg font-semibold text-primary flex items-center gap-2'>
|
||||
<span className='icon-base'>people</span>
|
||||
Users ({users.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<table className='w-full'>
|
||||
<thead className='bg-secondary-alt'>
|
||||
<tr>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
User
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Role
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Sessions
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Created
|
||||
</th>
|
||||
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onUpdate={fetchData}
|
||||
onDelete={fetchData}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
15
os-league-tools-master/src/setupProxy.js
Normal file
15
os-league-tools-master/src/setupProxy.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
module.exports = function(app) {
|
||||
// Proxy API requests to the Node.js backend
|
||||
app.use(
|
||||
'/api',
|
||||
createProxyMiddleware({
|
||||
target: process.env.REACT_APP_RELDO_URL || 'http://localhost:3003',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// Pass cookies for session auth
|
||||
cookieDomainRewrite: 'localhost',
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,25 +1,49 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { LOCALSTORAGE_KEYS } from '../client/localstorage-client';
|
||||
import { updateCharacter } from '../client/character-client';
|
||||
|
||||
// Debounce server sync to avoid too many requests
|
||||
let syncTimeout = null;
|
||||
const SYNC_DEBOUNCE_MS = 2000;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export default function updateWithUserDataStorage(wrappedDispatchFn, wrappedFnProps, localstorageKey, stateKey) {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(wrappedDispatchFn(wrappedFnProps));
|
||||
|
||||
// TODO re-enable user login
|
||||
// const dataKey = (() => {
|
||||
// switch (localstorageKey) {
|
||||
// case LOCALSTORAGE_KEYS.UNLOCKS:
|
||||
// case LOCALSTORAGE_KEYS.TASKS: {
|
||||
// const state = getState();
|
||||
// return `${localstorageKey}_${state.character.characters[state.character.activeCharacter] ?? 'DEFAULT'}`;
|
||||
// }
|
||||
// default: {
|
||||
// return localstorageKey;
|
||||
// }
|
||||
// }
|
||||
// })();
|
||||
const state = getState();
|
||||
const { isLoggedIn } = state.account.accountCache;
|
||||
|
||||
// const { isLoggedIn, userEmail, accessToken } = getState().account.accountCache;
|
||||
// if (isLoggedIn && !wrappedFnProps.skipDbUpdate) {
|
||||
// putUserData(userEmail, dataKey, getState()[stateKey], accessToken);
|
||||
// }
|
||||
// Sync to server if logged in and not skipping DB update
|
||||
if (isLoggedIn && !wrappedFnProps?.skipDbUpdate) {
|
||||
// Only sync tasks and unlocks data
|
||||
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS || localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
|
||||
const activeRsn = state.character.characters[state.character.activeCharacter];
|
||||
const characterId = state.character.characterIds[activeRsn];
|
||||
|
||||
if (characterId) {
|
||||
// Debounce the sync to avoid too many requests
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout);
|
||||
}
|
||||
|
||||
syncTimeout = setTimeout(async () => {
|
||||
const currentState = getState();
|
||||
const updateData = {};
|
||||
|
||||
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS) {
|
||||
updateData.tasksData = currentState.tasks;
|
||||
} else if (localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
|
||||
updateData.unlocksData = currentState.unlocks;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateCharacter(characterId, updateData);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync data to server:', error);
|
||||
}
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const CURRENT_VERSION = 4;
|
||||
export const CURRENT_VERSION = 5;
|
||||
|
||||
const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
@@ -11,6 +11,7 @@ const INITIAL_STATE = {
|
||||
isChecking: true,
|
||||
username: undefined,
|
||||
userEmail: undefined,
|
||||
userRole: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,12 +27,14 @@ export const accountSlice = createSlice({
|
||||
state.accountCache.isChecking = false;
|
||||
state.accountCache.username = action.payload.username;
|
||||
state.accountCache.userEmail = action.payload.email;
|
||||
state.accountCache.userRole = action.payload.role;
|
||||
},
|
||||
setLoggedOut: state => {
|
||||
state.accountCache.isLoggedIn = false;
|
||||
state.accountCache.isChecking = false;
|
||||
state.accountCache.username = undefined;
|
||||
state.accountCache.userEmail = undefined;
|
||||
state.accountCache.userRole = undefined;
|
||||
},
|
||||
reset: () => INITIAL_STATE,
|
||||
},
|
||||
|
||||
@@ -16,11 +16,18 @@ export const characterSlice = createSlice({
|
||||
},
|
||||
addCharacter: (state, action) => {
|
||||
state.characters.push(action.payload.rsn);
|
||||
if (action.payload.id) {
|
||||
state.characterIds[action.payload.rsn] = action.payload.id;
|
||||
}
|
||||
if (action.payload.setActive) {
|
||||
state.activeCharacter = state.characters.length - 1;
|
||||
}
|
||||
},
|
||||
deleteCharacter: (state, action) => {
|
||||
const rsn = state.characters[action.payload];
|
||||
if (rsn && state.characterIds[rsn]) {
|
||||
delete state.characterIds[rsn];
|
||||
}
|
||||
state.characters.splice(action.payload, 1);
|
||||
if (state.activeCharacter === action.payload) {
|
||||
state.activeCharacter = 0;
|
||||
@@ -29,8 +36,28 @@ export const characterSlice = createSlice({
|
||||
}
|
||||
},
|
||||
renameCharacter: (state, action) => {
|
||||
const oldRsn = state.characters[action.payload.index];
|
||||
const serverId = state.characterIds[oldRsn];
|
||||
if (serverId) {
|
||||
delete state.characterIds[oldRsn];
|
||||
state.characterIds[action.payload.rsn] = serverId;
|
||||
}
|
||||
state.characters[action.payload.index] = action.payload.rsn;
|
||||
},
|
||||
// Sync characters from server (replaces local state)
|
||||
syncFromServer: (state, action) => {
|
||||
const { characters, activeIndex } = action.payload;
|
||||
state.characters = characters.map(c => c.rsn);
|
||||
state.characterIds = {};
|
||||
characters.forEach(c => {
|
||||
state.characterIds[c.rsn] = c.id;
|
||||
});
|
||||
state.activeCharacter = activeIndex ?? 0;
|
||||
},
|
||||
// Set server ID for a character
|
||||
setCharacterId: (state, action) => {
|
||||
state.characterIds[action.payload.rsn] = action.payload.id;
|
||||
},
|
||||
updateHiscores: (state, action) => {
|
||||
switch (action.payload.type) {
|
||||
case 'LOADING':
|
||||
@@ -128,6 +155,6 @@ export function reset(props) {
|
||||
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.CHARACTER, 'character');
|
||||
}
|
||||
|
||||
export const { updateHiscores } = characterSlice.actions;
|
||||
export const { updateHiscores, syncFromServer, setCharacterId } = characterSlice.actions;
|
||||
|
||||
export default characterSlice.reducer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CURRENT_VERSION = 2;
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
export const HISCORES_TTL = 1800000; // 30 min in ms
|
||||
|
||||
@@ -13,5 +13,7 @@ export const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
activeCharacter: 0,
|
||||
characters: [],
|
||||
// Map of RSN -> server character ID (for sync)
|
||||
characterIds: {},
|
||||
hiscoresCache: INITIAL_HISCORES_STATE,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CURRENT_VERSION } from './constants';
|
||||
|
||||
const versionUpdaters = {
|
||||
2: updateToV2,
|
||||
3: updateToV3,
|
||||
};
|
||||
|
||||
export default function updateCharacterVersion(state) {
|
||||
@@ -27,3 +28,12 @@ function updateToV2(prevState) {
|
||||
characters: [prevState.username],
|
||||
};
|
||||
}
|
||||
|
||||
function updateToV3(prevState) {
|
||||
// V3 adds characterIds map for server sync
|
||||
return {
|
||||
...prevState,
|
||||
version: 3,
|
||||
characterIds: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -947,6 +947,16 @@ body:is(.dark *) {
|
||||
border-color: rgb(113 113 122 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-error {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(220 38 38 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.border-error:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
|
||||
}
|
||||
|
||||
.shadow-primary {
|
||||
--tw-shadow-color: rgb(0 0 0 / 0.1);
|
||||
--tw-shadow: var(--tw-shadow-colored);
|
||||
@@ -2742,6 +2752,9 @@ select[multiple]:focus option:checked {
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.mb-auto {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
@@ -2976,6 +2989,9 @@ select[multiple]:focus option:checked {
|
||||
.max-w-5xl {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.max-w-6xl {
|
||||
max-width: 72rem;
|
||||
}
|
||||
.max-w-\[3\.5rem\] {
|
||||
max-width: 3.5rem;
|
||||
}
|
||||
@@ -3185,6 +3201,9 @@ select[multiple]:focus option:checked {
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -3561,6 +3580,10 @@ select[multiple]:focus option:checked {
|
||||
.tracking-widest {
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.text-black {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
.text-blue-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
|
||||
|
||||
Reference in New Issue
Block a user