0
0
Fork 0

Add the first version of the history site

This commit is contained in:
Oliver-Akins 2021-09-29 23:34:56 -06:00
parent e616466f98
commit cd2349a449
16 changed files with 1696 additions and 0 deletions

116
site/src/App.vue Normal file
View file

@ -0,0 +1,116 @@
<script setup>
import LoginView from "./views/Login.vue";
import GuildSelect from "./views/GuildSelect.vue";
import InputID from "./views/InputID.vue";
import History from "./views/History.vue";
</script>
<template>
<LoginView
class="inner-view"
v-if="state === `login`"
@change-state="state = $event"
/>
<GuildSelect
class="inner-view"
v-else-if="state === `guild-select`"
@set-guild="setGuild($event)"
@change-state="state = $event"
/>
<InputID
class="inner-view"
v-else-if="state === `id-entry`"
@set-guild="setGuild($event)"
@change-state="state = $event"
/>
<History
class="inner-view"
v-else-if="state === `view-history`"
:gid="gid"
@set-guild="setGuild($event)"
@change-state="state = $event"
/>
</template>
<script>
export default {
data() {return {
state: `login`,
gid: null,
}},
methods: {
setGuild(guild) {
this.gid = guild;
// Don't set null as a parameter
if (guild) {
let url = new URL(window.location.href);
let qs = url.searchParams;
qs.set(`gid`, guild);
window.history.replaceState(null, null, url);
}
},
},
mounted() {
let qs = new URLSearchParams(window.location.search);
if (qs.has(`gid`)) {
this.gid = qs.get(`gid`);
this.state = `view-history`;
return;
};
let hash = new URLSearchParams(window.location.hash);
if (hash.has(`access_token`)) {
// Check if the state is enabled
if (this.discord.auth.useState) {
// Assert state validity
if (sessionStorage.getItem(`qb-auth-state`) === hash.get(`state`)) {
console.info(`State compare success`);
sessionStorage.setItem(`qb-auth-token`, hash.get(`access_token`));
sessionStorage.removeItem(`qb-auth-state`);
window.location.hash = ``;
} else {
console.error(`State compare failed`);
window.location.hash = ``;
};
} else {
sessionStorage.setItem(`qb-auth-token`, hash.get(`access_token`));
};
};
if (sessionStorage.getItem(`qb-auth-token`)) {
this.state = `guild-select`;
};
},
};
</script>
<style>
@import "css/themes/dark.css";
@import "css/inputs.css";
html, body {
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 5px;
margin-bottom: 15px;
}
body {
background: var(--primary-background);
color: var(--light-text);
font-family: var(--fonts);
overflow-x: hidden;
overflow-y: auto;
}
</style>

View file

@ -0,0 +1,164 @@
<template>
<div
class="custom-select"
:tabindex="tabindex"
@blur="open = false"
>
<div
class="selected"
:class="{open: open}"
@click.stop="open = !open"
>
<span v-if="selected === null">
Select a Server
</span>
<div v-else class="guild-card">
<img
class="guild-icon"
:src="`https://cdn.discordapp.com/icons/${selected.id}/${selected.icon}.png`"
:alt="`${selected.name}'s Server Icon`"
>
{{ selected.name }}
</div>
</div>
<div
class="items"
:class="{selectHide: !open}"
>
<div
class="item"
v-for="(guild, i) of options"
:key="i"
@click.stop="handleSelect(guild)"
>
<div class="guild-card">
<img
v-if="guild.icon"
class="guild-icon"
:src="`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`"
:alt="`${guild.name}'s Server Icon`"
>
{{ guild.name }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
emits: [`setGuild`],
props: {
options: {
required: true,
type: Array,
},
defaultOption: {
required: false,
type: Number,
default: 0,
},
tabindex: {
required: false,
type: Number,
default: 0,
},
},
data() {return {
selected: null,
open: false,
}},
methods: {
handleSelect(guild) {
this.$emit(`setGuild`, guild);
this.selected = guild;
this.open = false;
},
},
mounted() {
if (this.options?.length && this.defaultOption !== null) {
this.selected = this.options[this.defaultOption];
this.$emit('setGuild', this.selected);
};
},
};
</script>
<style scoped>
.guild-card {
align-items: center;
display: flex;
padding: 5px;
}
.guild-icon {
--size: 50px;
border-radius: calc(var(--size) / 2);
height: var(--size);
margin-right: 10px;
width: var(--size);
}
.custom-select {
position: relative;
width: 100%;
text-align: left;
outline: none;
height: 47px;
line-height: 47px;
}
.selected {
background-color: var(--tertiary-background);
border-radius: 6px;
border: 1px solid var(--accent-neutral);
color: var(--light-text);
padding-left: 8px;
cursor: pointer;
user-select: none;
}
.selected.open{
border: 1px solid var(--accent-positive);
border-radius: 6px 6px 0px 0px;
}
.selected:after {
position: absolute;
content: "";
top: 22px;
right: 10px;
width: 0;
height: 0;
border: 4px solid transparent;
border-color: #fff transparent transparent transparent;
}
.items {
color: #ffffff;
border-radius: 0px 0px 6px 6px;
overflow: hidden;
border-right: 1px solid var(--accent-positive);
border-left: 1px solid var(--accent-positive);
border-bottom: 1px solid var(--accent-positive);
position: absolute;
background-color: var(--tertiary-background);
left: 0;
right: 0;
}
.item{
color: var(--light-text);
padding-left: 8px;
cursor: pointer;
user-select: none;
}
.item:hover{
background: #2b3035;
}
.selectHide {
display: none;
}
</style>

51
site/src/css/inputs.css Normal file
View file

@ -0,0 +1,51 @@
button {
background: var(--blurple);
border-color: transparent;
border-radius: var(--medium-border-radius);
border-style: solid;
border-width: 2px;
color: var(--light-text);
cursor: pointer;
font-family: var(--fonts);
font-size: larger;
margin-bottom: 5px;
outline: none;
padding: 5px 10px;
user-select: none;
}
button:hover:not(:disabled) {
border-color: var(--accent-neutral);
}
button:active:not(:disabled) {
border-color: var(--accent-positive);
}
button:disabled {
background-color: var(--blurple-faded);
color: #4D4D4D;
}
input[type="text"] {
background: var(--tertiary-background);
border-color: var(--accent-neutral);
border-radius: var(--medium-border-radius);
border-style: solid;
color: var(--light-text);
font-family: var(--fonts);
font-size: larger;
font-weight: bold;
margin-bottom: 10px;
outline: none;
padding: 5px;
}
input[type="text"]:focus,
input[type="text"]:active {
border-color: var(--accent-positive);
}
a {
color: var(--accent-neutral);
}
a:visited {
color: var(--accent-positive);
}

View file

@ -0,0 +1,54 @@
@import url('https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500&family=Electrolize&display=swap');
:root {
/* ===================================================================== */
/* Default Themes for backgrounds and accent colours */
--primary-background: #23272A;
--primary-border: unset;
--secondary-background: #2C2F33;
--secondary-border: unset;
--tertiary-background: #343A40;
--tertiary-border: unset;
/* Border indicators for better support in high contrast themes */
--large-border-radius: 10px;
--medium-border-radius: 7px;
--small-border-radius: 5px;
/* Text colours and the default font family */
--fonts: 'Electrolize', 'Chakra Petch', sans-serif;
--light-text: #E9ECEF;
--dark-text: #000000;
/* Accent colours used for smaller sections on the site */
--accent-positive: #1DB954;
--accent-neutral: #7289DA;
--accent-negative: #ED4245;
/* Scrollbar styling */
--scrollbar-background: #0f0f0f;
--scrollbar-handle: #4D4D4D;
--scrollbar-handle-hover: #5E5E5E;
/* ===================================================================== */
/* Additional Themes for the specific site */
--blurple-faded: #383e77;
/* ===================================================================== */
/* Discord branding colours */
/* Pre-Rebranding */
--og-blurple: #7289da;
--greyple: #99aab5;
--dark-but-not-black: #2c2f33;
--not-quite-black: #23272a;
/* + black and white */
/* Post-Rebranding */
--blurple: #5865f2;
--green: #57f287;
--yellow: #fee75c;
--fuchsia: #eb459e;
--red: #ed4245;
/* + black and white */
}

33
site/src/main.js Normal file
View file

@ -0,0 +1,33 @@
import { createApp } from "vue";
import App from "./App.vue";
let app = createApp(App);
app.mixin({
data() {return {
discord: {
client: {
id: `863968565353906226`,
},
auth: {
base: `https://discord.com/api/oauth2/authorize`,
scopes: [
`identify`,
`guilds`,
],
useState: true,
},
api: {
base: `https://discord.com/api/v9`,
getGuilds: `/users/@me/guilds`,
},
},
private: {
api: `http://localhost:3001`,
},
}},
methods: {},
computed: {},
})
app.mount('#app');

View file

@ -0,0 +1,133 @@
<script setup>
import Dropdown from "../components/GuildDropdown.vue";
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
</script>
<template>
<div id="guild-select">
<div class="card">
<h1>Quote Bracket</h1>
<div v-if="loading">
<h2>{{ message }}</h2>
<button
v-if="errored"
@click.stop="$emit(`change-state`, `login`)"
>
Go Back
</button>
</div>
<div v-else>
<Dropdown
:options="userGuilds"
:default-option="null"
@set-guild="selectGuild"
/>
<br>
<button
v-if="selectedGuild !== null"
@click.stop="loadHistory"
>
Load History
</button>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
data() {return {
userGuilds: [],
loading: true,
selectedGuild: null,
message: `Loading Your Servers...`,
errored: false,
}},
methods: {
loadHistory() {
this.$emit(`set-guild`, this.selectedGuild.id);
this.$emit(`change-state`, `view-history`);
},
selectGuild(guild) {
this.selectedGuild = guild;
},
},
async mounted() {
// Get the user's guilds from Discord
let token = sessionStorage.getItem(`qb-auth-token`);
try {
var response = await axios.get(
`${this.discord.api.base}${this.discord.api.getGuilds}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
);
} catch (err) {
this.message = `Error Getting Your Server List From Discord`;
this.errored = true;
return;
};
let allGuilds = response.data;
if (200 <= response.status && response.status < 300) {
// Request the guild intersection from the server
try {
response = await axios.post(
`${this.private.api}/guilds/compare`,
response.data.map(g => g.id)
);
} catch (err) {
this.message = `Error Comparing Server Lists`;
this.errored = true;
return;
};
let intersectedGuilds = response.data;
if (intersectedGuilds.length === 1) {
this.$emit(`set-guild`, intersectedGuilds[0]);
this.$emit(`change-state`, `view-history`);
return;
};
// Find all the guild objects that were returned from the private API
for (var guild of allGuilds) {
if (intersectedGuilds.includes(guild.id)) {
this.userGuilds.push(guild);
};
};
this.loading = false;
}
},
};
</script>
<style scoped>
#guild-select {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
width: 100vw;
}
.card {
background: var(--secondary-background);
border-radius: 7px;
padding: 15px;
text-align: center;
width: 33%;
}
@media only screen and (max-width: 768px) {
.card {
width: 90%;
}
}
</style>

235
site/src/views/History.vue Normal file
View file

@ -0,0 +1,235 @@
<script setup>
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
</script>
<template>
<div id="history">
<div class="card">
<h2>Quote Bracket History</h2>
<div
v-if="bracket"
>
<div class="flex-row controls">
<button
class="no-mobile"
:disabled="isFirst"
@click.stop="newestBracket"
>
Newest
</button>
<button
:disabled="isFirst"
@click.stop="newerBracket"
>
Newer
</button>
<span>{{ date }}</span>
<button
:disabled="isLast"
@click.stop="olderBracket"
>
Older
</button>
<button
class="no-mobile"
:disabled="isLast"
@click.stop="oldestBracket"
>
Oldest
</button>
</div>
<div class="quotes">
<div
class="quote"
v-for="(quote, i) in bracket.quotes"
:key="i"
:class="quoteClasses(i)"
>
<span class="text">
{{ quote.text }}
</span>
<div class="metadata flex-row">
<span class="votes">Votes: {{ quote.votes }}</span>
<span
class="streak"
v-if="quote.win_streak > 0"
>
Win Streak: {{ quote.win_streak }}
</span>
</div>
</div>
</div>
</div>
<div v-else>
<p>
There was an error loading the quote bracket information. Please
wait a minute and then try again. If the issue continues, let
Oliver know.
</p>
</div>
<button
@click.stop="resetGuild"
>
Pick a Different Server
</button>
</div>
</div>
</template>
<script>
import axios from "axios";
export default {
props: {
gid: {
required: true,
}
},
data() {return {
history: [],
page: 0,
}},
computed: {
bracket() {
return this.history[this.page];
},
isFirst() {
return this.page === this.history.length - 1;
},
isLast() {
return this.page === 0;
},
date() {
let date = new Date(this.bracket.date);
return date.toLocaleString();
},
winners() {
let max = -1;
let quotes = [];
for (var qi in this.bracket.quotes) {
let q = this.bracket.quotes[qi];
if (q.votes === max) {
quotes.push(qi);
} else if (q.votes > max) {
max = q.votes;
quotes = [qi];
};
};
return quotes;
},
},
methods: {
quoteClasses(qi) {
if (this.winners.includes(`${qi}`)) {
return [`winner`];
};
return [];
},
updateQuery() {
let url = new URL(window.location.href);
let qs = url.searchParams;
qs.set(`page`, this.page);
history.replaceState(null, null, url);
},
oldestBracket() {
this.page = 0;
this.updateQuery();
},
olderBracket() {
this.page--;
this.updateQuery();
},
newerBracket() {
this.page++;
this.updateQuery();
},
newestBracket() {
this.page = this.history.length - 1;
this.updateQuery();
},
resetGuild() {
history.replaceState(null, null, `/`);
this.$emit(`set-guild`, null);
this.$emit(`change-state`, `login`);
},
},
async mounted() {
let qs = new URLSearchParams(window.location.search);
try {
let r = await axios.get(`${this.private.api}/${this.gid}/history`);
if (r.status === 200) {
this.history = r.data;
};
if (qs.has(`page`)) {
this.page = parseInt(qs.get(`page`));
this.updateQuery();
} else {
this.newestBracket();
};
} catch (err) {};
},
}
</script>
<style scoped>
#history {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
width: 100vw;
}
.card {
background: var(--secondary-background);
border-radius: 7px;
padding: 15px;
text-align: center;
width: 75%;
}
.flex-row {
align-items: center;
display: flex;
justify-content: space-evenly;
}
.quote {
background: var(--tertiary-background);
border-radius: var(--large-border-radius);
margin-bottom: 10px;
padding: 10px;
border-width: 1px;
border-style: solid;
border-color: var(--tertiary-background);
}
.quote.winner {
border-color: gold;
}
.controls {
margin-bottom: 15px;
}
.quote .metadata {
margin-top: 10px;
}
@media only screen and (max-width: 768px) {
.no-mobile {
display: none;
}
.card {
width: 90%;
}
}
</style>

113
site/src/views/InputID.vue Normal file
View file

@ -0,0 +1,113 @@
<script setup>
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
</script>
<template>
<div id="guild-id-input">
<div class="card">
<h1>Quote Bracket</h1>
<div>
<p>
Enter a server ID in the box below in order to load the
quote bracket history. If you need help finding out how to
get the server's ID, you can read Discord's help article
about getting IDs here:
<a href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-">
Where can I find my User/Server/Message ID?
</a>
</p>
<div class="flex-row">
<input
type="text"
name="Server ID"
id="server-id"
v-model="guildID"
>
</div>
<div
v-if="hasError"
>
The server ID you entered is invalid, please make sure that
you entered it correctly.
</div>
<div class="flex-row">
<button
@click.stop="goBack"
>
Cancel
</button>
<button
v-if="guildID && !hasError"
@click.stop="loadHistory"
>
Load History
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
data() {return {
guildID: ``,
}},
computed: {
hasError() {
return this.guildID.match(/[^0-9]/g) != null;
},
},
methods: {
goBack() {
this.$emit(`change-state`, `login`);
},
loadHistory() {
this.$emit(`set-guild`, this.guildID);
this.$emit(`change-state`, `view-history`);
},
},
async mounted() {},
};
</script>
<style scoped>
#guild-id-input {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
width: 100vw;
}
.card {
background: var(--secondary-background);
border-radius: 7px;
padding: 15px;
text-align: center;
width: 33%;
}
.flex-row {
align-items: center;
display: flex;
justify-content: space-evenly;
}
button {
margin-top: 10px;
}
#server-id {
text-align: center;
flex-grow: 1;
}
@media only screen and (max-width: 768px) {
.card {
width: 90%;
}
}
</style>

82
site/src/views/Login.vue Normal file
View file

@ -0,0 +1,82 @@
<script setup>
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup
</script>
<template>
<div id="login-view">
<div class="card">
<h1>Quote Bracket</h1>
<button
@click.stop="handleDiscordLogin"
class="discord-login"
>
Login With Discord
</button>
<br>
<button
@click.stop="handleGuildID"
class="server-id-login"
>
Enter a Server ID
</button>
</div>
</div>
</template>
<script>
export default {
computed: {},
methods: {
handleDiscordLogin() {
let qs = new URLSearchParams();
qs.set(`response_type`, `token`);
qs.set(`client_id`, this.discord.client.id);
qs.set(`scope`, this.discord.auth.scopes.join(` `));
// Construct the redirect URI for Discord
qs.set(
`redirect_uri`,
window.location.origin + window.location.pathname
);
// Add state for verifying the response redirect from Discord
if (this.discord.auth.useState) {
let state = Math.random().toString(36).substring(2, 15)
+ Math.random().toString(36).substring(2, 15);
sessionStorage.setItem(`qb-auth-state`, state);
qs.set(`state`, state);
};
window.location.href = `${this.discord.auth.base}?${qs.toString()}`;
},
handleGuildID() {
this.$emit(`change-state`, `id-entry`)
},
},
};
</script>
<style scoped>
#login-view {
align-items: center;
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
width: 100vw;
}
.card {
background: var(--secondary-background);
border-radius: 7px;
padding: 15px;
text-align: center;
}
@media only screen and (max-width: 768px) {
.card {
width: 90%;
}
}
</style>