Add unfinished website section

This commit is contained in:
Ske 2019-04-19 18:08:27 +02:00
parent d081be838a
commit 3d6fa86518
16 changed files with 3882 additions and 11 deletions

View File

@ -25,6 +25,7 @@ services:
environment:
- "DATABASE_URI=postgres://postgres:postgres@db:5432/postgres"
- "CLIENT_ID"
- "INVITE_CLIENT_ID_OVERRIDE"
- "CLIENT_SECRET"
- "REDIRECT_URI"
db:

View File

@ -43,6 +43,17 @@ async def auth_middleware(request, handler):
request["system"] = system
return await handler(request)
@web.middleware
async def cors_middleware(request, handler):
try:
resp = await handler(request)
except web.HTTPException as r:
resp = r
resp.headers["Access-Control-Allow-Origin"] = "*"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PATCH"
resp.headers["Access-Control-Allow-Headers"] = "X-Token"
return resp
class Handlers:
@require_system
async def get_system(request):
@ -52,14 +63,14 @@ class Handlers:
system_id = request.match_info.get("system")
system = await System.get_by_hid(request["conn"], system_id)
if not system:
raise web.HTTPNotFound()
raise web.HTTPNotFound(body="null")
return web.json_response(system.to_json())
async def get_system_members(request):
system_id = request.match_info.get("system")
system = await System.get_by_hid(request["conn"], system_id)
if not system:
raise web.HTTPNotFound()
raise web.HTTPNotFound(body="null")
members = await system.get_members(request["conn"])
return web.json_response([m.to_json() for m in members])
@ -68,7 +79,7 @@ class Handlers:
system_id = request.match_info.get("system")
system = await System.get_by_hid(request["conn"], system_id)
if not system:
raise web.HTTPNotFound()
raise web.HTTPNotFound(body="null")
switches = await system.get_switches(request["conn"], 9999)
@ -85,12 +96,12 @@ class Handlers:
system = await System.get_by_hid(request["conn"], system_id)
if not system:
raise web.HTTPNotFound()
raise web.HTTPNotFound(body="null")
members, stamp = await utils.get_fronters(request["conn"], system.id)
if not stamp:
# No switch has been registered at all
raise web.HTTPNotFound()
raise web.HTTPNotFound(body="null")
data = {
"timestamp": stamp.isoformat(),
@ -117,8 +128,11 @@ class Handlers:
member_id = request.match_info.get("member")
member = await Member.get_member_by_hid(request["conn"], None, member_id)
if not member:
raise web.HTTPNotFound()
return web.json_response(member.to_json())
raise web.HTTPNotFound(body="{}")
system = await System.get_by_id(request["conn"], member.system)
member_json = member.to_json()
member_json["system"] = system.to_json()
return web.json_response(member_json)
@require_system
async def post_member(request):
@ -213,8 +227,9 @@ class Handlers:
return web.Response(text=await system.get_token(request["conn"]))
async def run():
app = web.Application(middlewares=[db_middleware, auth_middleware, error_middleware])
app = web.Application(middlewares=[cors_middleware, db_middleware, auth_middleware, error_middleware])
def cors_fallback(req):
return web.Response(headers={"Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "x-token", "Access-Control-Allow-Methods": "GET, POST, PATCH"}, status=404 if req.method != "OPTIONS" else 200)
app.add_routes([
web.get("/s", Handlers.get_system),
web.post("/s/switches", Handlers.post_switch),
@ -227,7 +242,8 @@ async def run():
web.post("/m", Handlers.post_member),
web.patch("/m/{member}", Handlers.patch_member),
web.delete("/m/{member}", Handlers.delete_member),
web.post("/discord_oauth", Handlers.discord_oauth)
web.post("/discord_oauth", Handlers.discord_oauth),
web.route("*", "/{tail:.*}", cors_fallback)
])
app["pool"] = await db.connect(
os.environ["DATABASE_URI"]

14
web/.babelrc Normal file
View File

@ -0,0 +1,14 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": [
"last 2 Chrome versions"
]
}
}
]
]
}

3
web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.cache/
dist/
node_modules/

63
web/app/API.js Normal file
View File

@ -0,0 +1,63 @@
import { EventEmitter } from "eventemitter3"
const SITE_ROOT = process.env.NODE_ENV === "production" ? "https://pluralkit.me" : "http://localhost:1234";
const API_ROOT = process.env.NODE_ENV === "production" ? "https://api.pluralkit.me" : "http://localhost:2939";
const CLIENT_ID = process.env.NODE_ENV === "production" ? "466378653216014359" : "467772037541134367";
export const AUTH_URI = `https://discordapp.com/api/oauth2/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(SITE_ROOT + "/auth/discord")}&response_type=code&scope=identify`
class API extends EventEmitter {
async init() {
this.token = localStorage.getItem("pk-token");
if (this.token) {
this.me = await fetch(API_ROOT + "/s", {headers: {"X-Token": this.token}}).then(r => r.json());
this.emit("update", this.me);
}
}
async fetchSystem(id) {
return await fetch(API_ROOT + "/s/" + id).then(r => r.json()) || null;
}
async fetchSystemMembers(id) {
return await fetch(API_ROOT + "/s/" + id + "/members").then(r => r.json()) || [];
}
async fetchSystemSwitches(id) {
return await fetch(API_ROOT + "/s/" + id + "/switches").then(r => r.json()) || [];
}
async fetchMember(id) {
return await fetch(API_ROOT + "/m/" + id).then(r => r.json()) || null;
}
async saveSystem(system) {
return await fetch(API_ROOT + "/s", {
method: "PATCH",
headers: {"X-Token": this.token},
body: JSON.stringify(system)
});
}
async login(code) {
this.token = await fetch(API_ROOT + "/discord_oauth", {method: "POST", body: code}).then(r => r.text());
this.me = await fetch(API_ROOT + "/s", {headers: {"X-Token": this.token}}).then(r => r.json());
if (this.me) {
localStorage.setItem("pk-token", this.token);
this.emit("update", this.me);
} else {
this.logout();
}
return this.me;
}
logout() {
localStorage.removeItem("pk-token");
this.emit("update", null);
this.token = null;
this.me = null;
}
}
export default new API();

61
web/app/App.vue Normal file
View File

@ -0,0 +1,61 @@
<template>
<div class="app">
<b-navbar>
<b-navbar-brand :to="{name: 'home'}">PluralKit</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<b-navbar-nav class="ml-auto">
<b-nav-item v-if="me" :to="{name: 'system', params: {id: me.id}}">My system</b-nav-item>
<b-nav-item variant="primary" :href="authUri" v-if="!me">Log in</b-nav-item>
<b-nav-item v-on:click="logout" v-if="me">Log out</b-nav-item>
</b-navbar-nav>
</b-collapse>
</b-navbar>
<router-view :me="me"></router-view>
</div>
</template>
<script>
import API from "./API";
import { AUTH_URI } from "./API";
export default {
data() {
return {
me: null
}
},
created() {
API.on("update", this.apply);
API.init();
},
methods: {
apply(system) {
this.me = system;
},
logout() {
API.logout();
}
},
computed: {
authUri() {
return AUTH_URI;
}
}
};
</script>
<style lang="scss">
$font-family-sans-serif: "PT Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default;
$container-max-widths: (
sm: 540px,
md: 720px,
lg: 959px,
xl: 960px,
) !default;
@import '~bootstrap/scss/bootstrap.scss';
@import '~bootstrap-vue/src/index.scss';
</style>

9
web/app/HomePage.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<h1>Hello</h1>
</template>
<script>
export default {
}
</script>

64
web/app/MemberCard.vue Normal file
View File

@ -0,0 +1,64 @@
<template>
<div class="member-card">
<div
class="member-avatar"
:style="{backgroundImage: `url(${member.avatar_url})`, borderColor: member.color}"
></div>
<div class="member-body">
<span class="member-name">{{ member.name }}</span>
<div class="member-description">{{ member.description }}</div>
<ul class="taglist">
<li>
<hash-icon></hash-icon>
{{ member.id }}
</li>
<li v-if="member.birthday">
<calendar-icon></calendar-icon>
{{ member.birthday }}
</li>
<li v-if="member.pronouns">
<message-circle-icon></message-circle-icon>
{{ member.pronouns }}
</li>
</ul>
</div>
</div>
</template>
<script>
import { CalendarIcon, HashIcon, MessageCircleIcon } from "vue-feather-icons";
export default {
props: ["member"],
components: { HashIcon, CalendarIcon, MessageCircleIcon }
};
</script>
<style lang="scss">
.member-card {
display: flex;
flex-direction: row;
.member-avatar {
margin: 1.5rem 1rem 0 0;
border-radius: 50%;
background-size: cover;
background-position: top center;
flex-basis: 4rem;
height: 4rem;
border: 4px solid white;
}
.member-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem 1rem 1rem 0;
.member-name {
font-size: 13pt;
font-weight: bold;
}
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<b-container v-if="loading" class="d-flex justify-content-center">
<b-spinner class="m-5"></b-spinner>
</b-container>
<b-container v-else-if="error">Error</b-container>
<b-container v-else>
<h1>Editing "{{member.name}}"</h1>
<b-form>
<b-form-group label="Name">
<b-form-input v-model="member.name" required></b-form-input>
</b-form-group>
<b-form-group label="Description">
<b-form-textarea v-model="member.description" rows="3" max-rows="6"></b-form-textarea>
</b-form-group>
<b-form-group label="Proxy tags">
<b-row>
<b-col>
<b-input-group prepend="Prefix">
<b-form-input class="text-right" v-model="member.prefix" placeholder="ex: ["></b-form-input>
</b-input-group>
</b-col>
<b-col>
<b-input-group append="Suffix">
<b-form-input v-model="member.suffix" placeholder="ex: ]"></b-form-input>
</b-input-group>
</b-col>
<b-col></b-col>
</b-row>
<template
v-slot:description
v-if="member.prefix || member.suffix"
>Example proxy message: {{member.prefix}}text{{member.suffix}}</template>
<template v-slot:description v-else>(no prefix or suffix defined, proxying will be disabled)</template>
</b-form-group>
<b-form-group label="Pronouns" description="Free text field - put anything you'd like :)">
<b-form-input v-model="member.pronouns" placeholder="eg. he/him"></b-form-input>
</b-form-group>
<b-row>
<b-col md>
<b-form-group label="Birthday">
<b-input-group>
<b-input-group-prepend is-text>
<input type="checkbox" v-model="hideBirthday" label="uwu">&nbsp;Hide year
</b-input-group-prepend>
<b-form-input v-model="member.birthday" type="date"></b-form-input>
</b-input-group>
</b-form-group>
</b-col>
<b-col md>
<b-form-group label="Color" description="Will be displayed on system profile cards.">
<b-form-input type="color" v-model="member.color"></b-form-input>
</b-form-group>
</b-col>
</b-row>
</b-form>
</b-container>
</template>
<script>
import API from "./API";
export default {
props: ["id"],
data() {
return {
loading: false,
error: false,
hideBirthday: false,
member: null
};
},
created() {
this.fetch();
},
methods: {
async fetch() {
this.loading = true;
this.error = false;
this.member = await API.fetchMember(this.id);
if (!this.member) this.error = true;
this.loading = false;
}
}
};
</script>
<style>
</style>

View File

@ -0,0 +1,20 @@
<template>
<b-container class="d-flex justify-content-center"><span class="sr-only">Loading...</span><b-spinner class="m-5"></b-spinner></b-container>
</template>
<script>
import API from "./API";
export default {
async created() {
const code = this.$route.query.code;
if (!code) this.$router.push({ name: "home" });
const me = await API.login(code);
if (me) this.$router.push({ name: "home" });
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,73 @@
<template>
<b-container>
<b-container v-if="loading" class="d-flex justify-content-center"><b-spinner class="m-5"></b-spinner></b-container>
<b-form v-else>
<h1>Editing "{{ system.name || system.id }}"</h1>
<b-form-group label="System name">
<b-form-input v-model="system.name" placeholder="Enter something..."></b-form-input>
</b-form-group>
<b-form-group label="Description">
<b-form-textarea v-model="system.description" placeholder="Enter something..." rows="3" max-rows="3" maxlength="1000"></b-form-textarea>
</b-form-group>
<b-form-group label="System tag">
<b-form-input maxlength="30" v-model="system.tag" placeholder="Enter something..."></b-form-input>
<template v-slot:description>
This is added to the names of proxied accounts. For example: <code>John {{ system.tag }}</code>
</template>
</b-form-group>
<b-form-group class="d-flex justify-content-end">
<b-button type="reset" variant="outline-secondary">Back</b-button>
<b-button v-if="!saving" type="submit" variant="primary" v-on:click="save">Save</b-button>
<b-button v-else variant="primary" disabled>
<b-spinner small></b-spinner>
<span class="sr-only">Saving...</span>
</b-button>
<b-form-group>
</b-form>
</b-container>
</template>
<script>
import API from "./API";
export default {
data() {
return {
loading: false,
saving: false,
system: null
}
},
props: ["me", "id"],
created() {
this.fetch()
},
watch: {
"id": "fetch"
},
methods: {
async fetch() {
this.loading = true;
this.system = await API.fetchSystem(this.id);
if (!this.me || !this.system || this.system.id != this.me.id) {
this.$router.push({name: "system", params: {id: this.id}});
}
this.loading = false;
},
async save() {
this.saving = true;
if (await API.saveSystem(this.system)) {
this.$router.push({ name: "system", params: {id: this.system.id} });
}
this.saving = false;
}
}
}
</script>
<style>
</style>

105
web/app/SystemPage.vue Normal file
View File

@ -0,0 +1,105 @@
<template>
<b-container v-if="loading" class="d-flex justify-content-center"><b-spinner class="m-5"></b-spinner></b-container>
<b-container v-else-if="error">An error occurred.</b-container>
<b-container v-else>
<ul v-if="system" class="taglist">
<li>
<hash-icon></hash-icon>
{{ system.id }}
</li>
<li v-if="system.tag">
<tag-icon></tag-icon>
{{ system.tag }}
</li>
<li v-if="system.tz">
<clock-icon></clock-icon>
{{ system.tz }}
</li>
<li v-if="isMine" class="ml-auto">
<b-link :to="{name: 'edit-system', params: {id: system.id}}">
<edit-2-icon></edit-2-icon>
Edit
</b-link>
</li>
</ul>
<h1 v-if="system && system.name">{{ system.name }}</h1>
<div v-if="system && system.description">{{ system.description }}</div>
<h2>Members</h2>
<div v-if="members">
<MemberCard v-for="member in members" :member="member" :key="member.id"/>
</div>
</b-container>
</template>
<script>
import API from "./API";
import MemberCard from "./MemberCard.vue";
import { Edit2Icon, ClockIcon, HashIcon, TagIcon } from "vue-feather-icons";
export default {
data() {
return {
loading: false,
error: false,
system: null,
members: null
};
},
props: ["me", "id"],
created() {
this.fetch();
},
methods: {
async fetch() {
this.loading = true;
this.system = await API.fetchSystem(this.id);
if (!this.system) {
this.error = true;
this.loading = false;
return;
}
this.members = await API.fetchSystemMembers(this.id);
this.loading = false;
}
},
watch: {
id: "fetch"
},
computed: {
isMine() {
return this.system && this.me && this.me.id == this.system.id;
}
},
components: {
Edit2Icon,
ClockIcon,
HashIcon,
TagIcon,
MemberCard
}
};
</script>
<style lang="scss">
.taglist {
margin: 0;
padding: 0;
color: #aaa;
display: flex;
li {
display: inline-block;
margin-right: 1rem;
list-style-type: none;
.feather {
display: inline-block;
margin-top: -2px;
width: 1em;
}
}
}
</style>

24
web/app/index.js Normal file
View File

@ -0,0 +1,24 @@
import Vue from "vue";
import VueRouter from "vue-router";
import BootstrapVue from "bootstrap-vue";
Vue.use(VueRouter);
Vue.use(BootstrapVue);
import App from "./App.vue";
import HomePage from "./HomePage.vue";
import SystemPage from "./SystemPage.vue";
import SystemEditPage from "./SystemEditPage.vue";
import MemberEditPage from "./MemberEditPage.vue";
import OAuthRedirectPage from "./OAuthRedirectPage.vue";
const router = new VueRouter({
mode: "history",
routes: [
{ name: "home", path: "/", component: HomePage },
{ name: "system", path: "/s/:id", component: SystemPage, props: true },
{ name: "edit-system", path: "/s/:id/edit", component: SystemEditPage, props: true },
{ name: "edit-member", path: "/m/:id/edit", component: MemberEditPage, props: true },
{ name: "auth-discord", path: "/auth/discord", component: OAuthRedirectPage }
]
})
new Vue({ el: "#app", render: r => r(App), router });

9
web/index.html Normal file
View File

@ -0,0 +1,9 @@
<html>
<head>
<link href="https://fonts.googleapis.com/css?family=PT+Sans:400,700" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script src="app/index.js"></script>
</body>
</html>

20
web/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"dependencies": {
"bootstrap": "^4.3.1",
"bootstrap-vue": "^2.0.0-rc.16",
"eventemitter3": "^3.1.0",
"vue": "^2.6.10",
"vue-feather-icons": "^4.10.0",
"vue-hot-reload-api": "^2.3.3",
"vue-router": "^3.0.2"
},
"devDependencies": {
"@vue/component-compiler-utils": "^2.6.0",
"babel-core": "^6.26.3",
"babel-preset-env": "^1.7.0",
"cssnano": "^4.1.10",
"parcel-plugin-bundle-visualiser": "^1.2.0",
"sass": "^1.17.3",
"vue-template-compiler": "^2.6.10"
}
}

3296
web/yarn.lock Normal file

File diff suppressed because it is too large Load Diff