@ -1,70 +1,7 @@
|
||||
# Getting Started with Create React App
|
||||
# Prittee
|
||||

|
||||
Prittee is an unofficial, experimental alternate front end for Pithee, the site Jacksfilms recent launched. I made Prittee because I noticed some small UI/UX issues I thought could be fixed to improve the user experience of the site (most notably, the time it takes to load more posts after voting for one).
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
Prittee has some limitations due to it being an unofficial site, such as the fact that you can only be logged in for an hour at a time, so I don't expect this to replace the official Pithee site for people's pith-ing needs. This was mostly made as an exercise for me.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
The site is made in React, using React Router v6.
|
||||
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 253 KiB |
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 7.1 KiB |
@ -1,38 +1,21 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
#winnerPostAuthor {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.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;
|
||||
.prevent-select {
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-ms-user-select: none; /* IE 10 and IE 11 */
|
||||
user-select: none; /* Standard syntax */
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@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 */
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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??"
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundSize: {
|
||||
'size-200': '200% 200%',
|
||||
},
|
||||
backgroundPosition: {
|
||||
'pos-0': '0% 0%',
|
||||
'pos-100': '100% 100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||