First Commit

This commit is contained in:
Gabe Farrell 2024-04-02 08:17:59 +00:00
parent d198a9ed8a
commit 229c967128
35 changed files with 1377 additions and 136 deletions

View file

@ -1,38 +1,21 @@
.App {
text-align: center;
#winnerPostAuthor {
font-size: 14px;
}
.App-logo {
height: 40vmin;
pointer-events: none;
.prevent-select {
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none; /* Standard syntax */
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}

View file

@ -1,24 +1,29 @@
import logo from './logo.svg';
import './App.css';
import { Box, Container } from '@mui/material';
import { Outlet } from 'react-router-dom';
import Header from './Components/Header';
import Footer from './Components/Footer';
// TODO:
// - Leaderboard past #25 (pagination)
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
<Container>
<Box
display="flex"
flexDirection="column"
alignItems="center"
className='h-screen'
>
Learn React
</a>
</header>
</div>
<Header />
<Box width={480} id='gameContainer' className='grow'>
<Outlet />
</Box>
<Footer/>
</Box>
</Container>
);
}

View file

@ -0,0 +1,38 @@
import { Box } from "@mui/material";
import { useState, useEffect } from "react";
export default function Countdown({ until, onEnd }) {
const t1 = new Date()
const t2 = new Date(until) // 10 minutes after win time
const msUntil = (t2.getTime()+10*60000) - t1.getTime();
const secondsUntil = Math.floor(msUntil / 1000)
const [seconds, setSeconds] = useState(secondsUntil)
useEffect(() => {
var interval = setInterval(() => {
setSeconds(s => s-1)
if (seconds < 1) {
clearInterval(interval)
onEnd()
}
}, 1000);
return function cleanup() {
clearInterval(interval);
};
}, [seconds])
const formatTime = (time) => {
if (time <= 0) {
return '0:00'
}
let m = Math.floor(time / 60)
let s = time - (m * 60)
let lead = s < 10 ? '0' : ''
return `${m}:${lead+s}`
}
return (
<Box className='prevent-select'>
{formatTime(seconds)}
</Box>
)
}

10
src/Components/Footer.jsx Normal file
View file

@ -0,0 +1,10 @@
import { Container, Box } from "@mui/material";
export default function Footer() {
return (
<Box width={600} className='flex flex-row justify-around text-sm text-neutral-500 border-t-2 border-neutral-600 p-4 mt-4'>
<a className="hover:text-neutral-300" href="https://github.com/gabehf/Prittee" target="_blank">View the source on GitHub</a>
<a className="hover:text-neutral-300" href="https://forms.gle/a8BkH6yUDnzLJD938" target="_blank">Report a bug</a>
</Box>
)
}

29
src/Components/Header.jsx Normal file
View file

@ -0,0 +1,29 @@
import { Box, Container } from "@mui/material"
import { NavLink } from "react-router-dom";
import { AccountBoxOutlined, PostAdd, LeaderboardOutlined } from '@mui/icons-material';
import { useNavigate } from "react-router-dom";
export default function Header() {
const navigate = useNavigate()
return (
<Container className='mt-6 mb-4'>
<Box id="header" className="mb-2 text-center hover:cursor-pointer" onClick={() => navigate('/')}>
<h1 className='text-4xl font-bold'>Prittee</h1>
<p className="text-md text-slate-300">An <span className='text-sky-400 italic'>unofficial</span> alternate front end for Pithee</p>
</Box>
<Box className='flex flex-row justify-between m-auto mb-6' width={125}>
<Box>
<NavLink to='/me'><AccountBoxOutlined /></NavLink>
</Box>
<Box>
<NavLink to='/leaderboard'><LeaderboardOutlined /></NavLink>
</Box>
<Box>
<NavLink to='/post'><PostAdd /></NavLink>
</Box>
</Box>
</Container>
)
}

View file

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
export default function Leaderboard({ users }) {
let i = 0
return (
<Box maxHeight={500} className='overflow-y-auto no-scrollbar'>
{users.map((user) => {
i++
return (
<Box className='flex flex-row justify-between border-b-2 p-2 mb-3'>
<Box className='flex flex-row gap-8'>
<Box>{i}.</Box>
<img src={user.avatar_url} alt={user.player_name} width={35} height={35}/>
</Box>
<Box>{user.player_name}</Box>
<Box className='text-end'>{user.total_points}</Box>
</Box>
)})}
</Box>
)
}

View file

@ -0,0 +1,9 @@
import { LinearProgress, Box } from "@mui/material";
export default function LeaderboardSkeleton() {
return (
<Box maxHeight={500} className='overflow-y-auto no-scrollbar'>
<LinearProgress color="inherit" />
</Box>
)
}

58
src/Components/Post.jsx Normal file
View file

@ -0,0 +1,58 @@
import { Box } from "@mui/material";
import { LinearProgress } from '@mui/material'
import { Key, Star } from "@mui/icons-material";
export default function Post({ text, tier, index, id, onClick }) {
const renderStars = () => {
const arr = []
for (let i = 0; i < tier; i++) {
arr.push(
<Star fontSize='small' />
)
}
return arr
}
if (text === '') {
return (
<Box className="
transition-all
duration-300
bg-gradient-to-tl
from-indigo-900
via-indigo-600
to-purple-500
bg-size-200
bg-pos-0
hover:bg-pos-100
hover:cursor-pointer
p-8" height={225}>
<LinearProgress sx={{
backgroundColor: 'white',
'& .MuiLinearProgress-bar': {
backgroundColor: 'indigo'
}
}} className="opacity-25"/>
</Box>
)
} else {
return (
<Box className="
transition-all
duration-300
bg-gradient-to-tl
from-indigo-900
via-indigo-600
to-purple-500
bg-size-200
bg-pos-0
hover:bg-pos-100
hover:cursor-pointer
p-8" height={225} onClick={() => onClick({post_text: text, index: index, tier: tier, id: id})}>
<Box sx={{marginTop: -2, marginBottom: 1, marginLeft: -2, display: 'flex'}}>
{renderStars()}
</Box>
<p className='text-clip overflow-hidden prevent-select'>{text}</p>
</Box>
)
}
}

164
src/Components/PostGrid.jsx Normal file
View file

@ -0,0 +1,164 @@
import { Grid } from '@mui/material';
import Post from './Post';
import { declareVictor, fetchPosts, pushPostScore } from '../utils'
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Queue from '../queue'
import { getFullContestants, lenContestants, rmContestant } from '../contestants';
const postsSkeleton = [
{
post_text: '',
index: 0,
id: '1',
tier: 0,
},
{
post_text: '',
index: 1,
id: '2',
tier: 0,
},
{
post_text: '',
index: 2,
id: '3',
tier: 0,
},
{
post_text: '',
index: 3,
id: '4',
tier: 0,
},
]
const PostQueue = new Queue()
export default function PostGrid() {
const navigate = useNavigate()
const [selected, setSelected] = useState(null)
const [posts, setPosts] = useState(postsSkeleton)
const [loading, setLoading] = useState(false)
// every time data state changes i.e. every time a user clicks on post,
// update posts with their selection
// note: this also will load the initial posts
useEffect(()=> {
//
// rebuild post array with kept post
// render new array
updatePosts(selected)
}, [selected])
// handler passed to Post children to let us know when one has been selected
const selectPost = (post) => {
post.tier += 1
if (post.tier == 4) { // triggers on a showdown winner
declareVictor(post)
setSelected(null)
// return early because we already handled selecting a showdown winner
return
}
// one of these if statements will trigger when a
// post has reached the end of its ranking,
// or if it has reached max-tier respectively
if (selected != null && post.id != selected.id && selected.tier != 3) {
pushPostScore(selected)
}
if (post.tier == 3){
pushPostScore(post)
}
// triggers when it's time for a showdown
if (lenContestants() >= 4) {
setPosts(getFullContestants())
// return early so as to not trigger the updatePosts logic and
// instead render the showdown
return
}
setSelected(post)
}
// handles updating the post state
const refreshPosts = (keep) => {
// this line prevents trying to refresh posts on mount, when
// no posts have actually been loaded yet
if (PostQueue.size() < 4) { return }
const arr = []
for (var i = 0; i < 4; i++) {
if (keep != null && keep.index == i && keep.tier < 3) {
arr.push(keep)
} else {
let p = PostQueue.pop()
arr.push(
{
post_text: p.post_text,
index: i,
id: p.post_id,
tier: 0,
}
)
}
}
setPosts(arr)
}
// handles refilling the queue when needed, then calls refreshPosts
const updatePosts = (keep) => {
console.log(`loading: ${loading}`)
// if the queue is really low, we want to stop the user from making any more
// selections to let the queue reload
if (PostQueue.size() <= 4) {
if (loading) {
console.log('user is clicking too fast!')
// if we are already loading in new posts and the user
// is basically spam clicking, just return right away to
// prevent it from requesting more posts
return
}
setLoading(true)
console.log('loading last-second posts...')
fetchPosts().then((r) => {
if (r == null) {
} else {
r.forEach(npost => {
PostQueue.push(npost)
});
refreshPosts(keep)
setLoading(false)
}
})
} else if (PostQueue.size() <= 11 && !loading) {
// somewhat low queue -> fetch more posts in the background
setLoading(true)
console.log('loading more posts...')
fetchPosts().then((r) => {
if (r == null) {
alert("Your token has expired. Please re-enter your information to keep using Prittee.")
navigate('/login')
} else {
r.forEach(npost => {
PostQueue.push(npost)
});
setLoading(false)
}
})
refreshPosts(keep)
} else {
// full queue -> keep going as normal
refreshPosts(keep)
}
}
return (
<Grid container spacing={1}>
{posts.map((p) => {
return(
<Grid item xs={6} key={'post-grid-'+p.index}>
<Post text={p.post_text} key={p.id} id={p.id} tier={p.tier} index={p.index} onClick={selectPost}/>
</Grid>
)})}
</Grid>
)
}

View file

@ -0,0 +1,12 @@
import { Box } from "@mui/material";
export default function PreviousWinner({ post }) {
let dateString = new Date(post.won_at).toLocaleString()
return (
<Box className='border-b-2 border-white p-4'>
<Box>{post.post_text}</Box>
<Box className='text-sm'>by {post.player_name}</Box>
<Box className='text-sm'>Won: {dateString}</Box>
</Box>
)
}

View file

@ -0,0 +1,14 @@
import { Box } from "@mui/material";
export default function UserPost({ post }) {
let dateString = new Date(post.created_at).toLocaleString()
return (
<Box className='border-b-2 border-white p-4'>
<Box>{post.post_text}</Box>
<Box className='text-sm'>Points: {post.points}</Box>
<Box className='text-sm'>Posted: {dateString}</Box>
{/* I actually don't know if won_at is the right key because i haven't won yet LOL */}
{post.won_at ? <Box className='text-sm'>Posted: {dateString}</Box> : <></>}
</Box>
)
}

View file

@ -0,0 +1,15 @@
import { Box } from "@mui/material";
import PreviousWinner from "./PreviousWinner";
export default function WinArchive({ data }) {
return(
<Box id="lastWinner" className="
p-4
pt-0
mb-6
overflow-y-auto
no-scrollbar" height={434}>
{data.map((post) => <PreviousWinner post={post}/>)}
</Box>
)
}

33
src/Pages/CreatePost.jsx Normal file
View file

@ -0,0 +1,33 @@
import { TextField } from '@mui/material'
import Box from '@mui/material/Box'
import LoadingButton from '@mui/lab/LoadingButton'
import { useState } from 'react'
import { insertPost } from '../utils'
import { useNavigate } from 'react-router-dom'
export default function CreatePost() {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const submitHandler = () => {
if (text.length > 80) {
return
}
setLoading(true)
insertPost(text)
.then(() => {
navigate('/')
setLoading(false)
})
}
return (
<Box className='flex flex-col align-end m-auto' width={300}>
<TextField color={text.length > 80 ? 'error' : 'primary'} label="Make a post" multiline rows={3} margin='normal' onChange={(e) => setText(e.target.value)}/>
<div className={text.length > 80 ? 'text-red-400' : 'text-neutral-300'}>{text.length}/80</div>
<LoadingButton loading={loading} className='grow-0' onClick={submitHandler}>Submit</LoadingButton>
</Box>
)
}
//i saw a bank that offered "24 hour service" and I thought "it takes that long??"

52
src/Pages/Index.jsx Normal file
View file

@ -0,0 +1,52 @@
import { Box } from '@mui/material';
import PostGrid from '../Components/PostGrid'
import { useLoaderData } from 'react-router-dom';
import Countdown from '../Components/Countdown';
import { useState } from 'react';
import { loadCurrentWinner } from '../utils';
import WinArchive from '../Components/WinArchive';
export default function Index({ cw, wa }) {
const data = useLoaderData()
const [winArchive, setWinArchive] = useState(data[0])
const [currentWinner, setCurrentWinner] = useState(data[1][0]) // why is the current winner api response an array??
const [winnerTime, setWinnerTime] = useState(new Date(currentWinner.won_at)) // why is the current winner api response an array??
const [showWinArchive, setShowWinArchive] = useState(false)
const reloadWinner = async () => {
console.log('loading new winner')
loadCurrentWinner().then(r => {
setCurrentWinner(r[0])
setWinnerTime(new Date(currentWinner.won_at))
})
}
const toggleWinArchive = () => {
setShowWinArchive(s => !s)
}
return (
<>
<Box id="lastWinner" className="
bg-gradient-to-tl
transition-all
duration-300
from-purple-500
via-sky-500
to-emerald-500
bg-size-200
bg-pos-0
hover:bg-pos-100
hover:cursor-pointer
p-8
mb-3" onClick={() => toggleWinArchive()}>
<p className="post-text pb-2 prevent-select" id="winnerPostText">{currentWinner.post_text}</p>
<p className="post-author prevent-select" id="winnerPostAuthor">by {currentWinner.player_name}</p>
<Countdown key={String(winnerTime)} until={winnerTime} onEnd={reloadWinner}/>
</Box>
<Box id="posts">
{showWinArchive ? <WinArchive data={winArchive}></WinArchive>: <PostGrid />}
</Box>
</>
)
}

View file

@ -0,0 +1,16 @@
import { Box } from "@mui/material";
import { useLoaderData, Await } from "react-router-dom";
import React from "react";
import Leaderboard from "../Components/Leaderboard";
import LeaderboardSkeleton from "../Components/LeaderboardSkeleton";
export default function LeaderboardPage() {
const { data } = useLoaderData()
return (
<React.Suspense fallback={<LeaderboardSkeleton />}>
<Await resolve={data}>
{(users) => <Leaderboard users={users} />}
</Await>
</React.Suspense>
)
}

69
src/Pages/Login.jsx Normal file
View file

@ -0,0 +1,69 @@
import TextField from '@mui/material/TextField'
import LoadingButton from '@mui/lab/LoadingButton'
import Box from '@mui/material/Box'
import { Container } from '@mui/material'
import Link from '@mui/material/Link'
import { useState } from 'react'
import { useNavigate } from "react-router-dom";
import { loadUser } from '../utils'
// TODO: change api key and token inputs to one input for Copy -> Request Headers
export default function Login() {
const navigate = useNavigate()
const [ token, setToken ] = useState('')
const [ apikey, setApiKey ] = useState(localStorage.getItem("apikey"))
const [ failText, setFailText ] = useState('')
const [ apiKeyErr, setApiKeyErr ] = useState(false)
const [ tokenErr, setTokenErr ] = useState(false)
const [ loading, setLoading ] = useState(false)
const onTokenChange = (e) => setToken(e.target.value);
const onApiKeyChange = (e) => setApiKey(e.target.value);
const addUserToken = async () => {
setLoading(true)
// reset error values
setTokenErr(false)
setApiKeyErr(false)
// ensure the bearer token provided is valid
localStorage.setItem('token', token)
localStorage.setItem('apikey', apikey)
loadUser().then((r) => {
setLoading(false)
if (r.success) {
navigate('/')
} else {
if (r.apikeyInvalid) {
setApiKeyErr(true)
}
if (r.tokenInvalid) {
setTokenErr(true)
}
}
})
}
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="100vh"
>
<Container>
<h1 className='text-center mb-12 text-xl'>Prittee is an <span className='text-red-400 italic'>experimental</span> alternate front end for Pithee<br/>
Before using it is recommended you <Link href='https://zircon-stoplight-fbb.notion.site/Prittee-Introduction-0e418deda00242ebb6026d074b397629'>read the introduction</Link> to learn about Prittee's limitations</h1>
<p className='text-center'>Add your Pithee api key and authorization token to start using Prittee.<br/>
You can find out how to get your info <Link href="https://zircon-stoplight-fbb.notion.site/Prittee-Introduction-0e418deda00242ebb6026d074b397629">here</Link>.</p>
<Box component="div" display="flex" flexDirection="row" alignItems="center" justifyContent="center" gap={4} className='mt-6'>
<TextField id="apikey" error={apiKeyErr} onChange={onApiKeyChange} label="API Key" variant="outlined" value={localStorage.getItem("apikey")} />
<TextField id="auth-token" error={tokenErr} helperText={failText} onChange={onTokenChange} label="Authorization Token" variant="outlined" />
<LoadingButton loading={loading} variant="text" size="large" onClick={addUserToken}>Let's Go</LoadingButton>
</Box>
</Container>
</Box>
)
}

61
src/Pages/Profile.jsx Normal file
View file

@ -0,0 +1,61 @@
import Header from "../Components/Header";
import Box from '@mui/material/Box'
import { LinearProgress, CircularProgress } from "@mui/material";
import { useLoaderData, Await } from "react-router-dom";
import React from "react";
import UserPost from "../Components/UserPost";
export default function Profile() {
const { user, posts } = useLoaderData()
const loadingFallback = () => {
return (
<Box className='flex flex-col items-center'>
<Box className='flex flex-row justify-around items-center' width={480} height={140}>
<Box maxWidth={150}>
<CircularProgress color="inherit"/>
</Box>
<Box width={100}>
<LinearProgress color="inherit" />
<br/>
<LinearProgress color="inherit" />
<br/>
<LinearProgress color="inherit" />
</Box>
</Box>
<Box maxHeight={400} width={400} className='mt-4 overflow-y-auto no-scrollbar'>
<br/>
<br/>
<LinearProgress color="inherit" />
</Box>
</Box>
)
}
return (
<React.Suspense fallback={loadingFallback()}>
<Await resolve={Promise.all([user, posts]).then(value => value)}>
{(value) => {
let [ user, posts ] = value
user = user.user
return(
<Box className='flex flex-col items-center'>
<Box className='flex flex-row justify-around items-center' width={480}>
<Box maxWidth={150}>
<img src={user.avatar_url} alt={`${user.player_name}`} />
</Box>
<Box>
<h2 className="text-2xl font-bold mb-2">{user.player_name}</h2>
<p>Total Points: {user.total_points}</p>
<p>Posts: {posts.length}</p>
</Box>
</Box>
<Box maxHeight={400} className='mt-4 overflow-y-auto no-scrollbar'>
{posts.map((post) => <UserPost post={post} />)}
</Box>
</Box>
)
}}
</Await>
</React.Suspense>
)
}

31
src/contestants.js Normal file
View file

@ -0,0 +1,31 @@
let contestants = {}
const addContestant = function(post) {
contestants[post.id] = post
}
const rmContestant = function(post) {
delete contestants[post.id]
}
const lenContestants = function() {
return Object.keys(contestants).length
}
const getContestants = function() {
return Object.keys(contestants)
}
const getFullContestants = function() {
// when fetching the full contestants, the indices need
// to be reset to prevent duplication in the React renderer
let raw = Object.values(contestants)
let i = 0
for (let post of raw) {
post.index = i
i++
}
return raw
}
export { addContestant, rmContestant, lenContestants, getContestants, getFullContestants }

View file

@ -1,13 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
background-color: #141113;
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;
color: white;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.centralContent {
margin: auto;
}

View file

@ -2,13 +2,94 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import Login from './Pages/Login'
import Profile from './Pages/Profile'
import CreatePost from './Pages/CreatePost'
import LeaderboardPage from './Pages/LeaderboardPage';
import reportWebVitals from './reportWebVitals';
import {
createBrowserRouter,
redirect,
RouterProvider,
defer
} from 'react-router-dom'
import { createTheme, ThemeProvider } from '@mui/material';
import { lightBlue, cyan, orange, green, red, purple } from '@mui/material/colors';
import CssBaseline from '@mui/material/CssBaseline';
import { loadCurrentWinner, loadLeaderboardPage, loadUser, loadUserPosts, loadWinArchive } from './utils';
import Index from './Pages/Index';
const theme = createTheme({
palette: {
mode: 'dark',
primary: cyan,
secondary: purple,
error: red,
warning: orange,
info: lightBlue,
success: green,
},
typography: {
fontSize: 16,
}
})
const router = createBrowserRouter([
{
path: '/',
element: <App />,
children: [
{
index: true,
element: <Index />,
loader: async () => {
let c = await loadCurrentWinner()
let a = await loadWinArchive()
if (!c || !a) {
return redirect('/login')
}
return new Promise((resolve) => resolve([a, c]))
},
},
{
path: '/me',
element: <Profile />,
loader: async () => {
return defer({
user: loadUser(),
posts: loadUserPosts()
})
}
},
{
path: '/post',
element: <CreatePost />
},
{
path: '/leaderboard',
element: <LeaderboardPage />,
loader: async () => {
return defer({
data: loadLeaderboardPage(1),
})
}
}
]
},
{
path: '/login',
element: <Login />,
},
])
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
<>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</>
);
// If you want to start measuring performance in your app, pass a function

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

28
src/queue.js Normal file
View file

@ -0,0 +1,28 @@
class Queue {
constructor() {
this.queue = [];
}
push(element) {
this.queue.push(element);
}
pop() {
return this.queue.shift();
}
peek() {
return this.queue[0];
}
print() {
console.log(this.queue)
}
size() {
return this.queue.length
}
}
export default Queue

218
src/utils.js Normal file
View file

@ -0,0 +1,218 @@
// NOTE: this file is poorly named
import { addContestant, getContestants, rmContestant } from "./contestants"
const userDataUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/get_player_data'
const postsUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/get_posts'
const playerPostsUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/get_player_posts'
const currentWinnerUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/current_winner?select=*'
const winnerArchiveUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/win_archive_view?select=*&limit=50'
const ygsUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/ygs'
const contestantsUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/update_contestants'
const victorUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/update_recent_showdown'
const leaderboardUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/get_paginated_leaderboard'
const insertPostUrl = 'https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/insert_post'
const tierIds = [
'1878f11f-782b-43fe-9aa3-cd34e989b174',
'629558be-c63d-4255-9bf5-1e98826fa3de',
'402261d8-4db8-4e24-8886-90dc6da6fcd1'
]
const loadUser = async function() {
return new Promise((resolve) => {
fetch('https://oqutjaxxxzzbjtyrfoka.supabase.co/rest/v1/rpc/get_player_data', {
method: 'POST', // why post?
headers: {
"APIKey": localStorage.getItem("apikey"),
"Authorization": localStorage.getItem("token")
}
}).then(r => r.json().then(data => ({status: r.status, body: data})))
.then((r) => {
// note: when an empty bearer token is provided, the server responds with 200 OK with an empty array as
// the body instead of the expected 401. This is a bug with Pithee that we need to account for!
if (r.body.length > 0) {
resolve({
success: true,
user: r.body[0]
})
} else if (r.status == 200) {
resolve({
success: false,
apikeyInvalid: false,
tokenInvalid: true,
})
} else {
resolve({
success: false,
apikeyInvalid: true,
tokenInvalid: true,
})
}
}).catch((err) => {
alert(err)
resolve({
success: false,
})
})
})
}
const loadUserPosts = async function () {
return makePitheeApiCall(playerPostsUrl, 'POST', null) // why POST?
}
const loadCurrentWinner = async function() {
return makePitheeApiCall(currentWinnerUrl, 'GET', null)
}
const loadWinArchive = async function() {
return makePitheeApiCall(winnerArchiveUrl, 'GET', null)
}
const fetchPosts = async function() {
return makePitheeApiCall(postsUrl, 'POST', JSON.stringify({post_quantity: 20}))
}
const loadLeaderboardPage = async function(page) {
return makePitheeApiCall(leaderboardUrl, 'POST', JSON.stringify({
page_number: page,
page_size: 25
}))
}
const updateContestants = async function() {
return makePitheeNoContentApiCall(contestantsUrl, 'POST', JSON.stringify({contestants: getContestants()}))
}
const showdownVictor = async function(victor_id) {
return makePitheeNoContentApiCall(victorUrl, 'POST', JSON.stringify({showdown_victor: victor_id}))
}
const pushPostScore = async function(post) {
if (post.tier < 1 || post.tier > 3) {
console.log('invalid tier value')
return
}
// just going to leave this unattended. sure I should check if the call
// failed, but I won't. Maybe I'll fix it later
makePitheeNoContentApiCall(ygsUrl, 'POST', JSON.stringify({
jacks: post.id,
films: tierIds[post.tier-1]
})) // good one, guys
// also update contestants for a three star post
// what confuses me is that the contestants for a showdown are stored
// client-side, but we still need to push them to the server. why?
// if you ask me, the contestants should be all on the server or on
// the client. IMO all on the server is better because I don't think
// showdown progress is retained upon refresh or login/logout which is silly
if (post.tier === 3) {
addContestant(post)
updateContestants()
}
}
const declareVictor = function (post) {
// upon showdown victor, another ygs call is made with the tier3 uuid,
// as well as a call to update_recent_showdown
// then, ONLY the victor is removed from the contestants
// this means that the very next post to reach tier3 triggers a showdown
// is this intended behavior? idk, but I will mirror it.
makePitheeNoContentApiCall(ygsUrl, 'POST', JSON.stringify({
jacks: post.id,
films: tierIds[2]
})) // good one, guys
showdownVictor(post.id)
rmContestant(post)
updateContestants()
}
const insertPost = function (postText) {
// the /insert_post endpoint weirdly responds with a string instead of
// JSON on success so I can't use the regular makePitheeApiCall function
return makePitheeNoContentApiCall(insertPostUrl, 'POST', JSON.stringify({
post_text: postText
}))
}
// /ygs and /update_contestants endpoints do not serve JSON responses so we need this func
// ALSO both of those endpoints return 204 OK when a null authorization is given... ???
const makePitheeNoContentApiCall = async function(url, method, body) {
return new Promise((resolve) => {
fetch(url, {
method: method,
headers: {
"apikey": localStorage.getItem('apikey'),
"authorization": localStorage.getItem('token'),
"Content-Type": "application/json"
},
body: body
})
.then((r) => {
if (r.status >= 200 && r.status <= 299) { // OK range
resolve(r.body)
} else if (r.status === 401) {
alert("Your token has expired. Please re-enter your information to keep using Prittee.")
window.location.href = '/login'
} else {
// TODO: this is little information for the client.
// the server could be responding with a 401 due to expired
// key (expected behavior) OR it could be a 500 server error and the client
// will not know the difference. This should be fixed!
resolve(null)
}
})
})
}
// the API annoyingly returns a 200 OK with an empty array response body
// when called with a null api key...
const makePitheeApiCall = async function(url, method, body) {
return new Promise((resolve) => {
fetch(url, {
method: method,
headers: {
"apikey": localStorage.getItem('apikey'),
"authorization": localStorage.getItem('token'),
"Content-Type": "application/json"
},
body: body
})
.then(r => r.json().then(data => ({status: r.status, body: data})))
.then((r) => {
if (r.status >= 200 && r.status <= 299) { // OK range
// check if empty array is given (token is null)
if (r.body.length == 0) {
alert("Your token has expired. Please re-enter your information to keep using Prittee.")
window.location.href = '/login'
}
resolve(r.body)
} else if (r.status === 401) {
alert("Your token has expired. Please re-enter your information to keep using Prittee.")
window.location.href = '/login'
} else {
// TODO: this is little information for the client.
// the server could be responding with a 401 due to expired
// key (expected behavior) OR it could be a 500 server error and the client
// will not know the difference. This should be fixed!
resolve(null)
}
})
})
}
export {
loadUser,
loadWinArchive,
loadCurrentWinner,
fetchPosts,
pushPostScore,
declareVictor,
loadUserPosts,
loadLeaderboardPage,
insertPost,
}