mirror of
https://github.com/Sheldan/abstracto.git
synced 2026-01-01 07:27:35 +00:00
[AB-xxx] initial experience leaderboard version
This commit is contained in:
23
ui/experience-tracking/.gitignore
vendored
Normal file
23
ui/experience-tracking/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
16654
ui/experience-tracking/package-lock.json
generated
Normal file
16654
ui/experience-tracking/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
ui/experience-tracking/package.json
Normal file
48
ui/experience-tracking/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "experience-tracking",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/experience/leaderboards",
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.91",
|
||||
"@types/react": "^18.2.69",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
19
ui/experience-tracking/public/index.html
Normal file
19
ui/experience-tracking/public/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Leaderboard for experience"
|
||||
/>
|
||||
<script>window.serverId={{ serverId }}n</script>
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Experience leaderboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
8
ui/experience-tracking/public/manifest.json
Normal file
8
ui/experience-tracking/public/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"short_name": "Experience leaderboard",
|
||||
"name": "Leaderboard showing experience gathered",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
ui/experience-tracking/public/robots.txt
Normal file
3
ui/experience-tracking/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
2
ui/experience-tracking/src/App.css
Normal file
2
ui/experience-tracking/src/App.css
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
17
ui/experience-tracking/src/App.tsx
Normal file
17
ui/experience-tracking/src/App.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import {Leaderboard} from "./components/Leaderboard";
|
||||
|
||||
function App() {
|
||||
// @ts-ignore
|
||||
const serverId: bigint = window.serverId
|
||||
return (
|
||||
<>
|
||||
<div className="bg-slate-700 bg-cover min-h-screen">
|
||||
<Leaderboard serverId={serverId}/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
8
ui/experience-tracking/src/components/ErrorDisplay.tsx
Normal file
8
ui/experience-tracking/src/components/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
export const ErrorDisplay = () => {
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-4 text-4xl font-extrabold leading-none tracking-tight md:text-5xl lg:text-6xl text-white">Failed to load leaderboard</h1>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {ExperienceConfig, ExperienceRole} from "../data/leaderboard";
|
||||
import {RoleDisplay} from "./RoleDisplay";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export const ExperienceConfigDisplay = ({serverId}: { serverId: bigint }) => {
|
||||
|
||||
const [roles, setRoles] = useState<ExperienceRole[]>([])
|
||||
const [hasError, setError] = useState(false)
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const configResponse = await fetch(`/experience/v1/leaderboards/${serverId}/config`)
|
||||
let configObj: ExperienceConfig = await configResponse.json();
|
||||
const roles = configObj.roles;
|
||||
setRoles(roles)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(()=> {
|
||||
loadConfig()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasError ?
|
||||
<div className="py-10">
|
||||
<h2 className="text-4xl font-extrabold leading-none tracking-tight text-white">Role
|
||||
config</h2>
|
||||
<table className="w-full text-gray-400">
|
||||
<thead
|
||||
className="text-xs uppercase bg-gray-700 text-gray-400">
|
||||
<tr>
|
||||
<th className="px-6 py-3 w-1/2">Role</th>
|
||||
<th className="px-6 py-3 w-1/8">Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roles.map(role =>
|
||||
<tr key={role.role.id} className="border-b bg-gray-800 border-gray-700">
|
||||
<td className="px-6 py-4">
|
||||
<RoleDisplay role={role.role}/>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{role.level}
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
{roles.length === 0 ?
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<span className="text-gray-400">No roles</span>
|
||||
</div> : ''}
|
||||
</div>
|
||||
: ''}
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
ui/experience-tracking/src/components/Leaderboard.tsx
Normal file
112
ui/experience-tracking/src/components/Leaderboard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {LeaderboardEntry} from "./LeaderboardEntry";
|
||||
import {useEffect, useState} from "react";
|
||||
import {ExperienceMember, GuildInfo} from "../data/leaderboard";
|
||||
import {ExperienceConfigDisplay} from "./ExperienceConfigDisplay";
|
||||
import {ErrorDisplay} from "./ErrorDisplay";
|
||||
|
||||
export function Leaderboard({serverId}: { serverId: bigint }) {
|
||||
|
||||
const pageSize = 25;
|
||||
|
||||
const [members, setMembers] = useState<ExperienceMember[]>([])
|
||||
const [memberCount, setMemberCount] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [hasError, setError] = useState(false)
|
||||
const [guildInfo, setGuildInfo] = useState<GuildInfo>({} as GuildInfo)
|
||||
|
||||
async function loadLeaderboard(page: number, size: number) {
|
||||
try {
|
||||
const leaderboardResponse = await fetch(`/experience/v1/leaderboards/${serverId}?page=${page}&size=${size}`)
|
||||
const leaderboardJson = await leaderboardResponse.json()
|
||||
const loadedMembers: Array<ExperienceMember> = leaderboardJson.content;
|
||||
setMemberCount(memberCount + loadedMembers.length)
|
||||
setHasMore(!leaderboardJson.last)
|
||||
setPageCount(page)
|
||||
setMembers(members.concat(loadedMembers))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGuildInfo() {
|
||||
try {
|
||||
const guildInfoResponse = await fetch(`/servers/v1/${serverId}/info`)
|
||||
const guildInfoJson: GuildInfo= await guildInfoResponse.json()
|
||||
setGuildInfo(guildInfoJson)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(()=> {
|
||||
if(memberCount === 0) {
|
||||
loadLeaderboard(0, pageSize)
|
||||
}
|
||||
loadGuildInfo()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},[])
|
||||
|
||||
function loadMore() {
|
||||
loadLeaderboard(pageCount + 1, pageSize)
|
||||
}
|
||||
let loadMoreButton = <button className="w-full bg-gray-500 hover:bg-gray-700 text-white" onClick={loadMore}>Load more</button>;
|
||||
return (
|
||||
<>
|
||||
{!hasError ?
|
||||
<>
|
||||
<div className="relative font-[sans-serif] before:absolute before:w-full before:h-full before:inset-0 before:bg-black before:opacity-50 before:z-10 h-48">
|
||||
{guildInfo.bannerUrl !== null ? <img src={guildInfo.bannerUrl + "?size=4096"}
|
||||
alt="Banner"
|
||||
className="absolute inset-0 w-full h-full object-cover"/> : ''}
|
||||
<div
|
||||
className="min-h-[150px] relative z-50 h-full max-w-6xl mx-auto flex flex-row justify-center items-center text-center text-white p-6">
|
||||
{guildInfo.iconUrl !== null ? <img
|
||||
src={guildInfo.iconUrl + "?size=512"}
|
||||
alt="Icon"
|
||||
className="w-24"/>
|
||||
: ''}
|
||||
<h1 className="text-4xl font-extrabold leading-none tracking-tight md:text-5xl lg:text-6xl text-white">{'Leaderboard for ' + guildInfo.name}</h1>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="text-sm text-left w-3/4 ">
|
||||
<table className="w-full text-gray-400">
|
||||
<thead
|
||||
className="text-xs uppercase bg-gray-700 text-gray-400">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 w-1/3">
|
||||
Member
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-1/6 text-center">
|
||||
Experience
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-1/6 text-center">
|
||||
Messages
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-1/6 text-center">
|
||||
Level
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-1/3 text-center">
|
||||
Role
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map(member => <LeaderboardEntry key={member.id} member={member}/>)}
|
||||
</tbody>
|
||||
</table>
|
||||
{hasMore ? loadMoreButton : ''}
|
||||
</div>
|
||||
<div className="w-1/4 px-3">
|
||||
<ExperienceConfigDisplay serverId={serverId}/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
: <ErrorDisplay/>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
37
ui/experience-tracking/src/components/LeaderboardEntry.tsx
Normal file
37
ui/experience-tracking/src/components/LeaderboardEntry.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import {ExperienceMember} from "../data/leaderboard";
|
||||
import {RoleDisplay} from "./RoleDisplay";
|
||||
import createStyle from "../utils/styleUtils";
|
||||
|
||||
export const LeaderboardEntry = ({member}: { member: ExperienceMember }) => {
|
||||
const userHasRole = member.role !== null;
|
||||
const memberExists = member.member !== null;
|
||||
const nameColor = userHasRole ? createStyle(member.role!) : ''
|
||||
let memberDisplay = memberExists ? <>
|
||||
<img alt={member.member!.name} src={member.member!.avatarUrl}
|
||||
className="object-contain h-16 w-16 rounded-full"/>
|
||||
<span className="align-middle" style={{color: nameColor}}>{member.member!.name}</span>
|
||||
</> : <>{member.id}</>;
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b bg-gray-800 border-gray-700">
|
||||
<td
|
||||
className="px-2 py-4 font-medium whitespace-nowrap text-white flex items-center gap-3">
|
||||
{memberDisplay}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{member.experience.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{member.messages.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{member.level.toString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{userHasRole ? <RoleDisplay role={member.role!}/> : 'No role'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
ui/experience-tracking/src/components/RoleDisplay.tsx
Normal file
12
ui/experience-tracking/src/components/RoleDisplay.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Role} from "../data/leaderboard";
|
||||
import createStyle from "../utils/styleUtils";
|
||||
|
||||
export const RoleDisplay = ({role}: { role: Role | null }) => {
|
||||
const roleColor = createStyle(role);
|
||||
let roleDisplay = role !== null && role.name !== null ? <span style={{ color: roleColor}}>{role.name}</span> : <>Deleted role {role !== null ? role!.id : ''}</>
|
||||
return (
|
||||
<>
|
||||
{roleDisplay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
ui/experience-tracking/src/data/leaderboard.tsx
Normal file
38
ui/experience-tracking/src/data/leaderboard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface ExperienceMember {
|
||||
experience: bigint;
|
||||
id: bigint;
|
||||
level: number;
|
||||
messages: bigint;
|
||||
member: Member | null;
|
||||
role: Role | null;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
id: bigint;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
r: number | null;
|
||||
g: number | null;
|
||||
b: number | null;
|
||||
name: string | null;
|
||||
id: number;
|
||||
}
|
||||
|
||||
export interface ExperienceRole {
|
||||
role: Role;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface ExperienceConfig {
|
||||
roles: Array<ExperienceRole>;
|
||||
}
|
||||
|
||||
export interface GuildInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
iconUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
}
|
||||
12
ui/experience-tracking/src/index.css
Normal file
12
ui/experience-tracking/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
19
ui/experience-tracking/src/index.tsx
Normal file
19
ui/experience-tracking/src/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={process.env.PUBLIC_URL}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
reportWebVitals();
|
||||
1
ui/experience-tracking/src/react-app-env.d.ts
vendored
Normal file
1
ui/experience-tracking/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
||||
15
ui/experience-tracking/src/reportWebVitals.ts
Normal file
15
ui/experience-tracking/src/reportWebVitals.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
6
ui/experience-tracking/src/utils/styleUtils.ts
Normal file
6
ui/experience-tracking/src/utils/styleUtils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function createStyle(obj: any) {
|
||||
if(obj == null || obj.r == null) {
|
||||
return ''
|
||||
}
|
||||
return `#${obj.r.toString(16).padStart(2, '0')}${obj.g.toString(16).padStart(2, '0')}${obj.b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
13
ui/experience-tracking/tailwind.config.js
Normal file
13
ui/experience-tracking/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
mode: 'jit',
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
26
ui/experience-tracking/tsconfig.json
Normal file
26
ui/experience-tracking/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user