mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-16 18:50:13 +00:00
Merge branch 'master' into master
This commit is contained in:
commit
bc197f2fbd
19 changed files with 3939 additions and 22 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -36,13 +36,24 @@ async def db_middleware(request, handler):
|
|||
|
||||
@web.middleware
|
||||
async def auth_middleware(request, handler):
|
||||
token = request.headers.get("X-Token") or request.query.get("token")
|
||||
token = request.headers.get("Authorization") or request.query.get("token")
|
||||
if token:
|
||||
system = await System.get_by_token(request["conn"], token)
|
||||
if system:
|
||||
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"] = "Authorization"
|
||||
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,17 +96,17 @@ 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(),
|
||||
"members": [member.to_json() for member in members]
|
||||
}
|
||||
}
|
||||
return web.json_response(data)
|
||||
|
||||
@require_system
|
||||
|
|
@ -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):
|
||||
|
|
@ -228,8 +242,9 @@ class Handlers:
|
|||
return web.json_response(message.to_json())
|
||||
|
||||
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": "Authorization", "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),
|
||||
|
|
@ -244,6 +259,7 @@ async def run():
|
|||
web.delete("/m/{member}", Handlers.delete_member),
|
||||
web.post("/discord_oauth", Handlers.discord_oauth),
|
||||
web.get("/message/{message}", Handlers.get_message)
|
||||
web.route("*", "/{tail:.*}", cors_fallback)
|
||||
])
|
||||
app["pool"] = await db.connect(
|
||||
os.environ["DATABASE_URI"]
|
||||
|
|
|
|||
|
|
@ -158,8 +158,11 @@ async def export(ctx: CommandContext):
|
|||
await working_msg.delete()
|
||||
|
||||
f = io.BytesIO(json.dumps(data).encode("utf-8"))
|
||||
await ctx.reply_ok("DM'd!")
|
||||
await ctx.message.author.send(content="Here you go!", file=discord.File(fp=f, filename="pluralkit_system.json"))
|
||||
|
||||
if not isinstance(ctx.message.channel, discord.DMChannel):
|
||||
await ctx.reply_ok("DM'd!")
|
||||
|
||||
await ctx.message.author.send(content="Here you go! \u2709", file=discord.File(fp=f, filename="pluralkit_system.json"))
|
||||
|
||||
|
||||
async def tell(ctx: CommandContext):
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@
|
|||
"root": [
|
||||
{
|
||||
"name": "PluralKit",
|
||||
"content": "PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.\n\n**Who's this for? What are systems?**\nPut simply, a system is a person that shares their body with at least 1 other sentient \"self\". This may be a result of having a dissociative disorder like DID/OSDD or a practice known as Tulpamancy, but people that aren't tulpamancers or undiagnosed and have headmates are also systems.\n\n**Why are people's names saying [BOT] next to them? What's going on?**\nThese people are not actually bots, this is simply a caveat to the message proxying feature of PluralKit.\nType `pk;help proxy` for an in-depth explanation."
|
||||
"content": "PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.\n\n**What's this for? What are systems?**\nThis bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks. This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account.\n\n**Why are people's names saying [BOT] next to them?**\nThese people are not actually bots, this is just a Discord a limitation.\nType `pk;help proxy` for an in-depth explanation."
|
||||
},
|
||||
{
|
||||
"name": "Getting started",
|
||||
|
|
@ -314,11 +314,11 @@
|
|||
},
|
||||
{
|
||||
"name": "Useful tips",
|
||||
"content": "React with ❌ on a proxied message to delete it (if you sent it!).\nReact with ❓ on a proxied message to look up information about it, like who sent it."
|
||||
"content": "React with ❌ on a proxied message to delete it (if you sent it!).\nReact with ❓ on a proxied message to look up information about it, like who sent it.\nType `pk;invite` for a link to invite this bot to your own server!"
|
||||
},
|
||||
{
|
||||
"name": "More information",
|
||||
"content": "For a full list of commands, type `pk;commands`.\nFor a more in-depth explanation of message proxying, type `pk;help proxy`.\nIf you're an existing user of the Tupperbox proxy bot, type `pk;import` to import your data from there."
|
||||
"content": "For a full list of commands, type `pk;commands`.\nFor a more in-depth explanation of message proxying, type `pk;help proxy`.\nIf you're an existing user of Tupperbox, type `pk;import` to import your data from there."
|
||||
},
|
||||
{
|
||||
"name": "Support server",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
|
|||
raise errors.ExistingSystemError()
|
||||
|
||||
new_hid = generate_hid()
|
||||
while await System.get_by_hid(conn, new_hid):
|
||||
new_hid = generate_hid()
|
||||
|
||||
async with conn.transaction():
|
||||
new_system = await db.create_system(conn, system_name, new_hid)
|
||||
|
|
@ -122,11 +124,12 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
|
|||
return await self.refresh_token(conn)
|
||||
|
||||
async def create_member(self, conn, member_name: str) -> Member:
|
||||
# TODO: figure out what to do if this errors out on collision on generate_hid
|
||||
new_hid = generate_hid()
|
||||
|
||||
if len(member_name) > self.get_member_name_limit():
|
||||
raise errors.MemberNameTooLongError(tag_present=bool(self.tag))
|
||||
|
||||
new_hid = generate_hid()
|
||||
while await db.get_member_by_hid(conn, new_hid):
|
||||
new_hid = generate_hid()
|
||||
|
||||
member = await db.create_member(conn, self.id, member_name, new_hid)
|
||||
return member
|
||||
|
|
@ -189,9 +192,8 @@ class System(namedtuple("System", ["id", "hid", "name", "description", "tag", "a
|
|||
"""Tries to find a member with proxy tags matching the given message. Returns the member and the inner contents."""
|
||||
members = await db.get_all_members(conn, self.id)
|
||||
|
||||
# Sort by specificity (members with both prefix and suffix defined go higher)
|
||||
# This will make sure more "precise" proxy tags get tried first and match properly
|
||||
members = sorted(members, key=lambda x: int(bool(x.prefix)) + int(bool(x.suffix)), reverse=True)
|
||||
# Sort by match specificity (longer prefix/suffix = smaller match = more specific)
|
||||
members = sorted(members, key=lambda x: len(x.prefix or "") + len(x.suffix or ""), reverse=True)
|
||||
|
||||
for member in members:
|
||||
proxy_prefix = member.prefix or ""
|
||||
|
|
|
|||
14
web/.babelrc
Normal file
14
web/.babelrc
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"env",
|
||||
{
|
||||
"targets": {
|
||||
"browsers": [
|
||||
"last 2 Chrome versions"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
3
web/.gitignore
vendored
Normal file
3
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.cache/
|
||||
dist/
|
||||
node_modules/
|
||||
63
web/app/API.js
Normal file
63
web/app/API.js
Normal 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();
|
||||
84
web/app/App.vue
Normal file
84
web/app/App.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<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 BCollapse from 'bootstrap-vue/es/components/collapse/collapse';
|
||||
import BNav from 'bootstrap-vue/es/components/nav/nav';
|
||||
import BNavItem from 'bootstrap-vue/es/components/nav/nav-item';
|
||||
import BNavbar from 'bootstrap-vue/es/components/navbar/navbar';
|
||||
import BNavbarBrand from 'bootstrap-vue/es/components/navbar/navbar-brand';
|
||||
import BNavbarNav from 'bootstrap-vue/es/components/navbar/navbar-nav';
|
||||
import BNavbarToggle from 'bootstrap-vue/es/components/navbar/navbar-toggle';
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
components: {BCollapse, BNav, BNavItem, BNavbar, BNavbarBrand, BNavbarNav, BNavbarToggle}
|
||||
};
|
||||
</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/_functions';
|
||||
@import '~bootstrap/scss/_variables';
|
||||
@import '~bootstrap/scss/_mixins';
|
||||
|
||||
@import '~bootstrap/scss/_buttons';
|
||||
@import '~bootstrap/scss/_code';
|
||||
@import '~bootstrap/scss/_forms';
|
||||
@import '~bootstrap/scss/_grid';
|
||||
@import '~bootstrap/scss/_nav';
|
||||
@import '~bootstrap/scss/_navbar';
|
||||
@import '~bootstrap/scss/_reboot';
|
||||
@import '~bootstrap/scss/_type';
|
||||
@import '~bootstrap/scss/_utilities';
|
||||
|
||||
@import '~bootstrap-vue/src/index.scss';
|
||||
</style>
|
||||
|
||||
9
web/app/HomePage.vue
Normal file
9
web/app/HomePage.vue
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<template>
|
||||
<h1>Hello</h1>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
|
||||
}
|
||||
</script>
|
||||
66
web/app/MemberCard.vue
Normal file
66
web/app/MemberCard.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<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 from "vue-feather-icons/icons/CalendarIcon";
|
||||
import HashIcon from "vue-feather-icons/icons/HashIcon";
|
||||
import MessageCircleIcon from "vue-feather-icons/icons/MessageCircleIcon";
|
||||
|
||||
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>
|
||||
93
web/app/MemberEditPage.vue
Normal file
93
web/app/MemberEditPage.vue
Normal 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"> 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>
|
||||
20
web/app/OAuthRedirectPage.vue
Normal file
20
web/app/OAuthRedirectPage.vue
Normal 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>
|
||||
83
web/app/SystemEditPage.vue
Normal file
83
web/app/SystemEditPage.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<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 BButton from 'bootstrap-vue/es/components/button/button';
|
||||
import BContainer from 'bootstrap-vue/es/components/layout/container';
|
||||
import BLink from 'bootstrap-vue/es/components/link/link';
|
||||
import BSpinner from 'bootstrap-vue/es/components/spinner/spinner';
|
||||
import BForm from 'bootstrap-vue/es/components/form/form';
|
||||
import BFormInput from 'bootstrap-vue/es/components/form-input/form-input';
|
||||
import BFormGroup from 'bootstrap-vue/es/components/form-group/form-group';
|
||||
import BFormTextarea from 'bootstrap-vue/es/components/form-textarea/form-textarea';
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
components: {BButton, BContainer, BLink, BSpinner, BForm, BFormGroup, BFormInput, BFormTextarea}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
113
web/app/SystemPage.vue
Normal file
113
web/app/SystemPage.vue
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<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 BContainer from 'bootstrap-vue/es/components/layout/container';
|
||||
import BLink from 'bootstrap-vue/es/components/link/link';
|
||||
import BSpinner from 'bootstrap-vue/es/components/spinner/spinner';
|
||||
|
||||
import MemberCard from "./MemberCard.vue";
|
||||
|
||||
import Edit2Icon from "vue-feather-icons/icons/Edit2Icon";
|
||||
import ClockIcon from "vue-feather-icons/icons/ClockIcon";
|
||||
import HashIcon from "vue-feather-icons/icons/HashIcon";
|
||||
import TagIcon from "vue-feather-icons/icons/TagIcon";
|
||||
|
||||
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,
|
||||
BContainer, BLink, BSpinner
|
||||
}
|
||||
};
|
||||
</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>
|
||||
23
web/app/index.js
Normal file
23
web/app/index.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Vue from "vue";
|
||||
|
||||
import VueRouter from "vue-router";
|
||||
Vue.use(VueRouter);
|
||||
|
||||
const App = () => import("./App.vue");
|
||||
const HomePage = () => import("./HomePage.vue");
|
||||
const SystemPage = () => import("./SystemPage.vue");
|
||||
const SystemEditPage = () => import("./SystemEditPage.vue");
|
||||
const MemberEditPage = () => import("./MemberEditPage.vue");
|
||||
const OAuthRedirectPage = () => import("./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
9
web/index.html
Normal 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>
|
||||
19
web/package.json
Normal file
19
web/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"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-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
3296
web/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue