commit
8027527055
@ -11,6 +11,7 @@
|
||||
!.git
|
||||
!proto
|
||||
!scripts/run-clustered.sh
|
||||
!dashboard
|
||||
|
||||
# Re-exclude host build artifact directories
|
||||
**/bin
|
||||
|
34
.github/workflows/dashboard
vendored
Normal file
34
.github/workflows/dashboard
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
name: Build dashboard Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'dashboard/'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
if: github.repository == 'xSke/PluralKit'
|
||||
steps:
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.CR_PAT }}
|
||||
- uses: actions/checkout@v2
|
||||
- run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
|
||||
- uses: docker/build-push-action@v2
|
||||
with:
|
||||
# https://github.com/docker/build-push-action/issues/378
|
||||
context: .
|
||||
file: Dockerfile.dashboard
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }}
|
||||
ghcr.io/pluralkit/dashboard:${{ github.sha }}
|
||||
ghcr.io/pluralkit/dashboard:latest
|
||||
cache-from: type=registry,ref=ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }}
|
||||
cache-to: type=inline
|
19
Dockerfile.dashboard
Normal file
19
Dockerfile.dashboard
Normal file
@ -0,0 +1,19 @@
|
||||
FROM alpine:latest as builder
|
||||
|
||||
RUN apk add nodejs-current yarn go git
|
||||
|
||||
COPY dashboard/ /build
|
||||
COPY .git/ /build/.git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
RUN yarn build
|
||||
|
||||
RUN sh -c 'go build -ldflags "-X main.version=$(git rev-parse HEAD)"'
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
COPY --from=builder /build/dashboard /bin/dashboard
|
||||
|
||||
ENTRYPOINT /bin/dashboard
|
@ -25,6 +25,18 @@ public static class APIJsonExt
|
||||
|
||||
return o;
|
||||
}
|
||||
|
||||
public static JObject EmbedJson(string title, string type)
|
||||
{
|
||||
var o = new JObject();
|
||||
|
||||
o.Add("type", "rich");
|
||||
o.Add("provider_name", "PluralKit " + type);
|
||||
o.Add("provider_url", "https://pluralkit.me");
|
||||
o.Add("title", title);
|
||||
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
public struct FrontersReturnNew
|
||||
|
@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase
|
||||
return Ok(group.ToJson(ContextFor(group), system.Hid));
|
||||
}
|
||||
|
||||
[HttpGet("groups/{groupRef}/oembed.json")]
|
||||
public async Task<IActionResult> GroupEmbed(string groupRef)
|
||||
{
|
||||
var group = await ResolveGroup(groupRef);
|
||||
if (group == null)
|
||||
throw Errors.GroupNotFound;
|
||||
var system = await _repo.GetSystem(group.System);
|
||||
|
||||
var name = group.NameFor(LookupContext.ByNonOwner);
|
||||
if (system.Name != null)
|
||||
name += $" ({system.Name})";
|
||||
|
||||
return Ok(APIJsonExt.EmbedJson(name, "Group"));
|
||||
}
|
||||
|
||||
[HttpPatch("groups/{groupRef}")]
|
||||
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
|
||||
{
|
||||
|
@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase
|
||||
return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid));
|
||||
}
|
||||
|
||||
[HttpGet("members/{memberRef}/oembed.json")]
|
||||
public async Task<IActionResult> MemberEmbed(string memberRef)
|
||||
{
|
||||
var member = await ResolveMember(memberRef);
|
||||
if (member == null)
|
||||
throw Errors.MemberNotFound;
|
||||
var system = await _repo.GetSystem(member.System);
|
||||
|
||||
var name = member.NameFor(LookupContext.ByNonOwner);
|
||||
if (system.Name != null)
|
||||
name += $" ({system.Name})";
|
||||
|
||||
return Ok(APIJsonExt.EmbedJson(name, "Member"));
|
||||
}
|
||||
|
||||
[HttpPatch("members/{memberRef}")]
|
||||
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
|
||||
{
|
||||
|
@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase
|
||||
return Ok(system.ToJson(ContextFor(system)));
|
||||
}
|
||||
|
||||
[HttpGet("{systemRef}/oembed.json")]
|
||||
public async Task<IActionResult> SystemEmbed(string systemRef)
|
||||
{
|
||||
var system = await ResolveSystem(systemRef);
|
||||
if (system == null)
|
||||
throw Errors.SystemNotFound;
|
||||
|
||||
return Ok(APIJsonExt.EmbedJson(system.Name ?? $"System with ID `{system.Hid}`", "System"));
|
||||
}
|
||||
|
||||
[HttpPatch("{systemRef}")]
|
||||
public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data)
|
||||
{
|
||||
|
@ -72,7 +72,8 @@ public class EmbedService
|
||||
.Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl()))
|
||||
.Footer(new Embed.EmbedFooter(
|
||||
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
|
||||
.Color(color);
|
||||
.Color(color)
|
||||
.Url($"https://dash.pluralkit.me/profile/s/{system.Hid}");
|
||||
|
||||
if (system.DescriptionPrivacy.CanAccess(ctx))
|
||||
eb.Image(new Embed.EmbedImage(system.BannerImage));
|
||||
@ -179,8 +180,7 @@ public class EmbedService
|
||||
.ToListAsync();
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
// TODO: add URL of website when that's up
|
||||
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl()))
|
||||
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
|
||||
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
|
||||
.Color(color)
|
||||
.Footer(new Embed.EmbedFooter(
|
||||
@ -264,7 +264,7 @@ public class EmbedService
|
||||
}
|
||||
|
||||
var eb = new EmbedBuilder()
|
||||
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx)))
|
||||
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
|
||||
.Color(color);
|
||||
|
||||
eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
|
||||
|
3
dashboard/.gitignore
vendored
Normal file
3
dashboard/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
dashboard
|
12
dashboard/README.md
Normal file
12
dashboard/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# PluralKit Dashboard
|
||||
|
||||
This project is built using [Vite](https://vitejs.dev/), using the svelte-ts template.
|
||||
|
||||
Some of the other stuff used to get this working:
|
||||
* sveltestrap (https://sveltestrap.js.org/)
|
||||
* svelte-navigator (https://github.com/mefechoel/svelte-navigator)
|
||||
* svelte-toggle (https://github.com/metonym/svelte-toggle)
|
||||
* svelecte (https://mskocik.github.io/svelecte/)
|
||||
* svelte-icons (https://github.com/Introvertuous/svelte-icons)
|
||||
* discord-markdown (https://github.com/brussell98/discord-markdown)
|
||||
* moment (https://momentjs.com/)
|
5
dashboard/go.mod
Normal file
5
dashboard/go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module dashboard
|
||||
|
||||
go 1.18
|
||||
|
||||
require github.com/go-chi/chi v1.5.4 // indirect
|
2
dashboard/go.sum
Normal file
2
dashboard/go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
16
dashboard/index.html
Normal file
16
dashboard/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="./myriad.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PluralKit | home</title>
|
||||
<!-- extra data -->
|
||||
<link rel="stylesheet" href="/styles/themes.scss" />
|
||||
<script defer data-domain="dash.pluralkit.me" src="https://plausible.pluralkit.me/js/plausible.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
141
dashboard/main.go
Normal file
141
dashboard/main.go
Normal file
@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var fs embed.FS
|
||||
|
||||
type entity struct {
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
IconURL *string `json:"icon_url"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
var baseURL = "https://api.pluralkit.me/v2"
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const defaultEmbed = `<meta property="og:title" content="PluralKit | web dashboard" /> <meta name="theme-color" content="#da9317">`
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.Header().Set("X-PluralKit-Version", version)
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
})
|
||||
|
||||
r.NotFound(notFoundHandler)
|
||||
|
||||
r.Get("/profile/{type}/{id}", func(rw http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if a := recover(); a != nil {
|
||||
notFoundHandler(rw, r)
|
||||
return
|
||||
}
|
||||
}()
|
||||
createEmbed(rw, r)
|
||||
})
|
||||
|
||||
http.ListenAndServe(":8080", r)
|
||||
}
|
||||
|
||||
func notFoundHandler(rw http.ResponseWriter, r *http.Request) {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
// lol
|
||||
if strings.HasSuffix(r.URL.Path, ".js") {
|
||||
data, err = fs.ReadFile("dist" + r.URL.Path)
|
||||
rw.Header().Add("content-type", "application/javascript")
|
||||
} else if strings.HasSuffix(r.URL.Path, ".css") {
|
||||
data, err = fs.ReadFile("dist" + r.URL.Path)
|
||||
rw.Header().Add("content-type", "text/css")
|
||||
} else if strings.HasSuffix(r.URL.Path, ".map") {
|
||||
data, err = fs.ReadFile("dist" + r.URL.Path)
|
||||
} else {
|
||||
data, err = fs.ReadFile("dist/index.html")
|
||||
rw.Header().Add("content-type", "text/html")
|
||||
data = []byte(strings.Replace(string(data), `<!-- extra data -->`, defaultEmbed, 1))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
rw.Write(data)
|
||||
}
|
||||
|
||||
// explanation for createEmbed:
|
||||
// we don't care about errors, we just want to return a HTML page as soon as possible
|
||||
// `panic(nil)` is caught by upstream, which then returns the raw HTML page
|
||||
|
||||
func createEmbed(rw http.ResponseWriter, r *http.Request) {
|
||||
entityType := chi.URLParam(r, "type")
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var path string
|
||||
|
||||
switch entityType {
|
||||
case "s":
|
||||
path = "/systems/" + id
|
||||
case "m":
|
||||
path = "/members/" + id
|
||||
case "g":
|
||||
path = "/groups/" + id
|
||||
default:
|
||||
panic(nil)
|
||||
}
|
||||
|
||||
res, err := http.Get(baseURL + path)
|
||||
if err != nil {
|
||||
panic(nil)
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
panic(nil)
|
||||
}
|
||||
|
||||
var data entity
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
panic(nil)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(`<link type="application/json+oembed" href="%s/%s/oembed.json" />%s`, baseURL, path, "\n")
|
||||
|
||||
if data.AvatarURL != nil {
|
||||
text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, *data.AvatarURL, "\n")
|
||||
} else if data.IconURL != nil {
|
||||
text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, *data.IconURL, "\n")
|
||||
}
|
||||
|
||||
if data.Description != nil {
|
||||
text += fmt.Sprintf(`<meta content="%s" property="og:description">%s`, *data.Description, "\n")
|
||||
}
|
||||
|
||||
if data.Color != nil {
|
||||
text += fmt.Sprintf(`<meta name="theme-color" content="#%s">%s`, *data.Color, "\n")
|
||||
}
|
||||
|
||||
html, err := fs.ReadFile("dist/index.html")
|
||||
if err != nil {
|
||||
panic(nil)
|
||||
}
|
||||
html = []byte(strings.Replace(string(html), `<!-- extra data -->`, text, 1))
|
||||
|
||||
rw.Header().Add("content-type", "text/html")
|
||||
rw.Write(html)
|
||||
}
|
41
dashboard/package.json
Normal file
41
dashboard/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "pluralkit-dashboard",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.30",
|
||||
"@tsconfig/svelte": "^2.0.1",
|
||||
"svelte": "^3.44.0",
|
||||
"svelte-check": "^2.2.7",
|
||||
"svelte-toggle": "^3.1.0",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.4.4",
|
||||
"vite": "^2.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/browser": "^6.19.5",
|
||||
"@sentry/tracing": "^6.19.5",
|
||||
"@types/twemoji": "^12.1.2",
|
||||
"axios": "^0.24.0",
|
||||
"bootstrap": "^5.1.3",
|
||||
"bootstrap-dark-5": "^1.1.3",
|
||||
"discord-markdown": "^2.5.1",
|
||||
"gh-pages": "^3.2.3",
|
||||
"import": "^0.0.6",
|
||||
"moment": "^2.29.1",
|
||||
"sass": "^1.52.2",
|
||||
"svelecte": "^3.4.5",
|
||||
"svelte-autosize": "^1.0.1",
|
||||
"svelte-icons": "^2.1.0",
|
||||
"svelte-navigator": "^3.1.5",
|
||||
"svelte-preprocess": "^4.10.6",
|
||||
"sveltestrap": "^5.6.3",
|
||||
"twemoji": "^13.1.0"
|
||||
}
|
||||
}
|
BIN
dashboard/public/myriad.png
Normal file
BIN
dashboard/public/myriad.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 664 KiB |
78
dashboard/src/App.svelte
Normal file
78
dashboard/src/App.svelte
Normal file
@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { Router, Route } from "svelte-navigator";
|
||||
import Navigation from "./lib/Navigation.svelte";
|
||||
import Dash from "./pages/Dash.svelte";
|
||||
import Home from "./pages/Home.svelte";
|
||||
import Settings from './pages/Settings.svelte';
|
||||
import Public from "./pages/Public.svelte";
|
||||
import Main from "./pages/profiles/Main.svelte";
|
||||
import Status from './pages/status.svelte';
|
||||
import Member from './pages/Member.svelte';
|
||||
import Group from './pages/Group.svelte';
|
||||
import PageNotFound from './pages/PageNotFound.svelte';
|
||||
import { Alert } from 'sveltestrap';
|
||||
import DiscordLogin from "./pages/DiscordLogin.svelte";
|
||||
import { onMount } from 'svelte';
|
||||
import BulkGroupPrivacy from "./pages/BulkGroupPrivacy.svelte";
|
||||
import BulkMemberPrivacy from "./pages/BulkMemberPrivacy.svelte";
|
||||
|
||||
// theme cdns (I might make some myself too)
|
||||
// if there's a style already set, retrieve it
|
||||
let style = localStorage.getItem("pk-style") && localStorage.getItem("pk-style");
|
||||
|
||||
// this automatically applies the style every time it is updated
|
||||
$: setStyle(style);
|
||||
|
||||
// not sure if there's a better way to handle this
|
||||
function setStyle(style) {
|
||||
switch (style) {
|
||||
case "light": document.documentElement.className = "light";
|
||||
localStorage.setItem("pk-style", "light");
|
||||
break;
|
||||
case "dark": document.documentElement.className = "dark";
|
||||
localStorage.setItem("pk-style", "dark");
|
||||
break;
|
||||
default: document.documentElement.className = "dark";
|
||||
localStorage.setItem("pk-style", "dark");
|
||||
break;
|
||||
};
|
||||
};
|
||||
|
||||
let falseBool = false;
|
||||
|
||||
onMount(() => {
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
if (settings && settings.accessibility && settings.accessibility.opendyslexic === true) {
|
||||
document.getElementById("app").classList.add("dyslexic");
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<Router>
|
||||
<Navigation bind:style={style}/>
|
||||
<Route path="/"><Home /></Route>
|
||||
<Route path="/login/discord"><DiscordLogin /></Route>
|
||||
<Route path="dash"><Dash /></Route>
|
||||
<Route path="dash/m/:id"><Member isPublic={falseBool}/></Route>
|
||||
<Route path = "dash/g/:id"><Group isPublic={falseBool}/></Route>
|
||||
<Route path="dash/bulk-member-privacy"><BulkMemberPrivacy/></Route>
|
||||
<Route path="dash/bulk-group-privacy"><BulkGroupPrivacy/></Route>
|
||||
<Route path="settings"><Settings /></Route>
|
||||
<Route path="profile"><Public /></Route>
|
||||
<Route path = "profile/s/:id"><Main /></Route>
|
||||
<Route path = "s">
|
||||
<Alert color="danger">Please provide a system ID in the URL.</Alert>
|
||||
</Route>
|
||||
<Route path = "profile/m/:id"><Member/></Route>
|
||||
<Route path = "profile/m">
|
||||
<Alert color="danger">Please provide a member ID in the URL.</Alert>
|
||||
</Route>
|
||||
<Route path = "profile/g/:id"><Group/></Route>
|
||||
<Route path = "profile/g">
|
||||
<Alert color="danger">Please provide a group ID in the URL.</Alert>
|
||||
</Route>
|
||||
<Route path="status"><Status /></Route>
|
||||
<Route component={PageNotFound}/>
|
||||
</Router>
|
28
dashboard/src/api/errors.ts
Normal file
28
dashboard/src/api/errors.ts
Normal file
@ -0,0 +1,28 @@
|
||||
enum ErrorType {
|
||||
Unknown = 0,
|
||||
InvalidToken = 401,
|
||||
NotFound = 404,
|
||||
InternalServerError = 500,
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
code: number,
|
||||
type: ErrorType,
|
||||
message?: string,
|
||||
data?: any,
|
||||
}
|
||||
|
||||
export function parse(code: number, data?: any): ApiError {
|
||||
var type = ErrorType[ErrorType[code]] ?? ErrorType.Unknown;
|
||||
if (code >= 500) type = ErrorType.InternalServerError;
|
||||
|
||||
var err: ApiError = { code, type };
|
||||
|
||||
if (data) {
|
||||
var d = data;
|
||||
err.message = d.message;
|
||||
err.data = d;
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
58
dashboard/src/api/index.ts
Normal file
58
dashboard/src/api/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
const baseUrl = () => localStorage.isBeta ? "https://api.beta.pluralkit.me" : "https://api.pluralkit.me";
|
||||
|
||||
const methods = ['get', 'post', 'delete', 'patch', 'put'];
|
||||
const noop = () => {};
|
||||
|
||||
const scheduled = [];
|
||||
const runAPI = () => {
|
||||
if (scheduled.length == 0) return;
|
||||
const {axiosData, res, rej} = scheduled.shift();
|
||||
axios(axiosData)
|
||||
.then((resp) => res(parseData(resp.status, resp.data)))
|
||||
.catch((err) => {
|
||||
Sentry.captureException("Fetch error", err);
|
||||
rej(err);
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(runAPI, 500);
|
||||
|
||||
export default function() {
|
||||
const route = [];
|
||||
const handler = {
|
||||
get(_, name) {
|
||||
if (route.length == 0 && name != "private")
|
||||
route.push("v2");
|
||||
if (methods.includes(name)) {
|
||||
return ({ data = undefined, auth = true, token = null, query = null } = {}) => new Promise((res, rej) => scheduled.push({ res, rej, axiosData: {
|
||||
url: baseUrl() + "/" + route.join("/") + (query ? `?${Object.keys(query).map(x => `${x}=${query[x]}`).join("&")}` : ""),
|
||||
method: name,
|
||||
headers: {
|
||||
authorization: token ?? (auth ? localStorage.getItem("pk-token") : undefined),
|
||||
"content-type": name == "get" ? undefined : "application/json"
|
||||
},
|
||||
data: !!data ? JSON.stringify(data) : undefined,
|
||||
validateStatus: () => true,
|
||||
}}));
|
||||
}
|
||||
route.push(name);
|
||||
return new Proxy(noop, handler);
|
||||
},
|
||||
apply(target, _, args) {
|
||||
route.push(...args.filter(x => x != null));
|
||||
return new Proxy(noop, handler);
|
||||
}
|
||||
}
|
||||
return new Proxy(noop, handler);
|
||||
}
|
||||
|
||||
import * as errors from './errors';
|
||||
|
||||
function parseData(code: number, data: any) {
|
||||
if (code == 200) return data;
|
||||
if (code == 204) return;
|
||||
throw errors.parse(code, data);
|
||||
}
|
88
dashboard/src/api/types.ts
Normal file
88
dashboard/src/api/types.ts
Normal file
@ -0,0 +1,88 @@
|
||||
interface SystemPrivacy {
|
||||
description_privacy?: string,
|
||||
member_list_privacy?: string,
|
||||
front_privacy?: string,
|
||||
front_history_privacy?: string,
|
||||
group_list_privacy?: string
|
||||
}
|
||||
|
||||
export interface System {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
tag?: string;
|
||||
avatar_url?: string;
|
||||
banner?: string;
|
||||
timezone?: string;
|
||||
created?: string;
|
||||
privacy?: SystemPrivacy;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
timezone: string;
|
||||
pings_enabled: boolean;
|
||||
member_default_private?: boolean;
|
||||
group_default_private?: boolean;
|
||||
show_private_info?: boolean;
|
||||
member_limit: number;
|
||||
group_limit: number;
|
||||
description_templates: string[];
|
||||
}
|
||||
|
||||
export interface MemberPrivacy {
|
||||
visibility?: string,
|
||||
description_privacy?: string,
|
||||
name_privacy?: string,
|
||||
birthday_privacy?: string,
|
||||
pronoun_privacy?: string,
|
||||
avatar_privacy?: string,
|
||||
metadata_privacy?: string
|
||||
}
|
||||
|
||||
interface proxytag {
|
||||
prefix?: string,
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
color?: string;
|
||||
birthday?: string;
|
||||
pronouns?: string;
|
||||
avatar_url?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
created?: string;
|
||||
keep_proxy?: boolean
|
||||
system?: string;
|
||||
proxy_tags?: Array<proxytag>;
|
||||
privacy?: MemberPrivacy
|
||||
}
|
||||
|
||||
export interface GroupPrivacy {
|
||||
description_privacy?: string,
|
||||
icon_privacy?: string,
|
||||
list_privacy?: string,
|
||||
visibility?: string,
|
||||
name_privacy?: string,
|
||||
metadata_privacy?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
color?: string;
|
||||
privacy?: GroupPrivacy;
|
||||
created?: string;
|
||||
members?: string[];
|
||||
}
|
BIN
dashboard/src/assets/default_avatar.png
Normal file
BIN
dashboard/src/assets/default_avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.4 KiB |
63
dashboard/src/lib/CardsHeader.svelte
Normal file
63
dashboard/src/lib/CardsHeader.svelte
Normal file
@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Modal, CardHeader, CardTitle, Image, Spinner } from 'sveltestrap';
|
||||
import default_avatar from '../assets/default_avatar.png';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
export let item: any;
|
||||
|
||||
let htmlName: string;
|
||||
let nameElement: any;
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
$: if (item.name) htmlName = toHTML(item.name);
|
||||
else htmlName = "";
|
||||
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (nameElement) twemoji.parse(nameElement);
|
||||
}
|
||||
|
||||
$: icon_url = item.avatar_url ? item.avatar_url : item.icon ? item.icon : default_avatar;
|
||||
|
||||
let avatarOpen = false;
|
||||
const toggleAvatarModal = () => (avatarOpen = !avatarOpen);
|
||||
|
||||
// this is the easiest way we can check what type of item the header has
|
||||
// unsure if there's a better way
|
||||
let altText = "icon";
|
||||
if (item.icon) altText = item.name ? `group ${item.name} icon (full size)` : "group icon (full size)";
|
||||
else if (item.proxy_tags) altText = item.name ? `member ${item.name} avatar (full size)` : "member avatar (full size)";
|
||||
else if (item.tag) altText = item.name ? `system ${item.name} avatar (full size)` : "system avatar (full size)";
|
||||
|
||||
export let loading: boolean = false;
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<CardTitle style="margin-top: 0px; margin-bottom: 0px; outline: none; align-items: center;" class="d-flex justify-content-between align-middle w-100">
|
||||
<div>
|
||||
<div class="icon d-inline-block">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<span bind:this={nameElement} style="vertical-align: middle;">{@html htmlName} ({item.id})</span>
|
||||
</div>
|
||||
<div>
|
||||
{#if loading}
|
||||
<div class="d-inline-block mr-5" style="vertical-align: middle;"><Spinner color="primary" /></div>
|
||||
{/if}
|
||||
{#if item && (item.avatar_url || item.icon)}
|
||||
<img tabindex={0} on:keyup={(event) => {if (event.key === "Enter") avatarOpen = true}} on:click={toggleAvatarModal} class="rounded-circle avatar" src={icon_url} alt={altText} />
|
||||
{:else}
|
||||
<img class="rounded-circle avatar" src={default_avatar} alt="icon (default)" />
|
||||
{/if}
|
||||
</div>
|
||||
<Modal isOpen={avatarOpen} toggle={toggleAvatarModal}>
|
||||
<div slot="external" on:click={toggleAvatarModal} style="height: 100%; max-width: 640px; width: 100%; margin-left: auto; margin-right: auto; display: flex;">
|
||||
<img class="d-block m-auto img-thumbnail" src={icon_url} alt={altText} tabindex={0} use:focus/>
|
||||
</div>
|
||||
</Modal>
|
||||
</CardTitle>
|
73
dashboard/src/lib/ListPagination.svelte
Normal file
73
dashboard/src/lib/ListPagination.svelte
Normal file
@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Pagination, PaginationItem, PaginationLink } from "sveltestrap";
|
||||
|
||||
export let currentPage: number;
|
||||
export let pageAmount: number;
|
||||
export let smallPages: boolean = false;
|
||||
|
||||
</script>
|
||||
{#if pageAmount > 1}
|
||||
<Pagination size={smallPages ? "sm" : ""} class="mx-auto" arialabel="member list page navigation">
|
||||
{#if currentPage !== 1}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" previous on:click={(e) => {e.preventDefault(); currentPage -= 1}}></PaginationLink>
|
||||
</PaginationItem>
|
||||
{:else}
|
||||
<PaginationItem disabled>
|
||||
<PaginationLink previous></PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage > 2}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => {e.preventDefault(); currentPage = 1}}>1</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage === 4}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => {e.preventDefault(); currentPage = 2}}>2</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage > 4}
|
||||
<PaginationItem disabled>
|
||||
<PaginationLink>...</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage > 1}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => {e.preventDefault(); currentPage -= 1}}>{currentPage - 1}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
<PaginationItem active>
|
||||
<PaginationLink href="#">{currentPage}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{#if currentPage < pageAmount}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => {e.preventDefault(); currentPage += 1}}>{currentPage + 1}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage < pageAmount - 3}
|
||||
<PaginationItem disabled>
|
||||
<PaginationLink>...</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage === pageAmount - 3}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => {e.preventDefault(); currentPage = pageAmount - 1}}>{pageAmount - 1}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage < pageAmount - 1}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" on:click={(e) => { e.preventDefault(); currentPage = pageAmount}}>{pageAmount}</PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
{#if currentPage !== pageAmount}
|
||||
<PaginationItem>
|
||||
<PaginationLink href="#" next on:click={(e) => {e.preventDefault(); currentPage += 1}}></PaginationLink>
|
||||
</PaginationItem>
|
||||
{:else}
|
||||
<PaginationItem disabled>
|
||||
<PaginationLink next></PaginationLink>
|
||||
</PaginationItem>
|
||||
{/if}
|
||||
</Pagination>
|
||||
{/if}
|
67
dashboard/src/lib/Navigation.svelte
Normal file
67
dashboard/src/lib/Navigation.svelte
Normal file
@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import {Navbar, NavbarBrand, Nav, NavItem, NavLink, Collapse, NavbarToggler, Dropdown, DropdownItem, DropdownMenu, DropdownToggle, Button} from 'sveltestrap';
|
||||
import { loggedIn } from '../stores';
|
||||
import { Link, navigate } from 'svelte-navigator';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export let style: string;
|
||||
|
||||
let isOpen = false;
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
|
||||
let loggedIn_value: boolean;
|
||||
|
||||
loggedIn.subscribe(value => {
|
||||
loggedIn_value = value;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("pk-token");
|
||||
localStorage.removeItem("pk-user");
|
||||
loggedIn.update(() => false);
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
</script>
|
||||
<Navbar color="light" light expand="lg" class="mb-4">
|
||||
<Link to="/" class="navbar-brand"><NavbarBrand tabindex={-1} class="m-0">PluralKit</NavbarBrand></Link>
|
||||
<NavbarToggler on:click={toggle}></NavbarToggler>
|
||||
<Collapse {isOpen} navbar expand="lg">
|
||||
<Nav class="ms-auto" navbar>
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle color="transparent" class="nav-link"><span class="select-text">Styles</span></DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
<DropdownItem on:click={() => style = "light"}>Light</DropdownItem>
|
||||
<DropdownItem on:click={() => style = "dark"}>Dark</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{#if loggedIn_value || localStorage.getItem("pk-token")}
|
||||
<Dropdown nav inNavbar>
|
||||
<DropdownToggle color="transparent" class="nav-link"><span class="select-text">Dash</span></DropdownToggle>
|
||||
<DropdownMenu end>
|
||||
<Link style="text-decoration: none;" to="/dash?tab=system"><DropdownItem tabindex={-1}>System</DropdownItem></Link>
|
||||
<Link style="text-decoration: none;" to="/dash?tab=members"><DropdownItem tabindex={-1}>Members</DropdownItem></Link>
|
||||
<Link style="text-decoration: none;" to="/dash?tab=groups"><DropdownItem tabindex={-1}>Groups</DropdownItem></Link>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={logout}>Log out</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
<NavItem>
|
||||
<Link to="/settings" class="nav-link">Settings</Link>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<Link to="/profile" class="nav-link">Public</Link>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<Link to="/status" class="nav-link">Bot status</Link>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
|
||||
<style>
|
||||
.select-text {
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
127
dashboard/src/lib/group/Body.svelte
Normal file
127
dashboard/src/lib/group/Body.svelte
Normal file
@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Row, Col, Modal, Image, Button, CardBody, ModalHeader, ModalBody, ModalFooter, Spinner } from 'sveltestrap';
|
||||
import moment from 'moment';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
import Edit from './Edit.svelte';
|
||||
import twemoji from 'twemoji';
|
||||
import Privacy from './Privacy.svelte';
|
||||
import MemberEdit from './MemberEdit.svelte';
|
||||
import { Link } from 'svelte-navigator';
|
||||
|
||||
import { Member, Group } from '../../api/types';
|
||||
|
||||
export let group: Group;
|
||||
let editMode: boolean = false;
|
||||
let memberMode: boolean = false;
|
||||
export let isPublic: boolean;
|
||||
export let members: Member[] = [];
|
||||
export let isMainDash = true;
|
||||
export let isPage = false;
|
||||
|
||||
let htmlDescription: string;
|
||||
$: if (group.description) {
|
||||
htmlDescription = toHTML(group.description, {embed: true});
|
||||
} else {
|
||||
htmlDescription = "(no description)";
|
||||
}
|
||||
let htmlDisplayName: string;
|
||||
$: if (group.display_name) htmlDisplayName = toHTML(group.display_name)
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
let descriptionElement: any;
|
||||
let displayNameElement: any;
|
||||
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (descriptionElement) twemoji.parse(descriptionElement);
|
||||
if (displayNameElement) twemoji.parse(displayNameElement);
|
||||
};
|
||||
|
||||
let created = moment(group.created).format("MMM D, YYYY");
|
||||
|
||||
let bannerOpen = false;
|
||||
const toggleBannerModal = () => (bannerOpen = !bannerOpen);
|
||||
|
||||
let privacyOpen = false;
|
||||
const togglePrivacyModal = () => (privacyOpen = !privacyOpen);
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<CardBody style="border-left: 4px solid #{settings && settings.appearance.color_background ? isPage ? "" : group.color : group.color }; margin: -1rem -1.25rem">
|
||||
{#if !editMode && !memberMode}
|
||||
<Row>
|
||||
{#if group.id}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>ID:</b> {group.id}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.name}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Name:</b> {group.name}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.display_name}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Display Name:</b> <span bind:this={displayNameElement}>{@html htmlDisplayName}</span>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.created && !isPublic}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Created:</b> {created}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.color}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Color:</b> {group.color}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.banner}
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<b>Banner:</b> <Button size="sm" color="secondary" on:click={toggleBannerModal} aria-label="view group banner">View</Button>
|
||||
<Modal isOpen={bannerOpen} toggle={toggleBannerModal}>
|
||||
<div slot="external" on:click={toggleBannerModal} style="height: 100%; width: max-content; max-width: 100%; margin-left: auto; margin-right: auto; display: flex;">
|
||||
<img class="img-thumbnail d-block m-auto" src={group.banner} tabindex={0} alt={`Group ${group.name} banner (full size)`} use:focus/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if group.privacy}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Privacy:</b> <Button size="sm" color="secondary" on:click={togglePrivacyModal} aria-label="edit group privacy">Edit</Button>
|
||||
<Modal size="lg" isOpen={privacyOpen} toggle={togglePrivacyModal}>
|
||||
<ModalHeader toggle={togglePrivacyModal}>
|
||||
Edit privacy
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Privacy on:update bind:group bind:privacyOpen={privacyOpen}/>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
<div class="my-2 description" bind:this={descriptionElement}>
|
||||
<b>Description:</b><br />
|
||||
{@html htmlDescription && htmlDescription}
|
||||
</div>
|
||||
{#if (group.banner && ((settings && settings.appearance.banner_bottom) || !settings))}
|
||||
<img src={group.banner} alt="group banner" class="w-100 mb-3 rounded" style="max-height: 12em; object-fit: cover"/>
|
||||
{/if}
|
||||
{#if !isPublic}
|
||||
<Button style="flex: 0" color="primary" on:click={() => editMode = true} aria-label="edit group information">Edit</Button>
|
||||
{#if isMainDash}<Button style="flex: 0" color="secondary" on:click={() => memberMode = true} aria-label="edit group members">Members</Button>{/if}
|
||||
{/if}
|
||||
{#if !isPage}
|
||||
<Link to={isPublic ? `/profile/g/${group.id}` : `/dash/g/${group.id}`}><Button style="flex: 0; {!isPublic && "float: right;"}" color="primary" tabindex={-1} aria-label="view group page">View page</Button></Link>
|
||||
{:else if !isPublic}
|
||||
<Link to="/dash?tab=groups"><Button style="flex: 0; {!isPublic && "float: right;"}" color="primary" tabindex={-1} aria-label="view group system">View system</Button></Link>
|
||||
{/if}
|
||||
{:else if editMode}
|
||||
<Edit on:deletion on:update bind:group bind:editMode />
|
||||
{:else if memberMode}
|
||||
<MemberEdit on:updateMembers bind:group bind:memberMode bind:members />
|
||||
{/if}
|
||||
</CardBody>
|
150
dashboard/src/lib/group/Edit.svelte
Normal file
150
dashboard/src/lib/group/Edit.svelte
Normal file
@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Input, Button, Label, Alert, Spinner, Modal, ModalHeader, ModalBody } from 'sveltestrap';
|
||||
import { createEventDispatcher, tick } from 'svelte';
|
||||
import { Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
import autosize from 'svelte-autosize';
|
||||
|
||||
const descriptions: string[] = JSON.parse(localStorage.getItem("pk-config"))?.description_templates;
|
||||
|
||||
let loading: boolean = false;
|
||||
export let group: Group;
|
||||
export let editMode: boolean;
|
||||
|
||||
let err: string[] = [];
|
||||
|
||||
let input: Group = {...group};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch('update', group);
|
||||
}
|
||||
|
||||
function deletion() {
|
||||
dispatch('deletion', group.id);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = [];
|
||||
|
||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||
err.push(`"${data.color}" is not a valid color, the color must be a 6-digit hex code. (example: #ff0000)`);
|
||||
} else if (data.color) {
|
||||
if (data.color.startsWith("#")) {
|
||||
data.color = input.color.slice(1, input.color.length);
|
||||
}
|
||||
}
|
||||
|
||||
err = err;
|
||||
if (err.length > 0) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().groups(group.id).patch({data});
|
||||
group = {...group, ...res};
|
||||
err = [];
|
||||
update();
|
||||
editMode = false;
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err.push(error.message);
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen: boolean = false;
|
||||
const toggleDeleteModal = () => deleteOpen = !deleteOpen;
|
||||
|
||||
let deleteInput: string;
|
||||
let deleteErr: string;
|
||||
|
||||
async function submitDelete() {
|
||||
deleteErr = null;
|
||||
|
||||
if (!deleteInput) {
|
||||
deleteErr = "Please type out the group ID.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteInput.trim().toLowerCase() !== group.id) {
|
||||
deleteErr = "This group's ID does not match the provided ID.";
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
await api().groups(group.id).delete();
|
||||
deleteErr = null;
|
||||
toggleDeleteModal();
|
||||
loading = false;
|
||||
deletion();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
deleteErr = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each err as error}
|
||||
<Alert color="danger">{@html error}</Alert>
|
||||
{/each}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Name:</Label>
|
||||
<Input bind:value={input.name} maxlength={100} type="text" placeholder={group.name} aria-label="group name" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Display name:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.display_name} maxlength={100} type="text" placeholder={group.display_name} aria-label="group display name"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Color:</Label>
|
||||
<Input bind:value={input.color} type="text" placeholder={group.color} aria-label="group color"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Icon url:</Label>
|
||||
<Input bind:value={input.icon} maxlength={256} type="url" placeholder={group.icon} aria-label="group icon url"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Banner url:</Label>
|
||||
<Input bind:value={input.banner} maxlength={256} type="url" placeholder={group.banner} aria-label="group banner url"/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div class="my-2">
|
||||
<b>Description:</b><br />
|
||||
{#if descriptions.length > 0 && descriptions[0].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[0]} aria-label="use template 1">Template 1</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 1 && descriptions[1].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[1]} aria-label="use template 2">Template 2</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 2 && descriptions[2].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[2]} aria-label="use template 3">Template 3</Button>
|
||||
{/if}
|
||||
<br>
|
||||
<textarea class="form-control" bind:value={input.description} maxlength={1000} use:autosize placeholder={group.description} aria-label="group description"/>
|
||||
</div>
|
||||
{#if !loading}<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit edits">Submit</Button> <Button style="flex: 0" color="secondary" on:click={() => editMode = false} aria-label="cancel edits">Back</Button><Button style="flex: 0; float: right;" color="danger" on:click={toggleDeleteModal} aria-label="delete group">Delete</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled aria-label="submit edits"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel edits">Back</Button><Button style="flex: 0; float: right;" color="danger" disabled aria-label="delete group">Delete</Button>{/if}
|
||||
<Modal size="lg" isOpen={deleteOpen} toggle={toggleDeleteModal}>
|
||||
<ModalHeader toggle={toggleDeleteModal}>
|
||||
Delete member
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if deleteErr}<Alert color="danger">{deleteErr}</Alert>{/if}
|
||||
<Label>If you're sure you want to delete this group, type out the group ID (<code>{group.id}</code>) below.</Label>
|
||||
<input class="mb-3 form-control" bind:value={deleteInput} maxlength={7} placeholder={group.id} aria-label={`type out the group id ${group.id} to confirm deletion`} use:focus>
|
||||
{#if !loading}<Button style="flex 0" color="danger" on:click={submitDelete} aria-label="confirm delete">Delete</Button> <Button style="flex: 0" color="secondary" on:click={toggleDeleteModal} aria-label="cancel delete">Back</Button>
|
||||
{:else}<Button style="flex 0" color="danger" disabled aria-label="confirm delete"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel delete">Back</Button>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
367
dashboard/src/lib/group/List.svelte
Normal file
367
dashboard/src/lib/group/List.svelte
Normal file
@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import { Link } from 'svelte-navigator';
|
||||
import { Card, CardHeader, CardBody, CardTitle, Alert, Accordion, AccordionItem, InputGroupText, InputGroup, Input, Label, Row, Col, Spinner, Button, Tooltip } from 'sveltestrap';
|
||||
import FaUsers from 'svelte-icons/fa/FaUsers.svelte'
|
||||
import { onMount } from 'svelte';
|
||||
import FaSearch from 'svelte-icons/fa/FaSearch.svelte'
|
||||
import { useParams } from 'svelte-navigator';
|
||||
import CardsHeader from '../CardsHeader.svelte';
|
||||
import ListPagination from '../ListPagination.svelte';
|
||||
import Body from './Body.svelte';
|
||||
import Svelecte, { addFormatter } from 'svelecte';
|
||||
import FaLock from 'svelte-icons/fa/FaLock.svelte';
|
||||
import NewGroup from './NewGroup.svelte';
|
||||
|
||||
import { Member, Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let isPublic: boolean;
|
||||
|
||||
export let list: Group[];
|
||||
export let members: Member[];
|
||||
|
||||
$: memberlist = members && members.map(function(member) { return {name: member.name, shortid: member.id, id: member.uuid, display_name: member.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let token = localStorage.getItem("pk-token");
|
||||
let listLoading = true;
|
||||
let err: string;
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
let itemsPerPageValue = settings && settings.accessibility && settings.accessibility.expandedcards ? "10" : "25";
|
||||
$: itemsPerPage = parseInt(itemsPerPageValue);
|
||||
|
||||
let searchBy = "name";
|
||||
let sortBy = "name";
|
||||
let sortOrder = "ascending";
|
||||
let privacyFilter = "all";
|
||||
let memberSearchMode = "include";
|
||||
let selectedMembers = [];
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
let params = useParams();
|
||||
$: id = $params.id;
|
||||
|
||||
onMount(() => {
|
||||
if (token || isPublic) fetchGroups();
|
||||
});
|
||||
|
||||
|
||||
async function fetchGroups() {
|
||||
err = "";
|
||||
listLoading = true;
|
||||
try {
|
||||
const res: Group[] = await api().systems(isPublic ? id : "@me").groups.get({ auth: !isPublic, query: { with_members: !isPublic } });
|
||||
list = res;
|
||||
listLoading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
listLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchValue: string;
|
||||
|
||||
$: {searchValue; privacyFilter; currentPage = 1};
|
||||
|
||||
$: searchedList = list.filter((item) => {
|
||||
if (!searchValue && searchBy !== "description" && searchBy !== "display name") return true;
|
||||
|
||||
switch (searchBy) {
|
||||
case "name": if (item.name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "display name": if (!searchValue) {
|
||||
if (!item.display_name) return true;
|
||||
else return false;
|
||||
}
|
||||
if (item.display_name && item.display_name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "description": if (!searchValue) {
|
||||
if (!item.description) return true;
|
||||
else return false;
|
||||
}
|
||||
else if (item.description && item.description.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "ID": if (item.id.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
default: if (item.name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
|
||||
$: filteredList = searchedList.filter((item) => {
|
||||
if (privacyFilter === "all") return true;
|
||||
if (privacyFilter === "public" && item.privacy.visibility === "public") return true;
|
||||
if (privacyFilter === "private" && item.privacy.visibility === "private") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
let sortedList = [];
|
||||
|
||||
$: if (filteredList) {
|
||||
switch (sortBy) {
|
||||
case "name": sortedList = filteredList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "display name": sortedList = filteredList.sort((a, b) => {
|
||||
if (a.display_name && b.display_name) return a.display_name.localeCompare(b.display_name);
|
||||
else if (a.display_name && !b.display_name) return a.display_name.localeCompare(b.name);
|
||||
else if (!a.display_name && b.display_name) return a.name.localeCompare(b.display_name);
|
||||
else return a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
case "creation date": sortedList = filteredList.sort((a, b) => {
|
||||
if (a.created && b.created) return a.created.localeCompare(b.created);
|
||||
});
|
||||
break;
|
||||
case "ID": sortedList = filteredList.sort((a, b) => a.id.localeCompare(b.id));
|
||||
break;
|
||||
default: sortedList = filteredList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let memberFilteredList = [];
|
||||
$: memberFilteredList = sortedList.filter((item: Group) => {
|
||||
if (memberSearchMode === "none") {
|
||||
if (item.members && item.members.length > 0) return false;
|
||||
}
|
||||
|
||||
if (selectedMembers.length < 1) return true;
|
||||
|
||||
switch (memberSearchMode) {
|
||||
case "include": if (item.members && selectedMembers.some(value => item.members.includes(value))) return true;
|
||||
break;
|
||||
case "exclude": if (item.members && selectedMembers.every(value => !item.members.includes(value))) return true;
|
||||
break;
|
||||
case "match": if (item.members && selectedMembers.every(value => item.members.includes(value))) return true;
|
||||
break;
|
||||
default: return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
let finalList = [];
|
||||
$:{sortOrder; if (sortOrder === "descending") finalList = memberFilteredList.reverse(); else finalList = memberFilteredList;}
|
||||
|
||||
$: finalList = finalList;
|
||||
|
||||
$: indexOfLastItem = currentPage * itemsPerPage;
|
||||
$: indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
$: pageAmount = Math.ceil(finalList.length / itemsPerPage);
|
||||
|
||||
let slicedList = [];
|
||||
$: slicedList = finalList.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
function memberListRenderer(item: any) {
|
||||
return `${item.name} (<code>${item.shortid}</code>)`;
|
||||
}
|
||||
|
||||
addFormatter({
|
||||
'member-list': memberListRenderer
|
||||
});
|
||||
|
||||
function updateList(event: any) {
|
||||
list = list.map(group => group.id !== event.detail.id ? group : event.detail)
|
||||
}
|
||||
|
||||
function updateDelete(event: any) {
|
||||
list = list.filter(group => group.id !== event.detail);
|
||||
}
|
||||
|
||||
function addGroupToList(event: any) {
|
||||
// reference types my beloathed
|
||||
// the stringify/parse sequence is here so that a previous added group doesn't get overwritten
|
||||
let group = JSON.parse(JSON.stringify(event.detail));
|
||||
group.members = [];
|
||||
list.push(group);
|
||||
list = list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaSearch />
|
||||
</div> Search groups
|
||||
</CardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Row>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Page length</InputGroupText>
|
||||
<Input bind:value={itemsPerPageValue} type="select" aria-label="page length">
|
||||
<option>10</option>
|
||||
<option>25</option>
|
||||
<option>50</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Search by</InputGroupText>
|
||||
<Input bind:value={searchBy} type="select" aria-label="search by">
|
||||
<option>name</option>
|
||||
<option>display name</option>
|
||||
<option>description</option>
|
||||
<option>ID</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Sort by</InputGroupText>
|
||||
<Input bind:value={sortBy} type="select" aria-label="sort by">
|
||||
<option>name</option>
|
||||
<option>display name</option>
|
||||
{#if !isPublic}<option>creation date</option>{/if}
|
||||
<option>ID</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Sort order</InputGroupText>
|
||||
<Input bind:value={sortOrder} type="select" aria-label="sort order">
|
||||
<option>ascending</option>
|
||||
<option>descending</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{#if !isPublic}
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Only show</InputGroupText>
|
||||
<Input bind:value={privacyFilter} type="select" aria-label="only show">
|
||||
<option>all</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
{#if !isPublic}
|
||||
<hr/>
|
||||
<Label>Filter groups by member</Label>
|
||||
<Svelecte renderer="member-list" bind:value={selectedMembers} disableHighlight options={memberlist} multiple style="margin-bottom: 0.5rem">
|
||||
</Svelecte>
|
||||
<span style="cursor: pointer" id="g-include" on:click={() => memberSearchMode = "include"} on:keyup={e => e.key === "Enter" ? memberSearchMode = "include" : ""} tabindex={0}>{@html memberSearchMode === "include" ? "<b>include</b>" : "include"}</span>
|
||||
| <span style="cursor: pointer" id="g-exclude" on:click={() => memberSearchMode = "exclude"} on:keyup={e => e.key === "Enter" ? memberSearchMode = "exclude" : ""} tabindex={0}>{@html memberSearchMode === "exclude" ? "<b>exclude</b>" : "exclude"}</span>
|
||||
| <span style="cursor: pointer" id="g-match" on:click={() => memberSearchMode = "match"} on:keyup={e => e.key === "Enter" ? memberSearchMode = "match" : ""} tabindex={0}>{@html memberSearchMode === "match" ? "<b>exact match</b>" : "exact match"}</span>
|
||||
| <span style="cursor: pointer" id="g-none" on:click={() => memberSearchMode = "none"} on:keyup={e => e.key === "Enter" ? memberSearchMode = "none" : ""} tabindex={0}>{@html memberSearchMode === "none" ? "<b>none</b>" : "none"}</span>
|
||||
<Tooltip placement="bottom" target="g-include">Includes every group with any of the members.</Tooltip>
|
||||
<Tooltip placement="bottom" target="g-exclude">Excludes every group with any of the members, opposite of include.</Tooltip>
|
||||
<Tooltip placement="bottom" target="g-match">Only includes groups which have all the members selected.</Tooltip>
|
||||
<Tooltip placement="bottom" target="g-none">Only includes groups that have no members.</Tooltip>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
|
||||
{#if listLoading && !err}
|
||||
<div class="mx-auto text-center">
|
||||
<Spinner class="d-inline-block" />
|
||||
</div>
|
||||
{:else if err}
|
||||
<Row>
|
||||
<Col xs={12} lg={10}>
|
||||
<Alert color="danger">{err}</Alert>
|
||||
</Col>
|
||||
<Col xs={12} lg={2}>
|
||||
<Button class="w-100 mb-3" color="primary" on:click={fetchGroups} aria-label="refresh group list">Refresh</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
|
||||
<Row>
|
||||
<Col xs={12} lg={10} class="mb-2 mb-lg-0">
|
||||
<Input class="mb-3" bind:value={searchValue} placeholder="search by {searchBy}..."/>
|
||||
</Col>
|
||||
<Col xs={12} lg={2}>
|
||||
<Button class="w-100 mb-3" color="primary" on:click={fetchGroups} aria-label="refresh group list">Refresh</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{#if !isPublic}
|
||||
<NewGroup on:create={addGroupToList} />
|
||||
{/if}
|
||||
{#if settings && settings.accessibility ? (!settings.accessibility.expandedcards && !settings.accessibility.pagelinks) : true}
|
||||
<Accordion class="my-3" stayOpen>
|
||||
{#each slicedList as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={group} slot="header">
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateMembers={updateList} bind:members bind:group bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{:else}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={group} slot="header">
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateMembers={updateList} bind:members bind:group bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Accordion>
|
||||
{:else if settings.accessibility.expandedcards}
|
||||
{#each slicedList as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader item={group}>
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateMembers={updateList} isPublic={isPublic} bind:members bind:group />
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader item={group}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateMembers={updateList} isPublic={isPublic} bind:group bind:members />
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="my-3">
|
||||
{#each slicedList as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/g/${group.id}` : `/profile/g/${group.id}`}>
|
||||
<CardsHeader bind:item={group}>
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/g/${group.id}` : `/profile/g/${group.id}`}>
|
||||
<CardsHeader bind:item={group}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{/if}
|
146
dashboard/src/lib/group/MemberEdit.svelte
Normal file
146
dashboard/src/lib/group/MemberEdit.svelte
Normal file
@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Button, Alert, ListGroup, ListGroupItem, Spinner } from 'sveltestrap';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ListPagination from "../ListPagination.svelte";
|
||||
import twemoji from "twemoji";
|
||||
import FaUserPlus from 'svelte-icons/fa/FaUserPlus.svelte'
|
||||
import FaUserFriends from 'svelte-icons/fa/FaUserFriends.svelte'
|
||||
import FaUserMinus from 'svelte-icons/fa/FaUserMinus.svelte'
|
||||
import Svelecte, { addFormatter } from 'svelecte';
|
||||
|
||||
import { Group, Member } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
let loading: boolean = false;
|
||||
let err: string;
|
||||
export let group: Group;
|
||||
export let memberMode: boolean = true;
|
||||
export let members: Member[];
|
||||
|
||||
let membersInGroup: Member[];
|
||||
let membersNotInGroup: Member[];
|
||||
let membersInGroupSelection: any[];
|
||||
let membersNotInGroupSelection: any[];
|
||||
|
||||
let membersToBeAdded: any[];
|
||||
let membersToBeRemoved: any[];
|
||||
|
||||
let currentPage: number = 1;
|
||||
|
||||
let smallPages = true;
|
||||
|
||||
$: if (group.members) {
|
||||
membersInGroup = members.filter(member => group.members.includes(member.uuid));
|
||||
membersInGroup = membersInGroup.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
membersNotInGroup = members.filter(member => !group.members.includes(member.uuid));
|
||||
membersNotInGroup = membersNotInGroup.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
membersInGroupSelection = membersInGroup.map(function(member) { return {name: member.name, shortid: member.id, id: member.uuid, display_name: member.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
membersNotInGroupSelection = membersNotInGroup.map(function(member) { return {name: member.name, shortid: member.id, id: member.uuid, display_name: member.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
$: indexOfLastItem = currentPage * 10;
|
||||
$: indexOfFirstItem = indexOfLastItem - 10;
|
||||
$: pageAmount = Math.ceil(membersInGroup && membersInGroup.length / 10);
|
||||
|
||||
$: finalMemberList = membersInGroup && membersInGroup.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem('pk-settings'));
|
||||
let listGroupElements: any[] = [];
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (listGroupElements && listGroupElements.length > 0) {
|
||||
listGroupElements.forEach(element => element && twemoji.parse(element));
|
||||
};
|
||||
}
|
||||
|
||||
function memberListRenderer(item: any) {
|
||||
return `${item.name} (<code>${item.shortid}</code>)`;
|
||||
}
|
||||
|
||||
addFormatter({
|
||||
'member-list': memberListRenderer
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch("updateMembers", group)
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
let data = membersToBeAdded;
|
||||
try {
|
||||
loading = true;
|
||||
await api().groups(group.id).members.add.post({data});
|
||||
data.forEach(member => group.members.push(member));
|
||||
update();
|
||||
err = null;
|
||||
membersToBeAdded = [];
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemove() {
|
||||
let data = membersToBeRemoved;
|
||||
try {
|
||||
loading = true;
|
||||
await api().groups(group.id).members.remove.post({data});
|
||||
group.members = group.members.filter(m => !data.includes(m));
|
||||
update();
|
||||
err = null;
|
||||
membersToBeRemoved = [];
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Row>
|
||||
<Col xs={12} lg={6} class="text-center mb-3">
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaUserFriends />
|
||||
</div>Current Members</h5>
|
||||
<ListPagination bind:currentPage bind:pageAmount bind:smallPages/>
|
||||
{#if finalMemberList && finalMemberList.length > 0}
|
||||
<ListGroup>
|
||||
{#each finalMemberList as member, index (member.id)}
|
||||
<ListGroupItem class="d-flex"><span bind:this={listGroupElements[index]} class="d-flex justify-content-between flex-grow-1"><span><b>{member.name}</b> (<code>{member.id}</code>)</span> <span>{member.display_name ? `${member.display_name}` : ""}</span></span></ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{:else}
|
||||
<p>There are no members in this group yet.</p>
|
||||
<p>You can add some in this menu!</p>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="text-center mb-3">
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaUserPlus />
|
||||
</div>Add Members</h5>
|
||||
<Svelecte renderer="member-list" disableHighlight bind:value={membersToBeAdded} options={membersNotInGroupSelection} multiple aria-label="search for members to add"/>
|
||||
{#if !loading && membersToBeAdded && membersToBeAdded.length > 0}
|
||||
<Button class="w-100 mt-2" color="primary" on:click={submitAdd} aria-label="add members">Add</Button>{:else}
|
||||
<Button class="w-100 mt-2" color="primary" disabled aria-label="add members">{#if loading}<Spinner size="sm" />{:else}Add{/if}</Button>
|
||||
{/if}
|
||||
<hr/>
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaUserMinus />
|
||||
</div>Remove Members</h5>
|
||||
<Svelecte renderer="member-list" disableHighlight bind:value={membersToBeRemoved} options={membersInGroupSelection} multiple aria-label="search for members to remove"/>
|
||||
{#if !loading && membersToBeRemoved && membersToBeRemoved.length > 0}
|
||||
<Button class="w-100 mt-2" color="primary" on:click={submitRemove} aria-label="remove members">Remove</Button>{:else}
|
||||
<Button class="w-100 mt-2" color="primary" disabled aria-label="remove members">{#if loading}<Spinner size="sm" />{:else}Remove{/if}</Button>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
<Button style="flex: 0" color="secondary" on:click={() => memberMode = false} aria-label="back to group card">Back</Button>
|
175
dashboard/src/lib/group/NewGroup.svelte
Normal file
175
dashboard/src/lib/group/NewGroup.svelte
Normal file
@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Input, Button, Label, Alert, Spinner, Accordion, AccordionItem, CardTitle } from 'sveltestrap';
|
||||
import { Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
import autosize from 'svelte-autosize';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import FaPlus from 'svelte-icons/fa/FaPlus.svelte';
|
||||
|
||||
const descriptions: string[] = JSON.parse(localStorage.getItem("pk-config"))?.description_templates;
|
||||
|
||||
let loading: boolean = false;
|
||||
let err: string[] = [];
|
||||
let message: string;
|
||||
let privacyMode = false;
|
||||
|
||||
let defaultGroup: Group = {
|
||||
privacy: {
|
||||
description_privacy: "public",
|
||||
metadata_privacy: "public",
|
||||
list_privacy: "public",
|
||||
icon_privacy: "public",
|
||||
name_privacy: "public",
|
||||
visibility: "public"
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function create() {
|
||||
dispatch('create', input);
|
||||
}
|
||||
|
||||
let input: Group = defaultGroup;
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
message = "";
|
||||
err = [];
|
||||
|
||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||
err.push(`"${data.color}" is not a valid color, the color must be a 6-digit hex code. (example: #ff0000)`);
|
||||
} else if (data.color) {
|
||||
if (data.color.startsWith("#")) {
|
||||
data.color = input.color.slice(1, input.color.length);
|
||||
}
|
||||
}
|
||||
|
||||
err = err;
|
||||
if (err.length > 0) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().groups().post({data});
|
||||
input = res;
|
||||
err = [];
|
||||
create();
|
||||
message = `Group ${data.name} successfully created!`
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err.push(error.message);
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Accordion class="mb-3">
|
||||
<AccordionItem>
|
||||
<CardTitle slot="header" style="margin-top: 0px; margin-bottom: 0px; outline: none; align-items: center;" class="d-flex align-middle w-100 p-2">
|
||||
<div class="icon d-inline-block">
|
||||
<FaPlus/>
|
||||
</div>
|
||||
<span style="vertical-align: middle;">Add new Group</span>
|
||||
</CardTitle>
|
||||
{#if message}
|
||||
<Alert color="success">{@html message}</Alert>
|
||||
{/if}
|
||||
{#each err as error}
|
||||
<Alert color="danger">{@html error}</Alert>
|
||||
{/each}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Name:</Label>
|
||||
<Input bind:value={input.name} maxlength={100} type="text" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Display name:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.display_name} maxlength={100} type="text"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Color:</Label>
|
||||
<Input bind:value={input.color} type="text"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Icon url:</Label>
|
||||
<Input bind:value={input.icon} maxlength={256} type="url"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Banner url:</Label>
|
||||
<Input bind:value={input.banner} maxlength={256} type="url"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Privacy:</Label>
|
||||
<Button class="w-100" color="secondary" on:click={() => privacyMode = !privacyMode}>Toggle privacy</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if privacyMode}
|
||||
<hr />
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Description:</Label>
|
||||
<Input type="select" bind:value={input.privacy.description_privacy}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Name:</Label>
|
||||
<Input type="select" bind:value={input.privacy.name_privacy}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Member list:</Label>
|
||||
<Input type="select" bind:value={input.privacy.list_privacy}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Icon:</Label>
|
||||
<Input type="select" bind:value={input.privacy.icon_privacy}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Visibility:</Label>
|
||||
<Input type="select" bind:value={input.privacy.visibility}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Metadata:</Label>
|
||||
<Input type="select" bind:value={input.privacy.metadata_privacy}>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
</Row>
|
||||
<hr />
|
||||
{/if}
|
||||
<div class="my-2">
|
||||
<b>Description:</b><br />
|
||||
{#if descriptions.length > 0 && descriptions[0].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[0]}>Template 1</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 1 && descriptions[1].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[1]}>Template 2</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 2 && descriptions[2].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[2]}>Template 3</Button>
|
||||
{/if}
|
||||
<br>
|
||||
<textarea class="form-control" bind:value={input.description} maxlength={1000} use:autosize />
|
||||
</div>
|
||||
{#if !loading && input.name}<Button style="flex: 0" color="primary" on:click={submit}>Submit</Button>
|
||||
{:else if !input.name }<Button style="flex: 0" color="primary" disabled>Submit</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled><Spinner size="sm"/></Button>{/if}
|
||||
</AccordionItem>
|
||||
</Accordion>
|
119
dashboard/src/lib/group/Privacy.svelte
Normal file
119
dashboard/src/lib/group/Privacy.svelte
Normal file
@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { ModalBody, ModalHeader, Col, Row, Input, Label, ModalFooter, Button, Spinner, Alert } from "sveltestrap";
|
||||
|
||||
import { Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let privacyOpen: boolean;
|
||||
export let group: Group;
|
||||
const togglePrivacyModal = () => (privacyOpen = !privacyOpen);
|
||||
|
||||
let err: string;
|
||||
let loading = false;
|
||||
|
||||
let allPrivacy: string;
|
||||
|
||||
$: { changePrivacy(allPrivacy)}
|
||||
|
||||
function changePrivacy(value: string) {
|
||||
if (value) {
|
||||
input.privacy.description_privacy = value;
|
||||
input.privacy.list_privacy = value;
|
||||
input.privacy.visibility = value;
|
||||
input.privacy.icon_privacy = value;
|
||||
input.privacy.name_privacy = value;
|
||||
input.privacy.metadata_privacy = value;
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch('update', group);
|
||||
}
|
||||
|
||||
let input: Group = {privacy: group.privacy};
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = null;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().groups(group.id).patch({data});
|
||||
group = {...group, ...res};
|
||||
update();
|
||||
loading = false;
|
||||
togglePrivacyModal();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Label><b>Set all to:</b></Label>
|
||||
<select class="form-select" bind:value={allPrivacy} use:focus aria-label="set all to">
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</select>
|
||||
<hr />
|
||||
<Row>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Description:</Label>
|
||||
<Input type="select" bind:value={input.privacy.description_privacy} aria-label="group description privacy">
|
||||
<option default={group.privacy.description_privacy === "public"}>public</option>
|
||||
<option default={group.privacy.description_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Name:</Label>
|
||||
<Input type="select" bind:value={input.privacy.name_privacy} aria-label="group name privacy">
|
||||
<option default={group.privacy.name_privacy === "public"}>public</option>
|
||||
<option default={group.privacy.name_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Member list:</Label>
|
||||
<Input type="select" bind:value={input.privacy.list_privacy} aria-label="group member list privacy">
|
||||
<option default={group.privacy.list_privacy === "public"}>public</option>
|
||||
<option default={group.privacy.list_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Icon:</Label>
|
||||
<Input type="select" bind:value={input.privacy.icon_privacy} aria-label="group icon privacy">
|
||||
<option default={group.privacy.icon_privacy === "public"}>public</option>
|
||||
<option default={group.privacy.icon_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Visibility:</Label>
|
||||
<Input type="select" bind:value={input.privacy.visibility} aria-label="group visibility privacy">
|
||||
<option default={group.privacy.visibility === "public"}>public</option>
|
||||
<option default={group.privacy.visibility === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Metadata:</Label>
|
||||
<Input type="select" bind:value={input.privacy.metadata_privacy} aria-label="group metadata privacy">
|
||||
<option default={group.privacy.metadata_privacy === "public"}>public</option>
|
||||
<option default={group.privacy.metadata_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if !loading}<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit privacy edits">Submit</Button> <Button style="flex: 0" color="secondary" on:click={togglePrivacyModal} aria-label="cancel privacy edits">Back</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled aria-label="submit privacy edits"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel privacy edits">Back</Button>
|
||||
{/if}
|
167
dashboard/src/lib/member/Body.svelte
Normal file
167
dashboard/src/lib/member/Body.svelte
Normal file
@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { Row, Col, Modal, Image, Button, CardBody, ModalHeader, ModalBody } from 'sveltestrap';
|
||||
import moment from 'moment';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
import GroupEdit from './GroupEdit.svelte';
|
||||
import Edit from './Edit.svelte';
|
||||
import Privacy from './Privacy.svelte';
|
||||
import ProxyTags from './ProxyTags.svelte';
|
||||
|
||||
import { Member, Group } from '../../api/types';
|
||||
import { Link } from 'svelte-navigator';
|
||||
|
||||
export let groups: Group[] = [];
|
||||
export let member: Member;
|
||||
export let isPublic: boolean = false;
|
||||
export let isPage: boolean = false;
|
||||
export let isMainDash = true;
|
||||
|
||||
let editMode: boolean = false;
|
||||
let groupMode: boolean = false;
|
||||
|
||||
let htmlDescription: string;
|
||||
$: if (member.description) {
|
||||
htmlDescription = toHTML(member.description, {embed: true});
|
||||
} else {
|
||||
htmlDescription = "(no description)";
|
||||
}
|
||||
|
||||
let htmlPronouns: string;
|
||||
$: if (member.pronouns) {
|
||||
htmlPronouns = toHTML(member.pronouns, {embed: true});
|
||||
}
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
let descriptionElement: any;
|
||||
let displayNameElement: any;
|
||||
let pronounElement: any;
|
||||
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (descriptionElement) twemoji.parse(descriptionElement);
|
||||
if (displayNameElement) twemoji.parse(displayNameElement);
|
||||
if (pronounElement) twemoji.parse(pronounElement);
|
||||
};
|
||||
|
||||
let bannerOpen = false;
|
||||
const toggleBannerModal = () => (bannerOpen = !bannerOpen);
|
||||
|
||||
let privacyOpen = false;
|
||||
const togglePrivacyModal = () => (privacyOpen = !privacyOpen);
|
||||
|
||||
let proxyOpen = false;
|
||||
const toggleProxyModal = () => (proxyOpen = !proxyOpen);
|
||||
|
||||
let created = moment(member.created).format("MMM D, YYYY");
|
||||
let birthday: string;
|
||||
$: {member.birthday;
|
||||
if (member.birthday) birthday = moment(member.birthday, "YYYY-MM-DD").format("MMM D, YYYY");
|
||||
}
|
||||
|
||||
$: trimmedBirthday = birthday && birthday.endsWith(', 0004') ? trimmedBirthday = birthday.replace(', 0004', '') : birthday;
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<CardBody style="border-left: 4px solid #{settings && settings.appearance.color_background ? isPage ? "" : member.color : member.color }; margin: -1rem -1.25rem">
|
||||
{#if !editMode && !groupMode}
|
||||
<Row>
|
||||
{#if member.id}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>ID:</b> {member.id}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.name}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Name:</b> {member.name}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.display_name}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Display Name:</b> <span bind:this={displayNameElement}>{member.display_name}</span>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.pronouns}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Pronouns:</b> <span bind:this={pronounElement}>{@html htmlPronouns}</span>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.birthday}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Birthday:</b> {trimmedBirthday}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.created}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Created:</b> {created}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.color}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Color:</b> {member.color}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.banner}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Banner:</b> <Button size="sm" color="secondary" on:click={toggleBannerModal} aria-label="view member banner">View</Button>
|
||||
<Modal isOpen={bannerOpen} toggle={toggleBannerModal}>
|
||||
<div slot="external" on:click={toggleBannerModal} style="height: 100%; width: max-content; max-width: 100%; margin-left: auto; margin-right: auto; display: flex;">
|
||||
<img class="img-thumbnail d-block m-auto" src={member.banner} tabindex={0} alt={`Member ${member.name} banner (full size)`} use:focus/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.privacy && !isPublic}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Privacy:</b> <Button size="sm" color="secondary" on:click={togglePrivacyModal} aria-label="edit member privacy">Edit</Button>
|
||||
<Modal size="lg" isOpen={privacyOpen} toggle={togglePrivacyModal}>
|
||||
<ModalHeader toggle={togglePrivacyModal}>
|
||||
Edit privacy
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Privacy on:update bind:member bind:privacyOpen/>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if member.proxy_tags && !isPublic}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Proxy Tags:</b> <Button size="sm" color="secondary" on:click={toggleProxyModal} aria-label="edit member proxy tags">Edit</Button>
|
||||
<Modal size="lg" isOpen={proxyOpen} toggle={toggleProxyModal}>
|
||||
<ModalHeader toggle={toggleProxyModal}>
|
||||
Edit proxy tags
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<ProxyTags on:update bind:member bind:proxyOpen/>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
<div class="my-2 mb-3 description" bind:this={descriptionElement}>
|
||||
<b>Description:</b><br />
|
||||
{@html htmlDescription && htmlDescription}
|
||||
</div>
|
||||
{#if (member.banner && ((settings && settings.appearance.banner_bottom) || !settings))}
|
||||
<img src={member.banner} alt="member banner" class="w-100 mb-3 rounded" style="max-height: 17em; object-fit: cover"/>
|
||||
{/if}
|
||||
{#if !isPublic}
|
||||
<Button style="flex: 0" color="primary" on:click={() => editMode = true} aria-label="edit member information">Edit</Button>
|
||||
{#if isMainDash}<Button style="flex: 0" color="secondary" on:click={() => groupMode = true} aria-label="edit member groups">Groups</Button>{/if}
|
||||
{/if}
|
||||
{#if !isPage}
|
||||
<Link to={isPublic ? `/profile/m/${member.id}` : `/dash/m/${member.id}`}><Button style="flex: 0; {!isPublic && "float: right;"}" color="primary" tabindex={-1} aria-label="view member page">View page</Button></Link>
|
||||
{:else}
|
||||
<Link to={isPublic ? `/profile/s/${member.system}?tab=members` : "/dash?tab=members"}><Button style="flex: 0; {!isPublic && "float: right;"}" color="primary" tabindex={-1} aria-label="view member's system">View system</Button></Link>
|
||||
{/if}
|
||||
{:else if editMode}
|
||||
<Edit on:deletion on:update bind:member bind:editMode />
|
||||
{:else if groupMode}
|
||||
<GroupEdit on:updateGroups bind:member bind:groups bind:groupMode />
|
||||
{/if}
|
||||
</CardBody>
|
173
dashboard/src/lib/member/Edit.svelte
Normal file
173
dashboard/src/lib/member/Edit.svelte
Normal file
@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Input, Button, Label, Alert, Spinner, Modal, ModalHeader, ModalBody } from 'sveltestrap';
|
||||
import { createEventDispatcher, tick } from 'svelte';
|
||||
import autosize from 'svelte-autosize';
|
||||
import moment from 'moment';
|
||||
|
||||
import { Member } from '../../api/types'
|
||||
import api from '../../api';
|
||||
|
||||
const descriptions: string[] = JSON.parse(localStorage.getItem("pk-config"))?.description_templates;
|
||||
|
||||
let loading: boolean = false;
|
||||
export let member: Member;
|
||||
export let editMode: boolean;
|
||||
|
||||
let err: string[] = [];
|
||||
|
||||
let input: Member = {...member};
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch('update', member);
|
||||
}
|
||||
|
||||
function deletion() {
|
||||
dispatch('deletion', member.id);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = [];
|
||||
|
||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||
err.push(`"${data.color}" is not a valid color, the color must be a 6-digit hex code. (example: #ff0000)`);
|
||||
} else if (data.color) {
|
||||
if (data.color.startsWith("#")) {
|
||||
data.color = input.color.slice(1, input.color.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.birthday) {
|
||||
if (!moment(data.birthday, 'YYYY-MM-DD').isValid()) {
|
||||
if (moment(data.birthday, 'MM-DD').isValid()) {
|
||||
data.birthday = '0004-' + data.birthday;
|
||||
} else {
|
||||
err.push(`${data.birthday} is not a valid date, please use the following format: YYYY-MM-DD. (example: 2019-07-21)`);
|
||||
}
|
||||
}
|
||||
if (data.birthday.includes('/')) {
|
||||
data.birthday.replace('/', '-');
|
||||
}
|
||||
}
|
||||
|
||||
err = err;
|
||||
if (err.length > 0) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().members(member.id).patch({data});
|
||||
member = res;
|
||||
err = [];
|
||||
update();
|
||||
editMode = false;
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err.push(error.message);
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let deleteOpen: boolean = false;
|
||||
const toggleDeleteModal = () => deleteOpen = !deleteOpen;
|
||||
|
||||
let deleteInput: string;
|
||||
let deleteErr: string;
|
||||
|
||||
async function submitDelete() {
|
||||
deleteErr = null;
|
||||
|
||||
if (!deleteInput) {
|
||||
deleteErr = "Please type out the member ID.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteInput.trim().toLowerCase() !== member.id) {
|
||||
deleteErr = "This member's ID does not match the provided ID.";
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
await api().members(member.id).delete();
|
||||
deleteErr = null;
|
||||
toggleDeleteModal();
|
||||
loading = false;
|
||||
deletion();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
deleteErr = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each err as error}
|
||||
<Alert color="danger">{@html error}</Alert>
|
||||
{/each}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Name:</Label>
|
||||
<Input bind:value={input.name} maxlength={100} type="text" placeholder={member.name} aria-label="member name"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Display name:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.display_name} maxlength={100} type="text" placeholder={member.display_name} aria-label="member display name" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Pronouns:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.pronouns} maxlength={100} type="text" placeholder={member.pronouns} aria-label="member pronouns" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Birthday:</Label>
|
||||
<Input bind:value={input.birthday} maxlength={100} type="text" placeholder={member.birthday} aria-label="member birthday" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Color:</Label>
|
||||
<Input bind:value={input.color} type="text" placeholder={member.color} aria-label="member color" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Avatar url:</Label>
|
||||
<Input bind:value={input.avatar_url} maxlength={256} type="url" placeholder={member.avatar_url} aria-label="member avatar url"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Banner url:</Label>
|
||||
<Input bind:value={input.banner} maxlength={256} type="url" placeholder={member.banner} aria-label="member banner url"/>
|
||||
</Col>
|
||||
</Row>
|
||||
<div class="my-2">
|
||||
<b>Description:</b><br />
|
||||
{#if descriptions.length > 0 && descriptions[0].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[0]} aria-label="use template 1">Template 1</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 1 && descriptions[1].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[1]} aria-label="use template 2">Template 2</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 2 && descriptions[2].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[2]} aria-label="use template 3">Template 3</Button>
|
||||
{/if}
|
||||
<br>
|
||||
<textarea class="form-control" bind:value={input.description} maxlength={1000} use:autosize placeholder={member.description} aria-label="member description"/>
|
||||
</div>
|
||||
{#if !loading}<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit edits" >Submit</Button> <Button style="flex: 0" color="secondary" on:click={() => editMode = false} aria-label="cancel edits">Back</Button><Button style="flex: 0; float: right;" color="danger" on:click={toggleDeleteModal} aria-label="delete member">Delete</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled aria-label="submit edits"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel edits">Back</Button><Button style="flex: 0; float: right;" color="danger" disabled aria-label="delete member">Delete</Button>{/if}
|
||||
<Modal size="lg" isOpen={deleteOpen} toggle={toggleDeleteModal}>
|
||||
<ModalHeader toggle={toggleDeleteModal}>
|
||||
Delete member
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if deleteErr}<Alert color="danger">{deleteErr}</Alert>{/if}
|
||||
<Label>If you're sure you want to delete this member, type out the member ID (<code>{member.id}</code>) below.</Label>
|
||||
<input class="mb-3 form-control" bind:value={deleteInput} maxlength={7} placeholder={member.id} aria-label={`type out the member id ${member.id} to confirm deletion`} use:focus>
|
||||
{#if !loading}<Button style="flex 0" color="danger" on:click={submitDelete} aria-label="confirm delete">Delete</Button> <Button style="flex: 0" color="secondary" on:click={toggleDeleteModal} aria-label="cancel deletion">Back</Button>
|
||||
{:else}<Button style="flex 0" color="danger" disabled><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel deletion">Back</Button>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
147
dashboard/src/lib/member/GroupEdit.svelte
Normal file
147
dashboard/src/lib/member/GroupEdit.svelte
Normal file
@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Button, Alert, ListGroup, ListGroupItem, Spinner } from 'sveltestrap';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ListPagination from "../ListPagination.svelte";
|
||||
import twemoji from "twemoji";
|
||||
import Svelecte, { addFormatter } from 'svelecte';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
|
||||
import FaFolderOpen from 'svelte-icons/fa/FaFolderOpen.svelte'
|
||||
import FaFolderPlus from 'svelte-icons/fa/FaFolderPlus.svelte'
|
||||
import FaFolderMinus from 'svelte-icons/fa/FaFolderMinus.svelte'
|
||||
|
||||
import { Member, Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let member: Member;
|
||||
export let groups: Group[] = [];
|
||||
let loading: boolean = false;
|
||||
let err: string;
|
||||
export let groupMode: boolean = true;
|
||||
|
||||
let groupsWithMember: Group[];
|
||||
let groupsWithoutMember: Group[];
|
||||
let groupsWithMemberSelection: any[];
|
||||
let groupsWithoutMemberSelection: any[];
|
||||
|
||||
let groupsToBeAdded: any[];
|
||||
let groupsToBeRemoved: any[];
|
||||
|
||||
let currentPage = 1;
|
||||
let smallPages = true;
|
||||
|
||||
$: if (groups) {
|
||||
groupsWithMember = groups.filter(group => group.members && group.members.includes(member.uuid));
|
||||
groupsWithMember.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
groupsWithoutMember = groups.filter(group => group.members && !group.members.includes(member.uuid));
|
||||
groupsWithoutMember.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
groupsWithMemberSelection = groupsWithMember.map(function(group) { return {name: group.name, shortid: group.id, id: group.uuid, members: group.members, display_name: group.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
groupsWithoutMemberSelection = groupsWithoutMember.map(function(group) { return {name: group.name, shortid: group.id, id: group.uuid, members: group.members, display_name: group.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
$: indexOfLastItem = currentPage * 10;
|
||||
$: indexOfFirstItem = indexOfLastItem - 10;
|
||||
$: pageAmount = Math.ceil(groupsWithMember && groupsWithMember.length / 10);
|
||||
|
||||
$: finalGroupsList = groupsWithMember && groupsWithMember.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem('pk-settings'));
|
||||
let listGroupElements: any[] = [];
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (listGroupElements && listGroupElements.length > 0) {
|
||||
listGroupElements.forEach(element => element && twemoji.parse(element));
|
||||
};
|
||||
}
|
||||
|
||||
function groupListRenderer(item: any) {
|
||||
return `${item.name} (<code>${item.shortid}</code>)`;
|
||||
}
|
||||
|
||||
addFormatter({
|
||||
'member-list': groupListRenderer
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function updateGroups() {
|
||||
dispatch("updateGroups", groups);
|
||||
}
|
||||
|
||||
async function submitAdd() {
|
||||
let data = groupsToBeAdded;
|
||||
try {
|
||||
loading = true;
|
||||
await api().members(member.id).groups.add.post({data});
|
||||
groups.forEach(group => data.includes(group.uuid) && group.members.push(member.uuid));
|
||||
updateGroups();
|
||||
err = null;
|
||||
groupsToBeAdded = [];
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemove() {
|
||||
let data = groupsToBeRemoved;
|
||||
try {
|
||||
loading = true;
|
||||
await api().members(member.id).groups.remove.post({data});
|
||||
groups.forEach(group => {if (data.includes(group.uuid)) group.members = group.members.filter(m => m !== member.uuid)});
|
||||
updateGroups();
|
||||
err = null;
|
||||
groupsToBeRemoved = [];
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Row>
|
||||
<Col xs={12} lg={6} class="text-center mb-3">
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaFolderOpen />
|
||||
</div>Current Groups</h5>
|
||||
<ListPagination bind:currentPage bind:pageAmount bind:smallPages/>
|
||||
{#if finalGroupsList && finalGroupsList.length > 0}
|
||||
<ListGroup>
|
||||
{#each finalGroupsList as group, index (group.id)}
|
||||
<ListGroupItem class="d-flex"><span bind:this={listGroupElements[index]} class="d-flex justify-content-between flex-grow-1"><span><b>{group.name}</b> (<code>{group.id}</code>)</span> <span>{@html group.display_name ? `${toHTML(group.display_name)}` : ""}</span></span></ListGroupItem>
|
||||
{/each}
|
||||
</ListGroup>
|
||||
{:else}
|
||||
<p>This member is inside no groups.</p>
|
||||
<p>You can add groups in this menu!</p>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="text-center mb-3">
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaFolderPlus />
|
||||
</div>Add to Groups</h5>
|
||||
<Svelecte renderer="member-list" disableHighlight bind:value={groupsToBeAdded} options={groupsWithoutMemberSelection} multiple aria-label="search for groups to add"/>
|
||||
{#if !loading && groupsToBeAdded && groupsToBeAdded.length > 0}
|
||||
<Button class="w-100 mt-2" color="primary" on:click={submitAdd} aria-label="add groups to member">Add</Button>{:else}
|
||||
<Button class="w-100 mt-2" color="primary" disabled aria-label="add groups to member">{#if loading}<Spinner size="sm" />{:else}Add{/if}</Button>
|
||||
{/if}
|
||||
<hr/>
|
||||
<h5><div class="icon d-inline-block">
|
||||
<FaFolderMinus />
|
||||
</div>Remove from Groups</h5>
|
||||
<Svelecte renderer="member-list" disableHighlight bind:value={groupsToBeRemoved} options={groupsWithMemberSelection} multiple aria-label="search for groups to remove"/>
|
||||
{#if !loading && groupsToBeRemoved && groupsToBeRemoved.length > 0}
|
||||
<Button class="w-100 mt-2" color="primary" on:click={submitRemove} aria-label="remove groups from member">Remove</Button>{:else}
|
||||
<Button class="w-100 mt-2" color="primary" disabled aria-label="remove groups from member">{#if loading}<Spinner size="sm" />{:else}Remove{/if}</Button>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
<Button style="flex: 0" color="secondary" on:click={() => groupMode = false} aria-label="back to member card">Back</Button>
|
366
dashboard/src/lib/member/List.svelte
Normal file
366
dashboard/src/lib/member/List.svelte
Normal file
@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import { Link } from 'svelte-navigator';
|
||||
import { Card, CardHeader, CardBody, CardTitle, Alert, Accordion, AccordionItem, InputGroupText, InputGroup, Input, Row, Col, Spinner, Button, Tooltip, Label } from 'sveltestrap';
|
||||
import FaUserCircle from 'svelte-icons/fa/FaUserCircle.svelte'
|
||||
import { onMount } from 'svelte';
|
||||
import FaSearch from 'svelte-icons/fa/FaSearch.svelte'
|
||||
import { useParams } from 'svelte-navigator';
|
||||
import CardsHeader from '../CardsHeader.svelte';
|
||||
import ListPagination from '../ListPagination.svelte';
|
||||
import Svelecte, { addFormatter } from 'svelecte';
|
||||
import FaLock from 'svelte-icons/fa/FaLock.svelte';
|
||||
import Body from './Body.svelte';
|
||||
import NewMember from './NewMember.svelte';
|
||||
|
||||
import { Member, Group } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let isPublic: boolean;
|
||||
|
||||
export let list: Member[] = [];
|
||||
export let groups: Group[] = [];
|
||||
export let isMainDash = true;
|
||||
|
||||
$: grouplist = groups && groups.map(function(group) { return {name: group.name, shortid: group.id, id: group.uuid, members: group.members, display_name: group.display_name}; }).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
let token = localStorage.getItem("pk-token");
|
||||
let listLoading = true;
|
||||
let err: string;
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
let itemsPerPageValue = settings && settings.accessibility && settings.accessibility.expandedcards ? "10" : "25";
|
||||
$: itemsPerPage = parseInt(itemsPerPageValue);
|
||||
|
||||
let searchBy = "name";
|
||||
let sortBy = "name";
|
||||
let sortOrder = "ascending";
|
||||
let privacyFilter = "all";
|
||||
let groupSearchMode = "include";
|
||||
let selectedGroups = [];
|
||||
|
||||
let currentPage = 1;
|
||||
|
||||
let params = useParams();
|
||||
$: id = $params.id;
|
||||
|
||||
onMount(() => {
|
||||
if (token || isPublic) fetchMembers();
|
||||
});
|
||||
|
||||
async function fetchMembers() {
|
||||
err = "";
|
||||
listLoading = true;
|
||||
try {
|
||||
const res: Member[] = await api().systems(isPublic ? id : "@me").members.get({ auth: !isPublic });
|
||||
list = res;
|
||||
listLoading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
listLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let searchValue: string;
|
||||
|
||||
$: {searchValue; privacyFilter; currentPage = 1};
|
||||
|
||||
$: searchedList = list.filter((item) => {
|
||||
if (!searchValue && searchBy !== "description" && searchBy !== "display name") return true;
|
||||
|
||||
switch (searchBy) {
|
||||
case "name": if (item.name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "display name": if (!searchValue) {
|
||||
if (!item.display_name) return true;
|
||||
else return false;
|
||||
}
|
||||
if (item.display_name && item.display_name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "description": if (!searchValue) {
|
||||
if (!item.description) return true;
|
||||
else return false;
|
||||
}
|
||||
else if (item.description && item.description.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
case "ID": if (item.id.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
default: if (item.name.toLowerCase().includes(searchValue.toLowerCase())) return true;
|
||||
break;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
|
||||
$: filteredList = searchedList.filter((item) => {
|
||||
if (privacyFilter === "all") return true;
|
||||
if (privacyFilter === "public" && item.privacy.visibility === "public") return true;
|
||||
if (privacyFilter === "private" && item.privacy.visibility === "private") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
let sortedList = [];
|
||||
|
||||
$: if (filteredList) {
|
||||
switch (sortBy) {
|
||||
case "name": sortedList = filteredList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
case "display name": sortedList = filteredList.sort((a, b) => {
|
||||
if (a.display_name && b.display_name) return a.display_name.localeCompare(b.display_name);
|
||||
else if (a.display_name && !b.display_name) return a.display_name.localeCompare(b.name);
|
||||
else if (!a.display_name && b.display_name) return a.name.localeCompare(b.display_name);
|
||||
else return a.name.localeCompare(b.name);
|
||||
});
|
||||
break;
|
||||
case "creation date": sortedList = filteredList.sort((a, b) => {
|
||||
if (a.created && b.created) return a.created.localeCompare(b.created);
|
||||
});
|
||||
break;
|
||||
case "ID": sortedList = filteredList.sort((a, b) => a.id.localeCompare(b.id));
|
||||
break;
|
||||
default: sortedList = filteredList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let memberFilteredList = [];
|
||||
$: memberFilteredList = sortedList.filter((item: Member) => {
|
||||
if (groupSearchMode === "none") {
|
||||
if (groups.some(group => group.members && group.members.includes(item.uuid))) return false;
|
||||
}
|
||||
|
||||
if (selectedGroups.length < 1) return true;
|
||||
|
||||
switch (groupSearchMode) {
|
||||
case "include": if (selectedGroups.some(group => group.members && group.members.includes(item.uuid))) return true;
|
||||
break;
|
||||
case "exclude": if (selectedGroups.every(group => group.members && !group.members.includes(item.uuid))) return true;
|
||||
break;
|
||||
case "match": if (selectedGroups.every(group => group.members && group.members.includes(item.uuid))) return true;
|
||||
break;
|
||||
default: return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
|
||||
let finalList = [];
|
||||
$:{sortOrder; if (sortOrder === "descending") finalList = memberFilteredList.reverse(); else finalList = memberFilteredList;}
|
||||
|
||||
$: finalList = finalList;
|
||||
|
||||
$: indexOfLastItem = currentPage * itemsPerPage;
|
||||
$: indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
$: pageAmount = Math.ceil(finalList.length / itemsPerPage);
|
||||
|
||||
$: slicedList = finalList.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
function groupListRenderer(item: any) {
|
||||
return `${item.name} (<code>${item.shortid}</code>)`;
|
||||
}
|
||||
|
||||
addFormatter({
|
||||
'member-list': groupListRenderer
|
||||
});
|
||||
|
||||
function updateList(event: any) {
|
||||
list = list.map(member => member.id !== event.detail.id ? member : event.detail);
|
||||
}
|
||||
|
||||
function updateGroups(event: any) {
|
||||
groups = event.detail;
|
||||
}
|
||||
|
||||
function updateDelete(event: any) {
|
||||
list = list.filter(member => member.id !== event.detail);
|
||||
}
|
||||
|
||||
function addMemberToList(event: any) {
|
||||
list.push(event.detail);
|
||||
list = list;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaSearch />
|
||||
</div> Search members
|
||||
</CardTitle>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Row>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Page length</InputGroupText>
|
||||
<Input bind:value={itemsPerPageValue} type="select" aria-label="page length">
|
||||
<option>10</option>
|
||||
<option>25</option>
|
||||
<option>50</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Search by</InputGroupText>
|
||||
<Input bind:value={searchBy} type="select" aria-label="search by">
|
||||
<option>name</option>
|
||||
<option>display name</option>
|
||||
<option>description</option>
|
||||
<option>ID</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Sort by</InputGroupText>
|
||||
<Input bind:value={sortBy} type="select" aria-label="sort by">
|
||||
<option>name</option>
|
||||
<option>display name</option>
|
||||
{#if !isPublic}<option>creation date</option>{/if}
|
||||
<option>ID</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Sort order</InputGroupText>
|
||||
<Input bind:value={sortOrder} type="select" aria-label="sort order">
|
||||
<option>ascending</option>
|
||||
<option>descending</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{#if !isPublic}
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<InputGroup>
|
||||
<InputGroupText>Only show</InputGroupText>
|
||||
<Input bind:value={privacyFilter} type="select" aria-label="only show">
|
||||
<option>all</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
{#if !isPublic}
|
||||
<hr/>
|
||||
<Label>Filter members by group</Label>
|
||||
<Svelecte disableHighlight renderer="member-list" valueAsObject bind:value={selectedGroups} options={grouplist} multiple style="margin-bottom: 0.5rem">
|
||||
</Svelecte>
|
||||
<span style="cursor: pointer" id="m-include" on:click={() => groupSearchMode = "include"} on:keyup={e => e.key === "Enter" ? groupSearchMode = "include" : ""} tabindex={0}>{@html groupSearchMode === "include" ? "<b>include</b>" : "include"}</span>
|
||||
| <span style="cursor: pointer" id="m-exclude" on:click={() => groupSearchMode = "exclude"} on:keyup={e => e.key === "Enter" ? groupSearchMode = "exclude" : ""} tabindex={0}>{@html groupSearchMode === "exclude" ? "<b>exclude</b>" : "exclude"}</span>
|
||||
| <span style="cursor: pointer" id="m-match" on:click={() => groupSearchMode = "match"} on:keyup={e => e.key === "Enter" ? groupSearchMode = "match" : ""} tabindex={0}>{@html groupSearchMode === "match" ? "<b>exact match</b>" : "exact match"}</span>
|
||||
| <span style="cursor: pointer" id="m-none" on:click={() => groupSearchMode = "none"} on:keyup={e => e.key === "Enter" ? groupSearchMode = "none" : ""} tabindex={0}>{@html groupSearchMode === "none" ? "<b>none</b>" : "none"}</span>
|
||||
<Tooltip placement="bottom" target="m-include">Includes every member who's a part of any of the groups.</Tooltip>
|
||||
<Tooltip placement="bottom" target="m-exclude">Excludes every member who's a part of any of the groups, the opposite of include.</Tooltip>
|
||||
<Tooltip placement="bottom" target="m-match">Only includes members who are a part of every group.</Tooltip>
|
||||
<Tooltip placement="bottom" target="m-none">Only includes members that are in no groups.</Tooltip>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
|
||||
{#if listLoading && !err}
|
||||
<div class="mx-auto text-center">
|
||||
<Spinner class="d-inline-block" />
|
||||
</div>
|
||||
{:else if err}
|
||||
<Row>
|
||||
<Col xs={12} lg={10}>
|
||||
<Alert color="danger">{err}</Alert>
|
||||
</Col>
|
||||
<Col xs={12} lg={2}>
|
||||
<Button class="w-100 mb-3" color="primary" on:click={fetchMembers} aria-label="refresh member list">Refresh</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
|
||||
<Row>
|
||||
<Col xs={12} lg={10}>
|
||||
<Input class="mb-3" bind:value={searchValue} placeholder="search by {searchBy}..."/>
|
||||
</Col>
|
||||
<Col xs={12} lg={2} class="mb-3 mb-lg-0">
|
||||
<Button class="w-100 mb-3" color="primary" on:click={fetchMembers} aria-label="refresh member list">Refresh</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{#if !isPublic}
|
||||
<NewMember on:create={addMemberToList} />
|
||||
{/if}
|
||||
{#if settings && settings.accessibility ? (!settings.accessibility.expandedcards && !settings.accessibility.pagelinks) : true}
|
||||
<Accordion class="my-3" stayOpen>
|
||||
{#each slicedList as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={member} slot="header">
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateGroups={updateGroups} bind:isPublic bind:groups bind:member />
|
||||
</AccordionItem>
|
||||
{:else}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={member} slot="header">
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateGroups={updateGroups} bind:isPublic bind:groups bind:member />
|
||||
</AccordionItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Accordion>
|
||||
{:else if settings.accessibility.expandedcards}
|
||||
{#each slicedList as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateGroups={updateGroups} bind:isPublic bind:groups bind:member />
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:update={updateList} on:updateGroups={updateGroups} bind:isPublic bind:groups bind:member />
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="my-3">
|
||||
{#each slicedList as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={isMainDash ? `/dash/m/${member.id}` : `/profile/m/${member.id}`}>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={isMainDash ? `/dash/m/${member.id}` : `/profile/m/${member.id}`}>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{/if}
|
240
dashboard/src/lib/member/NewMember.svelte
Normal file
240
dashboard/src/lib/member/NewMember.svelte
Normal file
@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import { Accordion, AccordionItem, Row, Col, Input, Button, Label, Alert, Spinner, CardTitle, InputGroup } from 'sveltestrap';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import autosize from 'svelte-autosize';
|
||||
import moment from 'moment';
|
||||
import FaPlus from 'svelte-icons/fa/FaPlus.svelte';
|
||||
import { Member } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
const descriptions: string[] = JSON.parse(localStorage.getItem("pk-config"))?.description_templates;
|
||||
|
||||
let err: string[] = [];
|
||||
let message: string;
|
||||
let loading: boolean = false;
|
||||
let privacyMode: boolean = false;
|
||||
let proxyTagMode: boolean = false;
|
||||
|
||||
let defaultMember = {
|
||||
privacy: {
|
||||
visibility: "public",
|
||||
metadata_privacy: "public",
|
||||
description_privacy: "public",
|
||||
pronoun_privacy: "public",
|
||||
birthday_privacy: "public",
|
||||
name_privacy: "public",
|
||||
avatar_privacy: "public"
|
||||
},
|
||||
proxy_tags: [
|
||||
{
|
||||
prefix: "",
|
||||
suffix: ""
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
let input: Member = defaultMember;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function create() {
|
||||
dispatch('create', input);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
message = "";
|
||||
err = [];
|
||||
|
||||
if (input.proxy_tags.some(tag => tag.prefix && tag.suffix && tag.prefix.length + tag.suffix.length + 4 > 100)) {
|
||||
err.push("One of your proxy tags is too long (prefix + 'text' + suffix must be shorter than 100 characters). Please shorten this tag, or remove it.");
|
||||
}
|
||||
|
||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||
err.push(`"${data.color}" is not a valid color, the color must be a 6-digit hex code. (example: #ff0000)`);
|
||||
} else if (data.color) {
|
||||
if (data.color.startsWith("#")) {
|
||||
data.color = input.color.slice(1, input.color.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.birthday) {
|
||||
if (!moment(data.birthday, 'YYYY-MM-DD').isValid()) {
|
||||
if (moment(data.birthday, 'MM-DD').isValid()) {
|
||||
data.birthday = '0004-' + data.birthday;
|
||||
} else {
|
||||
err.push(`${data.birthday} is not a valid date, please use the following format: YYYY-MM-DD. (example: 2019-07-21)`);
|
||||
}
|
||||
}
|
||||
if (data.birthday.includes('/')) {
|
||||
data.birthday.replace('/', '-');
|
||||
}
|
||||
}
|
||||
|
||||
err = err;
|
||||
if (err.length > 0) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().members().post({data});
|
||||
input = res;
|
||||
err = [];
|
||||
create();
|
||||
input = defaultMember;
|
||||
message = `Member ${data.name} successfully created!`
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err.push(error.message);
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Accordion class="mb-3">
|
||||
<AccordionItem>
|
||||
<CardTitle slot="header" style="margin-top: 0px; margin-bottom: 0px; outline: none; align-items: center;" class="d-flex align-middle w-100 p-2">
|
||||
<div class="icon d-inline-block">
|
||||
<FaPlus/>
|
||||
</div>
|
||||
<span style="vertical-align: middle;">Add new Member</span>
|
||||
</CardTitle>
|
||||
{#if message}
|
||||
<Alert color="success">{@html message}</Alert>
|
||||
{/if}
|
||||
{#each err as error}
|
||||
<Alert color="danger">{@html error}</Alert>
|
||||
{/each}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Name:</Label>
|
||||
<Input bind:value={input.name} maxlength={100} type="text" placeholder={input.name} />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Display name:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.display_name} maxlength={100} type="text" placeholder={input.display_name} />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Pronouns:</Label>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={input.pronouns} maxlength={100} type="text" placeholder={input.pronouns} />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Birthday:</Label>
|
||||
<Input bind:value={input.birthday} maxlength={100} type="text" placeholder={input.birthday} />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Color:</Label>
|
||||
<Input bind:value={input.color} type="text" placeholder={input.color}/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Avatar url:</Label>
|
||||
<Input bind:value={input.avatar_url} maxlength={256} type="url" placeholder={input.avatar_url}/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Banner url:</Label>
|
||||
<Input bind:value={input.banner} maxlength={256} type="url" placeholder={input.banner}/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Privacy:</Label>
|
||||
<Button class="w-100" color="secondary" on:click={() => privacyMode = !privacyMode}>Toggle privacy</Button>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Proxy tags:</Label>
|
||||
<Button class="w-100" color="secondary" on:click={() => proxyTagMode = !proxyTagMode}>Toggle proxy tags</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if privacyMode}
|
||||
<hr/>
|
||||
<b>Privacy:</b>
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Description:</Label>
|
||||
<Input type="select" bind:value={input.privacy.description_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Name:</Label>
|
||||
<Input type="select" bind:value={input.privacy.name_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Avatar:</Label>
|
||||
<Input type="select" bind:value={input.privacy.avatar_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Birthday:</Label>
|
||||
<Input type="select" bind:value={input.privacy.birthday_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Pronouns:</Label>
|
||||
<Input type="select" bind:value={input.privacy.pronoun_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Visibility:</Label>
|
||||
<Input type="select" bind:value={input.privacy.visibility}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Metadata:</Label>
|
||||
<Input type="select" bind:value={input.privacy.metadata_privacy}>
|
||||
<option default>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
{#if proxyTagMode}
|
||||
<hr/>
|
||||
<b>Proxy tags:</b>
|
||||
<Row class="mb-2">
|
||||
{#each input.proxy_tags as proxyTag, index (index)}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<InputGroup>
|
||||
<Input style="resize: none; height: 1em" type="textarea" bind:value={proxyTag.prefix} />
|
||||
<Input disabled value="text"/>
|
||||
<Input style="resize: none; height: 1em" type="textarea" bind:value={proxyTag.suffix}/>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{/each}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Button class="w-100" color="secondary" on:click={() => {input.proxy_tags.push({prefix: "", suffix: ""}); input.proxy_tags = input.proxy_tags;}}>New</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
<hr/>
|
||||
<div class="my-2">
|
||||
<b>Description:</b><br />
|
||||
{#if descriptions.length > 0 && descriptions[0].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[0]}>Template 1</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 1 && descriptions[1].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[1]}>Template 2</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 2 && descriptions[2].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[2]}>Template 3</Button>
|
||||
{/if}
|
||||
<br>
|
||||
<textarea class="form-control" bind:value={input.description} maxlength={1000} use:autosize placeholder={input.description}/>
|
||||
</div>
|
||||
{#if !loading && input.name}<Button style="flex: 0" color="primary" on:click={submit}>Submit</Button>
|
||||
{:else if !input.name }<Button style="flex: 0" color="primary" disabled>Submit</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled><Spinner size="sm"/></Button>{/if}
|
||||
</AccordionItem>
|
||||
</Accordion>
|
128
dashboard/src/lib/member/Privacy.svelte
Normal file
128
dashboard/src/lib/member/Privacy.svelte
Normal file
@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { Col, Row, Input, Label, Button, Alert, Spinner } from "sveltestrap";
|
||||
|
||||
import { Member } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
let loading: boolean;
|
||||
export let privacyOpen: boolean;
|
||||
export let member: Member;
|
||||
const togglePrivacyModal = () => (privacyOpen = !privacyOpen);
|
||||
|
||||
let err: string;
|
||||
|
||||
let allPrivacy: string;
|
||||
|
||||
$: { changePrivacy(allPrivacy)}
|
||||
|
||||
function changePrivacy(value: string) {
|
||||
if (value) {
|
||||
input.privacy.description_privacy = value;
|
||||
input.privacy.name_privacy = value;
|
||||
input.privacy.avatar_privacy = value;
|
||||
input.privacy.birthday_privacy = value;
|
||||
input.privacy.pronoun_privacy = value;
|
||||
input.privacy.visibility = value;
|
||||
input.privacy.metadata_privacy = value;
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch('update', member);
|
||||
}
|
||||
|
||||
let input: Member = {privacy: member.privacy};
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = null;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().members(member.id).patch({data});
|
||||
member = res;
|
||||
update();
|
||||
loading = false;
|
||||
togglePrivacyModal();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function focus(el) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Label><b>Set all to:</b></Label>
|
||||
<select class="form-select" bind:value={allPrivacy} use:focus aria-label="set all to">
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</select>
|
||||
<hr />
|
||||
<Row>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Description:</Label>
|
||||
<Input type="select" bind:value={input.privacy.description_privacy} aria-label="member description privacy">
|
||||
<option default={member.privacy.description_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.description_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Name:</Label>
|
||||
<Input type="select" bind:value={input.privacy.name_privacy} aria-label="member name privacy">
|
||||
<option default={member.privacy.name_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.name_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Avatar:</Label>
|
||||
<Input type="select" bind:value={input.privacy.avatar_privacy} aria-label="member avatar privacy">
|
||||
<option default={member.privacy.avatar_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.avatar_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Birthday:</Label>
|
||||
<Input type="select" bind:value={input.privacy.birthday_privacy} aria-label="member birthday privacy">
|
||||
<option default={member.privacy.birthday_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.birthday_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Pronouns:</Label>
|
||||
<Input type="select" bind:value={input.privacy.pronoun_privacy} aria-label="member pronoun privacy">
|
||||
<option default={member.privacy.pronoun_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.pronoun_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Visibility:</Label>
|
||||
<Input type="select" bind:value={input.privacy.visibility} aria-label="member visibility privacy">
|
||||
<option default={member.privacy.visibility === "public"}>public</option>
|
||||
<option default={member.privacy.visibility === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>Metadata:</Label>
|
||||
<Input type="select" bind:value={input.privacy.metadata_privacy} aria-label="member metadata privacy">
|
||||
<option default={member.privacy.metadata_privacy === "public"}>public</option>
|
||||
<option default={member.privacy.metadata_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if !loading}<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit privacy edits">Submit</Button> <Button style="flex: 0" color="secondary" on:click={togglePrivacyModal} aria-label="cancel privacy edits">Back</Button>
|
||||
{:else}<Button style="flex: 0" color="primary" disabled aria-label="submit privacy edits"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="cancel privacy edits">Back</Button>
|
||||
{/if}
|
74
dashboard/src/lib/member/ProxyTags.svelte
Normal file
74
dashboard/src/lib/member/ProxyTags.svelte
Normal file
@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, tick } from "svelte";
|
||||
import { Col, Row, Input, Label, Button, Alert, Spinner, InputGroup } from "sveltestrap";
|
||||
|
||||
import { Member } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
let loading: boolean;
|
||||
export let proxyOpen: boolean;
|
||||
export let member: Member;
|
||||
const toggleProxyModal = () => (proxyOpen = !proxyOpen);
|
||||
|
||||
let err: string;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function update() {
|
||||
dispatch('update', member);
|
||||
}
|
||||
|
||||
let input = member.proxy_tags;
|
||||
|
||||
async function submit() {
|
||||
err = null;
|
||||
if (input.some(tag => tag.prefix && tag.suffix && tag.prefix.length + tag.suffix.length + 4 > 100)) {
|
||||
err = "One of your proxy tags is too long (prefix + 'text' + suffix must be shorter than 100 characters). Please shorten this tag, or remove it."
|
||||
return;
|
||||
}
|
||||
|
||||
let data: Member = {proxy_tags: input};
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
let res = await api().members(member.id).patch({data});
|
||||
member = res;
|
||||
err = null;
|
||||
update();
|
||||
loading = false;
|
||||
toggleProxyModal();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function focus(el, first) {
|
||||
if (first) {
|
||||
await tick();
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Row class="mb-2">
|
||||
{#each input as proxyTag, index (index)}
|
||||
<Col xs={12} lg={6} class="mb-2">
|
||||
<InputGroup>
|
||||
<textarea class="form-control" style="resize: none; height: 1em" bind:value={proxyTag.prefix} use:focus={index === 0 ? true : false} aria-label="proxy tag prefix"/>
|
||||
<Input disabled value="text"/>
|
||||
<Input style="resize: none; height: 1em" type="textarea" bind:value={proxyTag.suffix} aria-label="proxy tag suffix"/>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
{/each}
|
||||
<Col xs={12} lg={6} class="mb-2">
|
||||
<Button class="w-100" color="secondary" on:click={() => {input.push({prefix: "", suffix: ""}); input = input;}}>New</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if !loading}<Button style="flex 0" color="primary" on:click={submit} aria-label="submit proxy tags">Submit</Button> <Button style="flex: 0" color="secondary" on:click={toggleProxyModal} aria-label="go back">Back</Button>
|
||||
{:else}<Button style="flex 0" color="primary" disabled aria-label="submit proxy tags"><Spinner size="sm"/></Button> <Button style="flex: 0" color="secondary" disabled aria-label="go back">Back</Button>
|
||||
{/if}
|
95
dashboard/src/lib/shard.svelte
Normal file
95
dashboard/src/lib/shard.svelte
Normal file
@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
export let hover;
|
||||
|
||||
export let shard = {
|
||||
id: 1,
|
||||
status: "",
|
||||
ping:0,
|
||||
disconnection_count:0,
|
||||
last_connection:0,
|
||||
last_heartbeat:0.
|
||||
};
|
||||
|
||||
let color = "background-color: #fff";
|
||||
|
||||
// shard is down
|
||||
// todo: check if last heartbeat is really recent, since database up/down status can get out of sync
|
||||
if (shard.status != "up") color = "background-color: #000;";
|
||||
// shard latency is < 250ms: OK!
|
||||
else if (shard.ping < 300) color = "background-color: #00cc00;";
|
||||
// shard latency is 250ms < ping < 600ms: slow, but OK
|
||||
else if (shard.ping < 600) color = "background-color: #da9317;";
|
||||
// shard latency is >600ms, this might be problematic
|
||||
else color = "background-color: #cc0000;"
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
<div
|
||||
on:click={() => hover = (hover != shard.id) ? shard.id : null}
|
||||
class="shard" id={shard.id.toString()}
|
||||
style={color}
|
||||
>{ shard.id }</div>
|
||||
{#if hover == shard.id}
|
||||
<div class="more-info">
|
||||
<br>
|
||||
<h3>Shard { shard.id }</h3>
|
||||
<br>
|
||||
<span>Status: <b>{ shard.status }</b></span><br>
|
||||
<span>Latency: { shard.ping }ms</span><br>
|
||||
<span>Disconnection count: { shard.disconnection_count }</span><br>
|
||||
<span>Last connection: { shard.last_connection }</span><br>
|
||||
<span>Last heartbeat: { shard.last_heartbeat }</span><br>
|
||||
<br>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
height: 55px;
|
||||
width: 55px;
|
||||
display: block;
|
||||
float: left;
|
||||
}
|
||||
.shard:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.shard {
|
||||
color: #fff;
|
||||
display: block;
|
||||
float: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 2px;
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-khtml-user-select: none; /* Konqueror HTML */
|
||||
-moz-user-select: none; /* Old versions of Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none; /* Non-prefixed version, currently
|
||||
supported by Chrome, Edge, Opera and Firefox */
|
||||
}
|
||||
.more-info {
|
||||
/* display: none; */
|
||||
position: absolute;
|
||||
margin-top: 3em;
|
||||
will-change: transform;
|
||||
min-height: 150px;
|
||||
width: 200px;
|
||||
z-index: 2;
|
||||
border-radius: 5px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
opacity: 95%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
94
dashboard/src/lib/system/Body.svelte
Normal file
94
dashboard/src/lib/system/Body.svelte
Normal file
@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Modal, Image, Button } from 'sveltestrap';
|
||||
import moment from 'moment';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
import twemoji from 'twemoji';
|
||||
|
||||
import { System } from '../../api/types';
|
||||
|
||||
export let user: System;
|
||||
export let editMode: boolean;
|
||||
export let isPublic: boolean;
|
||||
|
||||
let htmlDescription: string;
|
||||
let htmlName: string;
|
||||
if (user.description) {
|
||||
htmlDescription = toHTML(user.description, {embed: true});
|
||||
} else {
|
||||
htmlDescription = "(no description)";
|
||||
}
|
||||
|
||||
if (user.name) {
|
||||
htmlName = toHTML(user.name);
|
||||
}
|
||||
|
||||
let created = moment(user.created).format("MMM D, YYYY");
|
||||
|
||||
let bannerOpen = false;
|
||||
const toggleBannerModal = () => (bannerOpen = !bannerOpen);
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
let descriptionElement: any;
|
||||
let nameElement: any;
|
||||
let tagElement: any;
|
||||
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (descriptionElement) twemoji.parse(descriptionElement);
|
||||
if (nameElement) twemoji.parse(nameElement);
|
||||
if (tagElement) twemoji.parse(tagElement);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Row>
|
||||
{#if user.id}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>ID:</b> {user.id}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.name}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span bind:this={nameElement}><b>Name:</b> {@html htmlName}</span>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.tag}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span bind:this={tagElement}><b>Tag:</b> {user.tag}</span>
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.created && !isPublic}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Created:</b> {created}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.timezone && !isPublic}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Timezone:</b> {user.timezone}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.color}
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<b>Color:</b> {user.color}
|
||||
</Col>
|
||||
{/if}
|
||||
{#if user.banner}
|
||||
<Col xs={12} lg={3} class="mb-2">
|
||||
<b>Banner:</b> <Button size="sm" color="secondary" on:click={toggleBannerModal} aria-label="view system banner">View</Button>
|
||||
<Modal isOpen={bannerOpen} toggle={toggleBannerModal}>
|
||||
<div slot="external" on:click={toggleBannerModal} style="height: 100%; width: max-content; max-width: 100%; margin-left: auto; margin-right: auto; display: flex;">
|
||||
<Image style="display: block; margin: auto;" src={user.banner} thumbnail alt="system banner" />
|
||||
</div>
|
||||
</Modal>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
<div class="my-2 description" bind:this={descriptionElement}>
|
||||
<b>Description:</b><br />
|
||||
{@html htmlDescription}
|
||||
</div>
|
||||
{#if (user.banner && ((settings && settings.appearance.banner_bottom) || !settings))}
|
||||
<img src={user.banner} alt="system banner" class="w-100 mb-3 rounded" style="max-height: 12em; object-fit: cover"/>
|
||||
{/if}
|
||||
{#if !isPublic}
|
||||
<Button style="flex: 0" color="primary" on:click={() => editMode = true} aria-label="edit system information">Edit</Button>
|
||||
{/if}
|
95
dashboard/src/lib/system/Edit.svelte
Normal file
95
dashboard/src/lib/system/Edit.svelte
Normal file
@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { Row, Col, Input, Button, Label, Alert } from 'sveltestrap';
|
||||
import autosize from 'svelte-autosize';
|
||||
// import moment from 'moment-timezone';
|
||||
import { currentUser } from '../../stores';
|
||||
|
||||
const descriptions: string[] = JSON.parse(localStorage.getItem("pk-config"))?.description_templates;
|
||||
|
||||
import { System } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let editMode: boolean;
|
||||
export let user: System;
|
||||
export let loading: boolean;
|
||||
|
||||
let err: string[] = [];
|
||||
|
||||
let input: System = {...user};
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = [];
|
||||
|
||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||
err.push(`"${data.color}" is not a valid color, the color must be a 6-digit hex code. (example: #ff0000)`);
|
||||
} else if (data.color) {
|
||||
if (data.color.startsWith("#")) {
|
||||
data.color = input.color.slice(1, input.color.length);
|
||||
}
|
||||
}
|
||||
|
||||
/* if (data.timezone && !moment.tz.zone(data.timezone)) {
|
||||
err.push(`"${data.timezone}" is not a valid timezone, check out <a target="_blank" style="color: var(--bs-body-color);" href="https://xske.github.io/tz/">this site</a> to see your current timezone!`);
|
||||
} */
|
||||
|
||||
err = err;
|
||||
if (err.length > 0) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().systems("@me").patch({data});
|
||||
user = res;
|
||||
currentUser.update(() => res);
|
||||
err = [];
|
||||
editMode = false;
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err.push(error.message);
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each err as error}
|
||||
<Alert color="danger">{@html error}</Alert>
|
||||
{/each}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Name:</Label>
|
||||
<Input bind:value={input.name} maxlength={100} type="text" placeholder={user.name} aria-label="system name" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Tag:</Label>
|
||||
<Input bind:value={input.tag} maxlength={100} type="text" placeholder={user.tag} aria-label="system tag" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Color:</Label>
|
||||
<Input bind:value={input.color} type="text" placeholder={user.color} aria-label="system color"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Avatar url:</Label>
|
||||
<Input bind:value={input.avatar_url} maxlength={256} type="url" placeholder={user.avatar_url} aria-label="system avatar url" />
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<Label>Banner url:</Label>
|
||||
<Input bind:value={input.banner} maxlength={256} type="url" placeholder={user.banner} aria-label="system banner url" />
|
||||
</Col>
|
||||
</Row>
|
||||
<div class="my-2">
|
||||
<b>Description:</b><br />
|
||||
{#if descriptions.length > 0 && descriptions[0].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[0]} aria-label="use template 1">Template 1</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 1 && descriptions[1].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[1]} aria-label="use template 2">Template 2</Button>
|
||||
{/if}
|
||||
{#if descriptions.length > 2 && descriptions[2].trim() != ""}
|
||||
<Button size="sm" color="primary" on:click={() => input.description = descriptions[2]} aria-label="use template 3">Template 3</Button>
|
||||
{/if}
|
||||
<br>
|
||||
<textarea class="form-control" bind:value={input.description} maxlength={1000} use:autosize placeholder={user.description} aria-label="system description"/>
|
||||
</div>
|
||||
<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit edits">Submit</Button> <Button style="flex: 0" color="light" on:click={() => editMode = false} aria-label="cancel edits" >Back</Button>
|
35
dashboard/src/lib/system/Main.svelte
Normal file
35
dashboard/src/lib/system/Main.svelte
Normal file
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardBody, CardHeader } from 'sveltestrap';
|
||||
import FaAddressCard from 'svelte-icons/fa/FaAddressCard.svelte'
|
||||
import CardsHeader from '../CardsHeader.svelte';
|
||||
import Body from './Body.svelte';
|
||||
import Privacy from './Privacy.svelte';
|
||||
import Edit from './Edit.svelte';
|
||||
|
||||
import { System } from '../../api/types';
|
||||
|
||||
export let user: System;
|
||||
export let isPublic = true;
|
||||
let loading = false;
|
||||
|
||||
let editMode = false;
|
||||
</script>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={user} bind:loading>
|
||||
<FaAddressCard slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody style="border-left: 4px solid #{user.color}">
|
||||
{#if !editMode}
|
||||
<Body bind:user bind:editMode bind:isPublic/>
|
||||
{:else}
|
||||
<Edit bind:user bind:editMode bind:loading />
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{#if !isPublic}
|
||||
<Privacy bind:user />
|
||||
{/if}
|
50
dashboard/src/lib/system/Privacy.svelte
Normal file
50
dashboard/src/lib/system/Privacy.svelte
Normal file
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardHeader, CardBody, CardTitle, Row, Col, Button, Spinner } from 'sveltestrap';
|
||||
import {Link} from 'svelte-navigator';
|
||||
import FaUserLock from 'svelte-icons/fa/FaUserLock.svelte';
|
||||
import PrivacyEdit from './PrivacyEdit.svelte';
|
||||
|
||||
import { System } from '../../api/types';
|
||||
|
||||
export let user: System;
|
||||
let editMode = false;
|
||||
|
||||
let loading: boolean;
|
||||
</script>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaUserLock />
|
||||
</div> System privacy
|
||||
{#if loading}<div class="d-inline-block mr-5" style="float: right;"><Spinner color="primary" /></div>{/if}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody style="border-left: 4px solid #{user.color}">
|
||||
{#if editMode}
|
||||
<PrivacyEdit bind:loading bind:user={user} bind:editMode/>
|
||||
{:else}
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<b>Description:</b> {user.privacy.description_privacy}
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<b>Member list:</b> {user.privacy.member_list_privacy}
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<b>Group list:</b> {user.privacy.group_list_privacy}
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<b>Current front:</b> {user.privacy.front_privacy}
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<b>Front history:</b> {user.privacy.front_history_privacy}
|
||||
</Col>
|
||||
</Row>
|
||||
<Button style="flex: 0" color="primary" on:click={() => editMode = true} aria-label="edit system privacy">Edit</Button>
|
||||
<Link to="/dash/bulk-member-privacy"><Button style="flex: 0" color="secondary" tabindex={-1}>Bulk member privacy</Button></Link>
|
||||
<Link to="/dash/bulk-group-privacy"><Button style="flex: 0" color="secondary" tabindex={-1}>Bulk group privacy</Button></Link>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
96
dashboard/src/lib/system/PrivacyEdit.svelte
Normal file
96
dashboard/src/lib/system/PrivacyEdit.svelte
Normal file
@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { Input, Row, Col, Button, Label, Alert } from 'sveltestrap';
|
||||
import { currentUser } from '../../stores';
|
||||
|
||||
import { System } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
export let loading = false;
|
||||
export let user: System;
|
||||
export let editMode: boolean;
|
||||
|
||||
let err: string;
|
||||
|
||||
let input: System = {privacy: user.privacy};
|
||||
|
||||
async function submit() {
|
||||
let data = input;
|
||||
err = null;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
let res = await api().systems("@me").patch({data});
|
||||
user = res;
|
||||
currentUser.update(() => res);
|
||||
editMode = false;
|
||||
loading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
err = err;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let allPrivacy: string;
|
||||
|
||||
$: { changePrivacy(allPrivacy)}
|
||||
|
||||
function changePrivacy(value: string) {
|
||||
if (value) {
|
||||
input.privacy.description_privacy = value;
|
||||
input.privacy.member_list_privacy = value;
|
||||
input.privacy.group_list_privacy = value;
|
||||
input.privacy.front_privacy = value;
|
||||
input.privacy.front_history_privacy = value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
<Label><b>Set all to:</b></Label>
|
||||
<Input type="select" bind:value={allPrivacy} aria-label="set all to">
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
<hr />
|
||||
<Row>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Description:</Label>
|
||||
<Input type="select" bind:value={input.privacy.description_privacy} aria-label="system description privacy">
|
||||
<option default={user.privacy.description_privacy === "public"}>public</option>
|
||||
<option default={user.privacy.description_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Member list:</Label>
|
||||
<Input type="select" bind:value={input.privacy.member_list_privacy} aria-label="system member list privacy">
|
||||
<option default={user.privacy.member_list_privacy === "public"}>public</option>
|
||||
<option default={user.privacy.member_list_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Group list:</Label>
|
||||
<Input type="select" bind:value={input.privacy.group_list_privacy} aria-label="system group list privacy">
|
||||
<option default={user.privacy.group_list_privacy === "public"}>public</option>
|
||||
<option default={user.privacy.group_list_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Current front:</Label>
|
||||
<Input type="select" bind:value={input.privacy.front_privacy} aria-label="system front privacy">
|
||||
<option default={user.privacy.front_privacy === "public"}>public</option>
|
||||
<option default={user.privacy.front_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-3">
|
||||
<Label>Front history:</Label>
|
||||
<Input type="select" bind:value={input.privacy.front_history_privacy} aria-label="system front history privacy">
|
||||
<option default={user.privacy.front_history_privacy === "public"}>public</option>
|
||||
<option default={user.privacy.front_history_privacy === "private"}>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button style="flex: 0" color="primary" on:click={submit} aria-label="submit privacy edit">Submit</Button> <Button style="flex: 0" color="secondary" on:click={() => editMode = false} aria-label="cancel privacy edit">Back</Button>
|
23
dashboard/src/main.ts
Normal file
23
dashboard/src/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://58109fec589f4c2bbfa190329acf679a@sentry.pluralkit.me/4",
|
||||
integrations: [new Integrations.BrowserTracing()],
|
||||
|
||||
enabled: !window.location.origin.includes("localhost"),
|
||||
debug: false,
|
||||
release: "dev",
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
})
|
||||
|
||||
export default app
|
109
dashboard/src/pages/BulkGroupPrivacy.svelte
Normal file
109
dashboard/src/pages/BulkGroupPrivacy.svelte
Normal file
@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Card, CardHeader, CardBody, CardTitle, Alert, Label, Input, Button, Spinner } from 'sveltestrap';
|
||||
import { navigate } from 'svelte-navigator';
|
||||
import FaUserLock from 'svelte-icons/fa/FaUserLock.svelte';
|
||||
|
||||
import api from '../api';
|
||||
import { GroupPrivacy, System } from '../api/types';
|
||||
const user: System = JSON.parse(localStorage.getItem("pk-user"));
|
||||
|
||||
if (!user) navigate('/');
|
||||
|
||||
// const capitalize = (str: string) => str[0].toUpperCase() + str.substr(1);
|
||||
|
||||
let loading = false;
|
||||
let err = "";
|
||||
let success = false;
|
||||
|
||||
// kinda hacked together from typescript's Required<T> type
|
||||
const privacy: { [P in keyof GroupPrivacy]-?: string; } = {
|
||||
description_privacy: "no change",
|
||||
name_privacy: "no change",
|
||||
list_privacy: "no change",
|
||||
icon_privacy: "no change",
|
||||
visibility: "no change",
|
||||
metadata_privacy: "no change",
|
||||
};
|
||||
|
||||
const privacyNames: { [P in keyof GroupPrivacy]-?: string; } = {
|
||||
name_privacy: "Name",
|
||||
description_privacy: "Description",
|
||||
icon_privacy: "Icon",
|
||||
list_privacy: "Member list",
|
||||
metadata_privacy: "Metadata",
|
||||
visibility: "Visbility",
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
success = false;
|
||||
loading = true;
|
||||
const dataArray = Object.entries(privacy).filter(([, value]) => value === "no change" ? false : true);
|
||||
const data = Object.fromEntries(dataArray);
|
||||
try {
|
||||
await api().private.bulk_privacy.group.post({ data });
|
||||
success = true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function changeAll(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
Object.keys(privacy).forEach(x => privacy[x] = target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaUserLock />
|
||||
</div> Bulk group privacy
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody style="border-left: 4px solid #{user.color}">
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert color="success">Group privacy updated!</Alert>
|
||||
{/if}
|
||||
<Label><b>Set all to:</b></Label>
|
||||
<Input type="select" on:change={(e) => changeAll(e)} aria-label="set all to">
|
||||
<option>no change</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
<hr/>
|
||||
<Row>
|
||||
{#each Object.keys(privacy) as x}
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>{privacyNames[x]}:</Label>
|
||||
<Input type="select" bind:value={privacy[x]} aria-label={`group ${privacyNames[x]} privacy`}>
|
||||
<option default>no change</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
{/each}
|
||||
</Row>
|
||||
|
||||
<Button color="primary" on:click={submit} bind:disabled={loading} aria-label="submit bulk group privacy">
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
{/if}
|
110
dashboard/src/pages/BulkMemberPrivacy.svelte
Normal file
110
dashboard/src/pages/BulkMemberPrivacy.svelte
Normal file
@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Card, CardHeader, CardBody, CardTitle, Label, Input, Button, Spinner, Alert } from 'sveltestrap';
|
||||
import { navigate } from 'svelte-navigator';
|
||||
import FaUserLock from 'svelte-icons/fa/FaUserLock.svelte';
|
||||
|
||||
import api from '../api';
|
||||
import { MemberPrivacy, System } from '../api/types';
|
||||
const user: System = JSON.parse(localStorage.getItem("pk-user"));
|
||||
|
||||
if (!user) navigate('/');
|
||||
|
||||
// const capitalize = (str: string) => str[0].toUpperCase() + str.substr(1);
|
||||
|
||||
let loading = false;
|
||||
let err = "";
|
||||
let success = false;
|
||||
|
||||
const privacy: { [P in keyof MemberPrivacy]-?: string; } = {
|
||||
description_privacy: "no change",
|
||||
name_privacy: "no change",
|
||||
avatar_privacy: "no change",
|
||||
birthday_privacy: "no change",
|
||||
pronoun_privacy: "no change",
|
||||
visibility: "no change",
|
||||
metadata_privacy: "no change",
|
||||
};
|
||||
|
||||
const privacyNames: { [P in keyof MemberPrivacy]-?: string; } = {
|
||||
avatar_privacy: "Avatar",
|
||||
birthday_privacy: "Birthday",
|
||||
description_privacy: "Description",
|
||||
metadata_privacy: "Metadata",
|
||||
name_privacy: "Name",
|
||||
pronoun_privacy: "Pronouns",
|
||||
visibility: "Visibility",
|
||||
};
|
||||
|
||||
async function submit() {
|
||||
success = false;
|
||||
loading = true;
|
||||
const dataArray = Object.entries(privacy).filter(([, value]) => value === "no change" ? false : true);
|
||||
const data = Object.fromEntries(dataArray);
|
||||
try {
|
||||
await api().private.bulk_privacy.member.post({ data });
|
||||
success = true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function changeAll(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
Object.keys(privacy).forEach(x => privacy[x] = target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaUserLock />
|
||||
</div> Bulk member privacy
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody style="border-left: 4px solid #{user.color}">
|
||||
{#if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert color="success">Member privacy updated!</Alert>
|
||||
{/if}
|
||||
<Label><b>Set all to:</b></Label>
|
||||
<Input type="select" on:change={(e) => changeAll(e)} aria-label="set all to">
|
||||
<option>no change</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
<hr/>
|
||||
<Row>
|
||||
{#each Object.keys(privacy) as x}
|
||||
<Col xs={12} lg={6} class="mb-3">
|
||||
<Label>{privacyNames[x]}:</Label>
|
||||
<Input type="select" bind:value={privacy[x]} aria-label={`member ${privacyNames[x]} privacy`}>
|
||||
<option default>no change</option>
|
||||
<option>public</option>
|
||||
<option>private</option>
|
||||
</Input>
|
||||
</Col>
|
||||
{/each}
|
||||
</Row>
|
||||
|
||||
<Button color="primary" on:click={submit} bind:disabled={loading} aria-label="submit bulk member privacy">
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
Submit
|
||||
{/if}
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
{/if}
|
101
dashboard/src/pages/Dash.svelte
Normal file
101
dashboard/src/pages/Dash.svelte
Normal file
@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { Container, Col, Row, TabContent, TabPane } from 'sveltestrap';
|
||||
import { navigate, useLocation } from "svelte-navigator";
|
||||
import { currentUser, loggedIn } from '../stores';
|
||||
|
||||
import SystemMain from '../lib/system/Main.svelte';
|
||||
import MemberList from '../lib/member/List.svelte';
|
||||
import GroupList from '../lib/group/List.svelte';
|
||||
|
||||
import { System } from '../api/types';
|
||||
import api from '../api';
|
||||
|
||||
let isPublic = false;
|
||||
|
||||
// get the state from the navigator so that we know which tab to start on
|
||||
let location = useLocation();
|
||||
let params = $location.search && new URLSearchParams($location.search);
|
||||
let tabPane: string;
|
||||
if (params) {
|
||||
tabPane = params.get("tab");
|
||||
}
|
||||
|
||||
if (!tabPane) {
|
||||
tabPane = "system";
|
||||
}
|
||||
|
||||
// subscribe to the cached user in the store
|
||||
let current;
|
||||
currentUser.subscribe(value => {
|
||||
current = value;
|
||||
});
|
||||
|
||||
// if there is no cached user, get the user from localstorage
|
||||
let user: System = current ?? JSON.parse(localStorage.getItem("pk-user"));
|
||||
// since the user in localstorage can be outdated, fetch the user from the api again
|
||||
if (!current) {
|
||||
login(localStorage.getItem("pk-token"));
|
||||
}
|
||||
|
||||
// if there's no user, and there's no token, assume the login failed and send us back to the homepage.
|
||||
if (!localStorage.getItem("pk-token") && !user) {
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
// just the login function
|
||||
async function login(token: string) {
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error("Token cannot be empty.")
|
||||
}
|
||||
const res: System = await api().systems("@me").get({ token });
|
||||
localStorage.setItem("pk-token", token);
|
||||
localStorage.setItem("pk-user", JSON.stringify(res));
|
||||
loggedIn.update(() => true);
|
||||
currentUser.update(() => res);
|
||||
user = res;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// localStorage.removeItem("pk-token");
|
||||
// localStorage.removeItem("pk-user");
|
||||
// currentUser.update(() => null);
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
// some values that get passed from the member to the group components and vice versa
|
||||
let members = [];
|
||||
let groups = [];
|
||||
|
||||
</script>
|
||||
|
||||
<!-- display the banner if there's a banner set, and if the current settings allow for it-->
|
||||
{#if user && user.banner && ((settings && settings.appearance.banner_top) || !settings)}
|
||||
<div class="banner" style="background-image: url({user.banner})" />
|
||||
{/if}
|
||||
{#if user}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<h2 class="visually-hidden">Viewing your own system</h2>
|
||||
<TabContent class="mt-3">
|
||||
<TabPane tabId="system" tab="System" active={tabPane === "system"}>
|
||||
<SystemMain bind:user={user} bind:isPublic />
|
||||
</TabPane>
|
||||
<TabPane tabId="members" tab="Members" active={tabPane === "members"}>
|
||||
<MemberList bind:groups={groups} bind:list={members} bind:isPublic />
|
||||
</TabPane>
|
||||
<TabPane tabId="groups" tab="Groups" active={tabPane === "groups"}>
|
||||
<GroupList bind:members={members} bind:list={groups} bind:isPublic />
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | dash</title>
|
||||
</svelte:head>
|
34
dashboard/src/pages/DiscordLogin.svelte
Normal file
34
dashboard/src/pages/DiscordLogin.svelte
Normal file
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col } from 'sveltestrap';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
let text = "Loading...";
|
||||
|
||||
onMount(async () =>
|
||||
{
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const paramkeys = [...params.keys()];
|
||||
if (paramkeys.includes("code"))
|
||||
{
|
||||
const res = await api().private.discord.callback.post({ data: { code: params.get("code"), redirect_domain: window.location.origin } });
|
||||
localStorage.setItem("pk-token", res.token);
|
||||
localStorage.setItem("pk-user", JSON.stringify(res.system));
|
||||
localStorage.setItem("pk-config", JSON.stringify(res.config));
|
||||
window.location.href = window.location.origin;
|
||||
}
|
||||
else
|
||||
{
|
||||
text = "Error: " + params.get("error_description");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
{text}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
253
dashboard/src/pages/Group.svelte
Normal file
253
dashboard/src/pages/Group.svelte
Normal file
@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Alert, Spinner, Card, CardHeader, CardBody, Accordion, AccordionItem, CardTitle } from "sveltestrap";
|
||||
import Body from '../lib/group/Body.svelte';
|
||||
import MemberBody from '../lib/member/Body.svelte';
|
||||
import { useParams, Link, navigate } from 'svelte-navigator';
|
||||
import { onMount } from 'svelte';
|
||||
import api from "../api";
|
||||
import { Member, Group } from "../api/types";
|
||||
import CardsHeader from "../lib/CardsHeader.svelte";
|
||||
import FaUsers from 'svelte-icons/fa/FaUsers.svelte';
|
||||
import FaList from 'svelte-icons/fa/FaList.svelte';
|
||||
import FaUserCircle from 'svelte-icons/fa/FaUserCircle.svelte';
|
||||
import ListPagination from '../lib/ListPagination.svelte';
|
||||
import FaLock from 'svelte-icons/fa/FaLock.svelte'
|
||||
|
||||
let loading = true;
|
||||
let memberLoading = false;
|
||||
let params = useParams();
|
||||
let err = "";
|
||||
let memberErr = "";
|
||||
let group: Group;
|
||||
let members: Member[] = [];
|
||||
let systemMembers: Group[] = [];
|
||||
let isMainDash = false;
|
||||
let isDeleted = false;
|
||||
let notOwnSystem = false;
|
||||
|
||||
const isPage = true;
|
||||
export let isPublic = true;
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
let currentPage = 1;
|
||||
let itemsPerPage = settings && settings.accessibility && settings.accessibility.expandedcards ? 5 : 10;
|
||||
|
||||
$: indexOfLastItem = currentPage * itemsPerPage;
|
||||
$: indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
$: pageAmount = Math.ceil(members.length / itemsPerPage);
|
||||
|
||||
$: orderedMembers = members.sort((a, b) => a.name.localeCompare(b.name));
|
||||
$: slicedMembers = orderedMembers.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
if (!isPublic && isPage) {
|
||||
let user = localStorage.getItem("pk-user");
|
||||
if (!user) navigate("/");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchGroup();
|
||||
});
|
||||
|
||||
let title = isPublic ? "group" : "group (dash)";
|
||||
|
||||
async function fetchGroup() {
|
||||
try {
|
||||
group = await api().groups($params.id).get({auth: !isPublic});
|
||||
if (!isPublic && !group.privacy) {
|
||||
notOwnSystem = true;
|
||||
throw new Error("Group is not from own system.");
|
||||
}
|
||||
err = "";
|
||||
loading = false;
|
||||
if (group.name) {
|
||||
title = isPublic ? group.name : `${group.name} (dash)`;
|
||||
}
|
||||
memberLoading = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
fetchMembers();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMembers() {
|
||||
try {
|
||||
members = await api().groups($params.id).members().get({auth: !isPublic});
|
||||
group.members = members.map(function(member) {return member.uuid});
|
||||
if (!isPublic) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
systemMembers = await api().systems("@me").members.get({ auth: true });
|
||||
}
|
||||
memberErr = "";
|
||||
memberLoading = false;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
memberErr = error.message;
|
||||
memberLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateMembers() {
|
||||
memberLoading = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
fetchMembers();
|
||||
}
|
||||
|
||||
function updateDelete() {
|
||||
isDeleted = true;
|
||||
}
|
||||
|
||||
function updateMemberList(event: any) {
|
||||
members = members.map(member => member.id !== event.detail.id ? member : event.detail);
|
||||
systemMembers = systemMembers.map(member => member.id !== event.detail.id ? member : event.detail);
|
||||
}
|
||||
|
||||
function deleteMemberFromList(event: any) {
|
||||
members = members.filter(member => member.id !== event.detail);
|
||||
systemMembers = systemMembers.filter(member => member.id !== event.detail);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if settings && settings.appearance.color_background && !notOwnSystem}
|
||||
<div class="background" style="background-color: {group && `#${group.color}`}"></div>
|
||||
{/if}
|
||||
{#if group && group.banner && settings && settings.appearance.banner_top && !notOwnSystem}
|
||||
<div class="banner" style="background-image: url({group.banner})" />
|
||||
{/if}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<h2 class="visually-hidden">Viewing {isPublic ? "a public" : "your own"} group</h2>
|
||||
{#if isDeleted}
|
||||
<Alert color="success">Group has been successfully deleted. <Link to="/dash">Return to dash</Link></Alert>
|
||||
{:else}
|
||||
{#if isPublic}
|
||||
<Alert color="info" aria-hidden>You are currently <b>viewing</b> a group.</Alert>
|
||||
{/if}
|
||||
{#if notOwnSystem}
|
||||
<Alert color="danger">This group does not belong to your system, did you mean to look up <Link to={`/profile/g/${group.id}`}>it's public page</Link>?</Alert>
|
||||
{:else if err}
|
||||
<Alert color="danger">{@html err}</Alert>
|
||||
{:else if loading}
|
||||
<Spinner/>
|
||||
{:else if group && group.id}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={group}>
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:updateMembers={updateMembers} bind:members={systemMembers} bind:group={group} isPage={isPage} isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{#if memberLoading}
|
||||
<Alert color="primary"><Spinner size="sm" /> Fetching members...</Alert>
|
||||
{:else if memberErr}
|
||||
<Alert color="danger">{memberErr}</Alert>
|
||||
{:else if members && members.length > 0}
|
||||
<Card class="mb-2">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaList />
|
||||
</div> Group list
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{#if settings && settings.accessibility ? (!settings.accessibility.expandedcards && !settings.accessibility.pagelinks) : true}
|
||||
<Accordion class="mb-3" stayOpen>
|
||||
{#each slicedMembers as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={member} slot="header">
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
<MemberBody on:update={updateMemberList} isMainDash={isMainDash} on:deletion={deleteMemberFromList} bind:member bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{:else}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={member} slot="header">
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
<MemberBody on:update={updateMemberList} isMainDash={isMainDash} on:deletion={deleteMemberFromList} bind:member bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Accordion>
|
||||
{:else if settings.accessibility.expandedcards}
|
||||
{#each slicedMembers as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<MemberBody on:update={updateMemberList} isMainDash={isMainDash} on:deletion={deleteMemberFromList} bind:member bind:isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<MemberBody on:update={updateMemberList} isMainDash={isMainDash} on:deletion={deleteMemberFromList} bind:member bind:isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="my-3">
|
||||
{#each slicedMembers as member, index (member.id)}
|
||||
{#if (!isPublic && member.privacy.visibility === "public") || isPublic}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/m/${member.id}` : `/profile/m/${member.id}`}>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaUserCircle slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/m/${member.id}` : `/profile/m/${member.id}`}>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{/if}
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
z-index: -30;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | {title}</title>
|
||||
</svelte:head>
|
||||
|
144
dashboard/src/pages/Home.svelte
Normal file
144
dashboard/src/pages/Home.svelte
Normal file
@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Container, Card, CardHeader, CardBody, CardTitle, Col, Row, Spinner, Input, Button, Label, Alert } from 'sveltestrap';
|
||||
import FaLockOpen from 'svelte-icons/fa/FaLockOpen.svelte';
|
||||
import { loggedIn, currentUser } from '../stores';
|
||||
import { Link } from 'svelte-navigator';
|
||||
import twemoji from 'twemoji';
|
||||
import { toHTML } from 'discord-markdown';
|
||||
|
||||
import { System } from '../api/types';
|
||||
import api from '../api';
|
||||
|
||||
let loading = false;
|
||||
let err: string;
|
||||
let token: string;
|
||||
|
||||
let isLoggedIn: boolean;
|
||||
let user;
|
||||
|
||||
loggedIn.subscribe(value => {
|
||||
isLoggedIn = value;
|
||||
});
|
||||
|
||||
currentUser.subscribe(value => {
|
||||
user = value;
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (localStorage.getItem("pk-token")) {
|
||||
login(localStorage.getItem("pk-token"));
|
||||
}
|
||||
});
|
||||
|
||||
async function login(token: string) {
|
||||
loading = true;
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error("Token cannot be empty.")
|
||||
}
|
||||
const res: System = await api().systems("@me").get({ token });
|
||||
localStorage.setItem("pk-token", token);
|
||||
localStorage.setItem("pk-user", JSON.stringify(res));
|
||||
const settings = await api().systems("@me").settings.get({ token });
|
||||
localStorage.setItem("pk-config", JSON.stringify(settings));
|
||||
err = null;
|
||||
loggedIn.update(() => true);
|
||||
currentUser.update(() => res);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
if (error.code == 401) {
|
||||
error.message = "Invalid token";
|
||||
localStorage.removeItem("pk-token");
|
||||
localStorage.removeItem("pk-user");
|
||||
currentUser.update(() => null);
|
||||
}
|
||||
err = error.message;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
|
||||
function logout() {
|
||||
token = null;
|
||||
localStorage.removeItem("pk-token");
|
||||
localStorage.removeItem("pk-user");
|
||||
loggedIn.update(() => false);
|
||||
currentUser.update(() => null);
|
||||
}
|
||||
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
let welcomeElement: any;
|
||||
let htmlName: string;
|
||||
$: if (user && user.name) {
|
||||
htmlName = toHTML(user.name);
|
||||
}
|
||||
$: if (settings && settings.appearance.twemoji) {
|
||||
if (welcomeElement) twemoji.parse(welcomeElement);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
{#if err}
|
||||
<Alert color="danger" >{err}</Alert>
|
||||
{/if}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaLockOpen />
|
||||
</div>Log in {#if loading} <div style="float: right"><Spinner color="primary" /></div> {/if}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{#if loading}
|
||||
verifying login...
|
||||
{:else if isLoggedIn}
|
||||
{#if user && user.name}
|
||||
<p bind:this={welcomeElement}>Welcome, <b>{@html htmlName}</b>!</p>
|
||||
{:else}
|
||||
<p>Welcome!</p>
|
||||
{/if}
|
||||
<Link to="/dash"><Button style="float: left;" color='primary' tabindex={-1}>Go to dash</Button></Link><Button style="float: right;" color='danger' on:click={logout}>Log out</Button>
|
||||
{:else}
|
||||
<Row>
|
||||
<Label>Enter your token here. You can get this by using <b>pk;token</b></Label>
|
||||
<Col xs={12} md={10}>
|
||||
<Input class="mb-2 mb-md-0" type="text" bind:value={token}/>
|
||||
</Col>
|
||||
<Col xs={12} md={2}>
|
||||
<Button style="width: 100%" color="primary" on:click={() => login(token)}>Submit</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<br>
|
||||
<Row>
|
||||
<Label>Or, you can</Label>
|
||||
<Col xs={12}>
|
||||
<Button color="dark" on:click={() => window.location.href = `https://discord.com/api/oauth2/authorize?client_id=${localStorage.isBeta ? "912009351160541225" : "466378653216014359"}&redirect_uri=${encodeURIComponent(window.location.origin + "/login/discord")}&response_type=code&scope=guilds%20identify`}>
|
||||
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.54 0c1.356 0 2.46 1.104 2.46 2.472v21.528l-2.58-2.28-1.452-1.344-1.536-1.428.636 2.22h-13.608c-1.356 0-2.46-1.104-2.46-2.472v-16.224c0-1.368 1.104-2.472 2.46-2.472h16.08zm-4.632 15.672c2.652-.084 3.672-1.824 3.672-1.824 0-3.864-1.728-6.996-1.728-6.996-1.728-1.296-3.372-1.26-3.372-1.26l-.168.192c2.04.624 2.988 1.524 2.988 1.524-1.248-.684-2.472-1.02-3.612-1.152-.864-.096-1.692-.072-2.424.024l-.204.024c-.42.036-1.44.192-2.724.756-.444.204-.708.348-.708.348s.996-.948 3.156-1.572l-.12-.144s-1.644-.036-3.372 1.26c0 0-1.728 3.132-1.728 6.996 0 0 1.008 1.74 3.66 1.824 0 0 .444-.54.804-.996-1.524-.456-2.1-1.416-2.1-1.416l.336.204.048.036.047.027.014.006.047.027c.3.168.6.3.876.408.492.192 1.08.384 1.764.516.9.168 1.956.228 3.108.012.564-.096 1.14-.264 1.74-.516.42-.156.888-.384 1.38-.708 0 0-.6.984-2.172 1.428.36.456.792.972.792.972zm-5.58-5.604c-.684 0-1.224.6-1.224 1.332 0 .732.552 1.332 1.224 1.332.684 0 1.224-.6 1.224-1.332.012-.732-.54-1.332-1.224-1.332zm4.38 0c-.684 0-1.224.6-1.224 1.332 0 .732.552 1.332 1.224 1.332.684 0 1.224-.6 1.224-1.332 0-.732-.54-1.332-1.224-1.332z"/>
|
||||
</svg>
|
||||
Login with Discord
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{#if isLoggedIn}
|
||||
<Card class="mb-4">
|
||||
<CardBody>
|
||||
Some cool stuff will go here.
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | home</title>
|
||||
</svelte:head>
|
259
dashboard/src/pages/Member.svelte
Normal file
259
dashboard/src/pages/Member.svelte
Normal file
@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Alert, Spinner, Card, CardHeader, CardBody, Accordion, AccordionItem, CardTitle } from "sveltestrap";
|
||||
import Body from '../lib/member/Body.svelte';
|
||||
import GroupBody from '../lib/group/Body.svelte';
|
||||
import { useParams, Link, navigate } from 'svelte-navigator';
|
||||
import { onMount } from 'svelte';
|
||||
import api from "../api";
|
||||
import { Member, Group } from "../api/types";
|
||||
import CardsHeader from "../lib/CardsHeader.svelte";
|
||||
import FaAddressCard from 'svelte-icons/fa/FaAddressCard.svelte'
|
||||
import FaUsers from 'svelte-icons/fa/FaUsers.svelte'
|
||||
import FaLock from 'svelte-icons/fa/FaLock.svelte'
|
||||
import FaList from 'svelte-icons/fa/FaList.svelte'
|
||||
import ListPagination from '../lib/ListPagination.svelte';
|
||||
|
||||
let loading = true;
|
||||
let groupLoading = false;
|
||||
let params = useParams();
|
||||
let err = "";
|
||||
let groupErr = "";
|
||||
let member: Member;
|
||||
let groups: Group[] = [];
|
||||
let systemGroups: Group[] = [];
|
||||
let systemMembers: Member[] = [];
|
||||
let isMainDash = false;
|
||||
let isDeleted = false;
|
||||
let notOwnSystem = false;
|
||||
|
||||
const isPage = true;
|
||||
export let isPublic = true;
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
let currentPage = 1;
|
||||
let itemsPerPage = settings && settings.accessibility && settings.accessibility.expandedcards ? 5 : 10;
|
||||
|
||||
$: indexOfLastItem = currentPage * itemsPerPage;
|
||||
$: indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
$: pageAmount = Math.ceil(groups.length / itemsPerPage);
|
||||
|
||||
$: orderedGroups = groups.sort((a, b) => a.name.localeCompare(b.name));
|
||||
$: slicedGroups = orderedGroups.slice(indexOfFirstItem, indexOfLastItem);
|
||||
|
||||
if (!isPublic && isPage) {
|
||||
let user = localStorage.getItem("pk-user");
|
||||
if (!user) navigate("/");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchMember();
|
||||
});
|
||||
|
||||
let title = isPublic ? "member" : "member (dash)";
|
||||
|
||||
async function fetchMember() {
|
||||
try {
|
||||
member = await api().members($params.id).get({auth: !isPublic});
|
||||
if (!isPublic && !member.privacy) {
|
||||
notOwnSystem = true;
|
||||
throw new Error("Member is not from own system.");
|
||||
}
|
||||
err = "";
|
||||
loading = false;
|
||||
if (member.name) {
|
||||
title = isPublic ? member.name : `${member.name} (dash)`;
|
||||
}
|
||||
groupLoading = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
fetchGroups();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGroups() {
|
||||
try {
|
||||
groups = await api().members($params.id).groups().get({auth: !isPublic, query: { with_members: !isPublic } });
|
||||
if (!isPublic) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
systemGroups = await api().systems("@me").groups.get({ auth: true, query: { with_members: true } });
|
||||
}
|
||||
groupErr = "";
|
||||
groupLoading = false;
|
||||
// we can't use with_members from a group list from a member endpoint yet, but I'm leaving this in in case we do
|
||||
// (this is needed for editing a group member list from the member page)
|
||||
/* if (!isPublic) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
systemMembers = await api().systems("@me").members.get({auth: true});
|
||||
} */
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
groupErr = error.message;
|
||||
groupLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGroups() {
|
||||
groupLoading = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
fetchGroups();
|
||||
}
|
||||
|
||||
function updateDelete() {
|
||||
isDeleted = true;
|
||||
}
|
||||
|
||||
function updateGroupList(event: any) {
|
||||
groups = groups.map(group => group.id !== event.detail.id ? group : event.detail);
|
||||
systemGroups = systemGroups.map(group => group.id !== event.detail.id ? group : event.detail);
|
||||
}
|
||||
|
||||
function deleteGroupFromList(event: any) {
|
||||
groups = groups.filter(group => group.id !== event.detail);
|
||||
systemGroups = systemGroups.filter(group => group.id !== event.detail);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
{#if settings && settings.appearance.color_background && !notOwnSystem}
|
||||
<div class="background" style="background-color: {member && `#${member.color}`}"></div>
|
||||
{/if}
|
||||
{#if member && member.banner && settings && settings.appearance.banner_top && !notOwnSystem}
|
||||
<div class="banner" style="background-image: url({member.banner})" />
|
||||
{/if}
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<h2 class="visually-hidden">Viewing {isPublic ? "a public" : "your own"} member</h2>
|
||||
{#if isDeleted}
|
||||
<Alert color="success">Member has been successfully deleted. <Link to="/dash">Return to dash</Link></Alert>
|
||||
{:else}
|
||||
{#if isPublic}
|
||||
<Alert color="info" aria-hidden>You are currently <b>viewing</b> a member.</Alert>
|
||||
{/if}
|
||||
{#if notOwnSystem}
|
||||
<Alert color="danger">This member does not belong to your system, did you mean to look up <Link to={`/profile/m/${member.id}`}>their public page</Link>?</Alert>
|
||||
{:else if err}
|
||||
<Alert color="danger">{@html err}</Alert>
|
||||
{:else if loading}
|
||||
<Spinner/>
|
||||
{:else if member && member.id}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={member}>
|
||||
<FaAddressCard slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Body on:deletion={updateDelete} on:updateGroups={updateGroups} bind:groups={systemGroups} bind:member={member} isPage={isPage} isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{#if groupLoading}
|
||||
<Alert color="primary"><Spinner size="sm" /> Fetching groups...</Alert>
|
||||
{:else if groupErr}
|
||||
<Alert color="danger">{groupErr}</Alert>
|
||||
{:else if groups && groups.length > 0}
|
||||
<Card class="mb-2">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaList />
|
||||
</div> Member groups
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{#if settings && settings.accessibility ? (!settings.accessibility.expandedcards && !settings.accessibility.pagelinks) : true}
|
||||
<Accordion class="mb-3" stayOpen>
|
||||
{#each slicedGroups as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={group} slot="header">
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
<GroupBody bind:members={systemMembers} on:update={updateGroupList} isMainDash={isMainDash} on:deletion={deleteGroupFromList} bind:group bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{:else}
|
||||
<AccordionItem>
|
||||
<CardsHeader bind:item={group} slot="header">
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
<GroupBody bind:members={systemMembers} on:update={updateGroupList} isMainDash={isMainDash} on:deletion={deleteGroupFromList} bind:group bind:isPublic={isPublic}/>
|
||||
</AccordionItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Accordion>
|
||||
{:else if settings.accessibility.expandedcards}
|
||||
{#each slicedGroups as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={group} >
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<GroupBody bind:members={systemMembers} on:update={updateGroupList} isMainDash={isMainDash} on:deletion={deleteGroupFromList} bind:group bind:isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<CardsHeader bind:item={group} >
|
||||
<FaLock slot="icon" />
|
||||
</CardsHeader>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<GroupBody bind:members={systemMembers} on:update={updateGroupList} isMainDash={isMainDash} on:deletion={deleteGroupFromList} bind:group bind:isPublic={isPublic}/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="my-3">
|
||||
{#each slicedGroups as group, index (group.id)}
|
||||
{#if (!isPublic && group.privacy.visibility === "public") || isPublic}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/g/${group.id}` : `/profile/g/${group.id}`}>
|
||||
<CardsHeader bind:item={group}>
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<Link class="accordion-button collapsed" style="text-decoration: none;" to={!isPublic ? `/dash/g/${group.id}` : `/profile/g/${group.id}`}>
|
||||
<CardsHeader bind:item={group}>
|
||||
<FaUsers slot="icon" />
|
||||
</CardsHeader>
|
||||
</Link>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ListPagination bind:currentPage bind:pageAmount />
|
||||
{/if}
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
<style>
|
||||
.background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
z-index: -30;
|
||||
}
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | {title}</title>
|
||||
</svelte:head>
|
19
dashboard/src/pages/PageNotFound.svelte
Normal file
19
dashboard/src/pages/PageNotFound.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Container, Card, CardHeader, CardBody, Row, Col, CardTitle } from 'sveltestrap';
|
||||
import { Link } from 'svelte-navigator';
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">404. Page not found</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
Looks like this page doesn't exist. <Link to="/">Go home</Link>?
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
96
dashboard/src/pages/Public.svelte
Normal file
96
dashboard/src/pages/Public.svelte
Normal file
@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Card, CardHeader, CardTitle, CardBody, Input, Button } from 'sveltestrap';
|
||||
import { Link, navigate } from 'svelte-navigator';
|
||||
import FaRocket from 'svelte-icons/fa/FaRocket.svelte';
|
||||
import FaStar from 'svelte-icons/fa/FaStar.svelte'
|
||||
import FaMoon from 'svelte-icons/fa/FaMoon.svelte'
|
||||
|
||||
let sysInput: string = "";
|
||||
let memberInput: string = "";
|
||||
let groupInput: string = "";
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={12}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaRocket />
|
||||
</div>System Profile
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
Submit a <b>system ID</b> to view that system's profile.
|
||||
<Row>
|
||||
<Col xs={12} lg={9} class="my-2">
|
||||
<Input on:keyup={(event) => {if (event.key === "Enter" && sysInput !== "") navigate(`/profile/s/${sysInput.toLowerCase().trim()}`)}} bind:value={sysInput} aria-label="enter system id to view system profile"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="my-2 d-flex">
|
||||
{#if sysInput !== ""}
|
||||
<Link style="text-decoration: none; flex: 1 0 auto" to="/profile/s/{sysInput.toLowerCase().trim()}"><Button class="w-100" color="primary">View</Button></Link>
|
||||
{:else}
|
||||
<Button class="w-100" disabled color="primary">View</Button>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaStar />
|
||||
</div>Member Card
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
Submit a <b>member ID</b> to view that member's profile.
|
||||
<Row>
|
||||
<Col xs={12} lg={9} class="my-2">
|
||||
<Input on:keyup={(event) => {if (event.key === "Enter" && memberInput !== "") navigate(`/profile/m/${memberInput.toLowerCase().trim()}`)}} bind:value={memberInput} aria-label="enter member id to view member profile"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="my-2 d-flex">
|
||||
{#if memberInput !== ""}
|
||||
<Link style="text-decoration: none; flex: 1 0 auto" to="/profile/m/{memberInput.toLowerCase().trim()}"><Button class="w-100" color="primary">View</Button></Link>
|
||||
{:else}
|
||||
<Button class="w-100" disabled color="primary">View</Button>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaMoon />
|
||||
</div>Group Card
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
Submit a <b>group ID</b> to view that group's profile.
|
||||
<Row>
|
||||
<Col xs={12} lg={9} class="my-2">
|
||||
<Input on:keyup={(event) => {if (event.key === "Enter" && groupInput !== "") navigate(`/profile/g/${groupInput.toLowerCase().trim()}`)}} bind:value={groupInput} aria-label="enter group id to view group profile"/>
|
||||
</Col>
|
||||
<Col xs={12} lg={3} class="my-2 d-flex">
|
||||
{#if groupInput !== ""}
|
||||
<Link style="text-decoration: none; flex: 1 0 auto" to="/profile/g/{groupInput.toLowerCase().trim()}"><Button class="w-100" color="primary">View</Button></Link>
|
||||
{:else}
|
||||
<Button class="w-100" disabled color="primary">View</Button>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | profile</title>
|
||||
</svelte:head>
|
136
dashboard/src/pages/Settings.svelte
Normal file
136
dashboard/src/pages/Settings.svelte
Normal file
@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardHeader, CardBody, Container, Row, Col, CardTitle, Tooltip, Button } from 'sveltestrap';
|
||||
import Toggle from 'svelte-toggle';
|
||||
import autosize from 'svelte-autosize';
|
||||
import FaCogs from 'svelte-icons/fa/FaCogs.svelte'
|
||||
import { Config } from '../api/types';
|
||||
import api from '../api';
|
||||
|
||||
let savedSettings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
let apiConfig: Config = JSON.parse(localStorage.getItem("pk-config"));
|
||||
|
||||
let settings = {
|
||||
appearance: {
|
||||
banner_top: false,
|
||||
banner_bottom: true,
|
||||
gradient_background: false,
|
||||
color_background: false,
|
||||
twemoji: false
|
||||
},
|
||||
accessibility: {
|
||||
opendyslexic: false,
|
||||
pagelinks: false,
|
||||
expandedcards: false
|
||||
}
|
||||
};
|
||||
|
||||
if (savedSettings) {
|
||||
settings = {...settings, ...savedSettings}
|
||||
}
|
||||
|
||||
let descriptions = apiConfig?.description_templates;
|
||||
|
||||
async function saveDescriptionTemplates() {
|
||||
const res = await api().systems("@me").settings.patch({ data: { description_templates: descriptions } });
|
||||
localStorage.setItem("pk-config", JSON.stringify(res));
|
||||
}
|
||||
|
||||
function toggleOpenDyslexic() {
|
||||
if (settings.accessibility.opendyslexic) document.getElementById("app").classList.add("dyslexic");
|
||||
else document.getElementById("app").classList.remove("dyslexic");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<Container>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaCogs />
|
||||
</div>Personal settings
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p>These settings are saved in your localstorage. This means that you have to reapply these every time you visit in a different browser, or clear your browser's cookies.</p>
|
||||
<h4>Appearance</h4>
|
||||
<hr/>
|
||||
<Row class="mb-3">
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-bannertop">Show banners in the background?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Show banners in the background of pages" toggled={settings.appearance.banner_top} on:toggle={() => {settings.appearance.banner_top = !settings.appearance.banner_top; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-bannertop" placement="bottom">If enabled, shows banners from the top of the system, member and group pages.</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-bannerbottom">Show banners at the bottom of cards?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Remove banner from bottom of cards and pages" toggled={settings.appearance.banner_bottom} on:toggle={() => {settings.appearance.banner_bottom = !settings.appearance.banner_bottom; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-bannerbottom" placement="bottom">If enabled, shows banners at the bottom of the system, member and group cards.</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-twemoji">Use twemoji?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Convert all emojis to twemoji" toggled={settings.appearance.twemoji} on:toggle={() => {settings.appearance.twemoji = !settings.appearance.twemoji; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-twemoji" placement="bottom">If enabled, converts all emojis into twemoji.</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-colorbackground">Colored background?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Use member colors as background on pages" toggled={settings.appearance.color_background} on:toggle={() => {settings.appearance.color_background = !settings.appearance.color_background; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-colorbackground" placement="bottom">If enabled, turns the background on member pages into the member's color.</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
<h4>Accessibility</h4>
|
||||
<hr/>
|
||||
<Row>
|
||||
<!-- <Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-opendyslexic">Use the opendyslexic font?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Use the opendyslexic font" toggled={settings.accessibility.opendyslexic} on:toggle={() => {settings.accessibility.opendyslexic = !settings.accessibility.opendyslexic; localStorage.setItem("pk-settings", JSON.stringify(settings)); toggleOpenDyslexic();}}/>
|
||||
<Tooltip target="s-opendyslexic" placement="bottom">If enabled, uses the opendyslexic font as it's main font.</Tooltip>
|
||||
</Col> -->
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-expandedcards">Expand cards by default?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Expand cards by default in list" toggled={settings.accessibility.expandedcards} on:toggle={() => {settings.accessibility.expandedcards = !settings.accessibility.expandedcards; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-expandedcards" placement="bottom">If enabled, lists will be expanded by default (overrides page links).</Tooltip>
|
||||
</Col>
|
||||
<Col xs={12} lg={4} class="mb-2">
|
||||
<span id="s-pagelinks">Use page links instead of cards?</span> <Toggle toggledColor="#da9317" hideLabel style="display: inline" label="Use page links instead of expandable cards" toggled={settings.accessibility.pagelinks} on:toggle={() => {settings.accessibility.pagelinks= !settings.accessibility.pagelinks; localStorage.setItem("pk-settings", JSON.stringify(settings));}}/>
|
||||
<Tooltip target="s-pagelinks" placement="bottom">If enabled, the list items will not expand, but instead link to the corresponding page.</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if apiConfig}
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaCogs />
|
||||
</div>Templates
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<p>Templates allow you to quickly set up a member description with a specific layout. Put in the template in one of the below fields, and access it whenever you create or edit a member. You can set up to 3 templates.</p>
|
||||
<b>Template 1</b>
|
||||
<textarea class="form-control" bind:value={descriptions[0]} maxlength={1000} use:autosize placeholder={descriptions[0]} aria-label="Description template 1"/>
|
||||
<br>
|
||||
<b>Template 2</b>
|
||||
<textarea class="form-control" bind:value={descriptions[1]} maxlength={1000} use:autosize placeholder={descriptions[1]} aria-label="Description template 2"/>
|
||||
<br>
|
||||
<b>Template 3</b>
|
||||
<textarea class="form-control" bind:value={descriptions[2]} maxlength={1000} use:autosize placeholder={descriptions[2]} aria-label="Description template 3"/>
|
||||
<br>
|
||||
<Button on:click={saveDescriptionTemplates}>Save</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | settings</title>
|
||||
</svelte:head>
|
||||
|
||||
<style>
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
88
dashboard/src/pages/profiles/Main.svelte
Normal file
88
dashboard/src/pages/profiles/Main.svelte
Normal file
@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { Container, Col, Row, TabContent, TabPane, Alert, Spinner } from 'sveltestrap';
|
||||
import { useParams, useLocation } from "svelte-navigator";
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import SystemMain from '../../lib/system/Main.svelte';
|
||||
import MemberList from '../../lib/member/List.svelte';
|
||||
import GroupList from '../../lib/group/List.svelte';
|
||||
|
||||
import { System } from '../../api/types';
|
||||
import api from '../../api';
|
||||
|
||||
let user: System = {};
|
||||
let settings = JSON.parse(localStorage.getItem("pk-settings"));
|
||||
|
||||
let members = [];
|
||||
let groups = [];
|
||||
|
||||
let params = useParams();
|
||||
$: id = $params.id;
|
||||
|
||||
let location = useLocation();
|
||||
let urlParams = $location.search && new URLSearchParams($location.search);
|
||||
let tabPane: string;
|
||||
if (urlParams) {
|
||||
tabPane = urlParams.get("tab");
|
||||
}
|
||||
|
||||
if (!tabPane) {
|
||||
tabPane = "system";
|
||||
}
|
||||
|
||||
let err: string;
|
||||
|
||||
let title = "system"
|
||||
|
||||
onMount(() => {
|
||||
getSystem();
|
||||
})
|
||||
|
||||
async function getSystem() {
|
||||
try {
|
||||
let res: System = await api().systems(id).get();
|
||||
user = res;
|
||||
title = user.name ? user.name : "system";
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
err = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- display the banner if there's a banner set, and if the current settings allow for it-->
|
||||
{#if user && user.banner && ((settings && settings.appearance.banner_top) || !settings)}
|
||||
<div class="banner" style="background-image: url({user.banner})" />
|
||||
{/if}
|
||||
<Container>
|
||||
<Row>
|
||||
<h1 class="visually-hidden">Viewing a public system</h1>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
{#if !user.id && !err}
|
||||
<div class="mx-auto text-center">
|
||||
<Spinner class="d-inline-block" />
|
||||
</div>
|
||||
{:else if err}
|
||||
<Alert color="danger">{err}</Alert>
|
||||
{:else}
|
||||
<Alert color="info" aria-hidden>You are currently <b>viewing</b> a system.</Alert>
|
||||
<TabContent class="mt-3">
|
||||
<TabPane tabId="system" tab="System" active={tabPane === "system"}>
|
||||
<SystemMain bind:user isPublic={true} />
|
||||
</TabPane>
|
||||
<TabPane tabId="members" tab="Members" active={tabPane === "members"}>
|
||||
<MemberList bind:list={members} isPublic={true} />
|
||||
</TabPane>
|
||||
<TabPane tabId="groups" tab="Groups" active={tabPane === "groups"}>
|
||||
<GroupList bind:members={members} bind:list={groups} isPublic={true}/>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
{/if}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
<svelte:head>
|
||||
<title>PluralKit | {title}</title>
|
||||
</svelte:head>
|
174
dashboard/src/pages/status.svelte
Normal file
174
dashboard/src/pages/status.svelte
Normal file
@ -0,0 +1,174 @@
|
||||
<script lang="ts">
|
||||
import { Container, Row, Col, Card, CardHeader, CardTitle, CardBody, Input, Button } from 'sveltestrap';
|
||||
import FaInfoCircle from 'svelte-icons/fa/FaInfoCircle.svelte'
|
||||
import ShardItem from '../lib/shard.svelte';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
let hover = null;
|
||||
|
||||
let message = "Loading...";
|
||||
let shards = [];
|
||||
let clusters = {};
|
||||
let pingAverage = "";
|
||||
let currentCommitMsg = "";
|
||||
|
||||
let foundShard = {
|
||||
id: 1,
|
||||
status: 1,
|
||||
ping:"",
|
||||
disconnection_count:0,
|
||||
last_connection:0,
|
||||
last_heartbeat:0.
|
||||
};
|
||||
foundShard = null;
|
||||
|
||||
let findShardInput = "";
|
||||
let valid = false;
|
||||
|
||||
const get = async () => {
|
||||
const pkdata = await api().private.meta.get();
|
||||
let data = pkdata.shards.sort((x, y) => (x.id > y.id) ? 1 : -1);
|
||||
let pings = 0;
|
||||
data = data.map(shard => {
|
||||
pings += shard.ping;
|
||||
shard.last_connection = new Date(Number(shard.last_connection) * 1000).toUTCString().match(/([0-9][0-9]:[0-9][0-9]:[0-9][0-9])/)?.shift()
|
||||
shard.last_heartbeat = new Date(Number(shard.last_heartbeat) * 1000).toUTCString().match(/([0-9][0-9]:[0-9][0-9]:[0-9][0-9])/)?.shift()
|
||||
return shard;
|
||||
});
|
||||
|
||||
currentCommitMsg = `Current Git commit: <a href="https://github.com/xSke/PluralKit/commit/${pkdata.version}">${pkdata.version.slice(0,7)}</a>`;
|
||||
|
||||
if (data[0].cluster_id === 0) {
|
||||
let clusterData = {};
|
||||
data.forEach(shard => {
|
||||
if (clusterData[shard.cluster_id] === undefined) clusterData[shard.cluster_id] = [];
|
||||
clusterData[shard.cluster_id].push(shard);
|
||||
});
|
||||
clusters = clusterData;
|
||||
}
|
||||
|
||||
shards = data;
|
||||
pingAverage = Math.trunc(pings / shards.length).toString();
|
||||
|
||||
message = "";
|
||||
};
|
||||
|
||||
get();
|
||||
setTimeout(get, 30 * 1000);
|
||||
|
||||
// javascript wants everything to be BigInts
|
||||
const getShardID = (guild_id: string, num_shards: number) => guild_id == "" ? -1 : (BigInt(guild_id) >> BigInt(22)) % BigInt(num_shards);
|
||||
|
||||
let shardInfoMsg = "";
|
||||
|
||||
let shardInfoHandler = (_: Event) => {
|
||||
if (findShardInput == "" || !findShardInput) {
|
||||
valid = false;
|
||||
foundShard = null;
|
||||
shardInfoMsg = "";
|
||||
return;
|
||||
};
|
||||
var match = findShardInput.match(/https:\/\/[\w+]?discord[app]?.com\/channels\/(\d+)\/\d+\/\d+/);
|
||||
if (match != null) {
|
||||
console.log("match", match)
|
||||
foundShard = shards[Number(getShardID(match[1], shards.length))];
|
||||
valid = true;
|
||||
shardInfoMsg = "";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var shard = getShardID(findShardInput, shards.length);
|
||||
if (shard == -1) {
|
||||
valid = false;
|
||||
foundShard == null;
|
||||
shardInfoMsg = "Invalid server ID";
|
||||
return;
|
||||
}
|
||||
foundShard = shards[Number(shard)];
|
||||
valid = true;
|
||||
shardInfoMsg = "";
|
||||
} catch(e) {
|
||||
valid = false;
|
||||
shardInfoMsg = "Invalid server ID";
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<Container fluid>
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
<div class="icon d-inline-block">
|
||||
<FaInfoCircle />
|
||||
</div>
|
||||
Bot status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<span>{@html currentCommitMsg}</span>
|
||||
<br>
|
||||
<noscript>Please enable JavaScript to view this page!</noscript>
|
||||
|
||||
{ shards.length } shards ({ shards.filter(x => x.status == "up").length } up) <br>
|
||||
Average latency: { pingAverage }ms
|
||||
<br><br>
|
||||
All times in UTC. More statistics available at <a href="https://stats.pluralkit.me">https://stats.pluralkit.me</a>
|
||||
<br><br>
|
||||
<details>
|
||||
<summary><b>Find my shard</b></summary>
|
||||
<br>
|
||||
Enter a server ID or a message link to find the shard currently assigned to your server:
|
||||
<br>
|
||||
<input bind:value={findShardInput} on:input={shardInfoHandler} />
|
||||
<br><br>
|
||||
<span>{ shardInfoMsg }</span>
|
||||
{#if valid}
|
||||
<h3>Your shard is: Shard { foundShard.id }</h3>
|
||||
<br>
|
||||
<span>Status: <b>{ foundShard.status }</b></span><br>
|
||||
<span>Latency: { foundShard.ping }ms</span><br>
|
||||
<span>Disconnection count: { foundShard.disconnection_count }</span><br>
|
||||
<span>Last connection: { foundShard.last_connection }</span><br>
|
||||
<span>Last heartbeat: { foundShard.last_heartbeat }</span><br>
|
||||
{/if}
|
||||
</details>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{#if Object.keys(clusters).length == 0 && shards.length > 0}
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardBody>
|
||||
<span>{ message }</span>
|
||||
{#each shards as shard}
|
||||
<ShardItem shard={shard} bind:hover={hover} />
|
||||
{/each}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/if}
|
||||
{#each Object.keys(clusters) as key}
|
||||
<Row>
|
||||
<Col class="mx-auto" xs={12} lg={11} xl={10}>
|
||||
<Card class="mb-4">
|
||||
<CardBody>
|
||||
<CardTitle style="margin-top: 8px; outline: none;">
|
||||
Cluster {key}
|
||||
</CardTitle>
|
||||
<br>
|
||||
{#each clusters[key] as shard}
|
||||
<ShardItem shard={shard} bind:hover={hover} />
|
||||
{/each}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/each}
|
||||
</Container>
|
25
dashboard/src/stores.ts
Normal file
25
dashboard/src/stores.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const loggedIn = writable(false);
|
||||
|
||||
/* export const user = writable({
|
||||
id: null,
|
||||
uuid: null,
|
||||
name: null,
|
||||
description: null,
|
||||
tag: null,
|
||||
avatar_url: null,
|
||||
banner: null,
|
||||
timezone: null,
|
||||
created: null,
|
||||
color: null,
|
||||
privacy: {
|
||||
description_privacy: null,
|
||||
member_list_privacy: null,
|
||||
front_privacy: null,
|
||||
front_history_privacy: null,
|
||||
group_list_privacy: null
|
||||
}
|
||||
}); */
|
||||
|
||||
export const currentUser = writable(null);
|
2
dashboard/src/vite-env.d.ts
vendored
Normal file
2
dashboard/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
135
dashboard/style.css
Normal file
135
dashboard/style.css
Normal file
@ -0,0 +1,135 @@
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 2.5em;
|
||||
width: 2.5em;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.banner {
|
||||
z-index: -200;
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 0.5em;
|
||||
margin: 0.25em 0 0.25em 0 !important;
|
||||
border-left: 4px solid rgba(128, 128, 128, 0.3);
|
||||
}
|
||||
|
||||
.nav-tabs * .nav-link {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-color: rgba(128, 128, 128, 0.3) !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1 0 auto !important;
|
||||
}
|
||||
|
||||
.accordion-button::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
z-index: initial !important;
|
||||
}
|
||||
|
||||
.svelecte-control .sv-control {
|
||||
border-color: rgba(128, 128, 128, 0.3) !important;
|
||||
}
|
||||
|
||||
.svelecte-control .sv-control, .sv-content, .sv-dropdown, .sv-item, .sv-item-content {
|
||||
color: var(--bs-body-color) !important;
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
}
|
||||
|
||||
.sv-item-btn {
|
||||
background-color: var(--bs-light) !important;
|
||||
}
|
||||
|
||||
.sv-item {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.d-spoiler {
|
||||
color: var(--bs-dark);
|
||||
background-color: var(--bs-dark);
|
||||
border-radius: 2px;
|
||||
transition-delay: 6000s;
|
||||
}
|
||||
|
||||
.d-spoiler::selection {
|
||||
color: var(--bs-dark);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.d-spoiler:active {
|
||||
background-color: rgba(128, 128, 128, 0.3);
|
||||
color: var(--bs-body-color);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.d-emoji {
|
||||
height: 1.125em;
|
||||
width: auto;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1125em;
|
||||
}
|
||||
|
||||
code {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
.description a {
|
||||
text-decoration: none;
|
||||
color: #457ead !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.banner {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
img.emoji {
|
||||
height: 1.125em;
|
||||
width: auto;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1125em;
|
||||
}
|
115
dashboard/styles/bootstrap-nightshade.scss
vendored
Normal file
115
dashboard/styles/bootstrap-nightshade.scss
vendored
Normal file
@ -0,0 +1,115 @@
|
||||
/*!
|
||||
* Bootstrap v5.x.x (https://getbootstrap.com/)
|
||||
* Copyright 2011-2021 The Bootstrap Authors
|
||||
* Copyright 2011-2021 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*
|
||||
* Bootstrap-Nightshade (https://vinorodrigues.github.io/bootstrap-dark-5/)
|
||||
* Copyright 2020-2021 Vino Rodrigues
|
||||
* This version is a dual color theme, with the default light mode and the dark
|
||||
* elements optioned in by a `.dark` class in `<html>` tag.
|
||||
* Why `<html>`? See https://css-tricks.com/html-vs-body-in-css/#quirky-background-color
|
||||
*/
|
||||
|
||||
// stylelint-disable-next-line scss/dollar-variable-default
|
||||
$enable-colors-hack: true;
|
||||
|
||||
// Configuration
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap-dark-5/scss/dark/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap-dark-5/scss/variables-alt"; // dark color set is here
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap-dark-5/scss/dark/mixins-2"; // !!!! fix for issue #32
|
||||
@import "bootstrap/scss/utilities";
|
||||
@import "bootstrap-dark-5/scss/dark/utilities";
|
||||
@import "bootstrap-dark-5/scss/dark/patch"; // missing from BS, unsupported patch/hack
|
||||
|
||||
// :root { color-scheme: light; } // !!!! fix for issue #33, do not set
|
||||
|
||||
// Layout & components
|
||||
// @import "bootstrap-dark-5/scss/dark/root";
|
||||
@import "bootstrap/scss/reboot";
|
||||
@import "bootstrap-dark-5/scss/dark/root-alts"; // !!!! fix for issue #35
|
||||
@import "bootstrap/scss/type";
|
||||
@import "bootstrap/scss/images";
|
||||
@import "bootstrap/scss/containers";
|
||||
@import "bootstrap/scss/grid";
|
||||
@import "bootstrap/scss/tables";
|
||||
@import "bootstrap/scss/forms";
|
||||
@import "bootstrap/scss/buttons";
|
||||
@import "bootstrap/scss/transitions";
|
||||
@import "bootstrap/scss/dropdown";
|
||||
@import "bootstrap/scss/button-group";
|
||||
@import "bootstrap/scss/nav";
|
||||
@import "bootstrap/scss/navbar";
|
||||
@import "bootstrap/scss/card";
|
||||
@import "bootstrap/scss/accordion";
|
||||
@import "bootstrap/scss/breadcrumb";
|
||||
@import "bootstrap/scss/pagination";
|
||||
@import "bootstrap/scss/badge";
|
||||
@import "bootstrap/scss/alert";
|
||||
@import "bootstrap/scss/progress";
|
||||
@import "bootstrap/scss/list-group";
|
||||
@import "bootstrap/scss/close";
|
||||
@import "bootstrap/scss/toasts";
|
||||
@import "bootstrap/scss/modal";
|
||||
@import "bootstrap/scss/tooltip";
|
||||
@import "bootstrap/scss/popover";
|
||||
@import "bootstrap/scss/carousel";
|
||||
@import "bootstrap/scss/spinners";
|
||||
@import "bootstrap/scss/offcanvas";
|
||||
@import "bootstrap/scss/placeholders";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap/scss/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap/scss/utilities/api";
|
||||
|
||||
/*
|
||||
* ---------- Dark Mode ------------------------------------------------------
|
||||
*/
|
||||
|
||||
@include color-scheme-alt("html.dark") {
|
||||
|
||||
// :root { color-scheme: dark; } // !!!! fix for issue #33, do not set
|
||||
|
||||
// Layout & components
|
||||
// @import "bootstrap-dark-5/scss/dark/root"; // !!!! fix for issue #33, do not use `dark/root`
|
||||
@import "bootstrap-dark-5/scss/dark/reboot";
|
||||
@import "bootstrap-dark-5/scss/dark/type";
|
||||
@import "bootstrap-dark-5/scss/dark/images";
|
||||
@import "bootstrap-dark-5/scss/dark/tables";
|
||||
@import "bootstrap-dark-5/scss/dark/forms";
|
||||
@import "bootstrap-dark-5/scss/dark/buttons-2"; // !!!! fix for issue #32
|
||||
@import "bootstrap-dark-5/scss/dark/dropdown";
|
||||
@import "bootstrap-dark-5/scss/dark/button-group";
|
||||
@import "bootstrap-dark-5/scss/dark/nav";
|
||||
@import "bootstrap-dark-5/scss/dark/navbar";
|
||||
@import "bootstrap-dark-5/scss/dark/card";
|
||||
@import "bootstrap-dark-5/scss/dark/accordion";
|
||||
@import "bootstrap-dark-5/scss/dark/breadcrumb";
|
||||
@import "bootstrap-dark-5/scss/dark/pagination";
|
||||
@import "bootstrap-dark-5/scss/dark/badge";
|
||||
@import "bootstrap-dark-5/scss/dark/alert";
|
||||
@import "bootstrap-dark-5/scss/dark/progress";
|
||||
@import "bootstrap-dark-5/scss/dark/list-group";
|
||||
@import "bootstrap-dark-5/scss/dark/close";
|
||||
@import "bootstrap-dark-5/scss/dark/toasts";
|
||||
@import "bootstrap-dark-5/scss/dark/modal";
|
||||
@import "bootstrap-dark-5/scss/dark/tooltip";
|
||||
@import "bootstrap-dark-5/scss/dark/popover";
|
||||
@import "bootstrap-dark-5/scss/dark/carousel";
|
||||
@import "bootstrap-dark-5/scss/dark/offcanvas";
|
||||
@import "bootstrap-dark-5/scss/dark/placeholders";
|
||||
|
||||
// Helpers
|
||||
@import "bootstrap-dark-5/scss/dark/helpers";
|
||||
|
||||
// Utilities
|
||||
@import "bootstrap-dark-5/scss/dark/utilities/api";
|
||||
|
||||
// Unique to dark-mode
|
||||
@import "bootstrap-dark-5/scss/dark/dark";
|
||||
}
|
51
dashboard/styles/dark.scss
Normal file
51
dashboard/styles/dark.scss
Normal file
@ -0,0 +1,51 @@
|
||||
@import "bootstrap-dark-5/scss/variables-alt";
|
||||
|
||||
.dark {
|
||||
@import "highlight.js/styles/github-dark.css";
|
||||
.navbar.bg-light {
|
||||
background-color: $body-bg-alt !important;
|
||||
}
|
||||
|
||||
.footer.bg-light {
|
||||
background-color: $dark-alt !important;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
.btn.btn-transparent {
|
||||
color: rgba(250,250,250,.55);
|
||||
}
|
||||
}
|
||||
|
||||
pre, code {
|
||||
background: $dark-alt !important;
|
||||
}
|
||||
|
||||
// bootstrap
|
||||
.nav-tabs * .nav-link {
|
||||
background-color: $body-bg-alt !important;
|
||||
}
|
||||
|
||||
//svelecte
|
||||
.svelecte-control .sv-control, .sv-content, .sv-dropdown, .sv-item, .sv-item-content {
|
||||
color: $body-color-alt !important; //this can also be optionally overwritten
|
||||
background-color: $body-bg-alt !important;
|
||||
}
|
||||
|
||||
.sv-item-btn {
|
||||
background-color: $light-alt !important;
|
||||
}
|
||||
|
||||
// discord markdown
|
||||
.d-spoiler {
|
||||
color: $dark-alt; //overwrite
|
||||
background-color: $dark-alt; //overwrite
|
||||
}
|
||||
|
||||
.d-spoiler::selection {
|
||||
color: $dark-alt; //overwrite
|
||||
}
|
||||
|
||||
.d-spoiler:active {
|
||||
color: $body-color-alt; //overwrite
|
||||
}
|
||||
}
|
190
dashboard/styles/generic.scss
Normal file
190
dashboard/styles/generic.scss
Normal file
@ -0,0 +1,190 @@
|
||||
/*
|
||||
This stylesheet should be used globally regardless of theming
|
||||
|
||||
some specific rules are meant to be overwritten by the individual themes
|
||||
*/
|
||||
@import url('http://fonts.cdnfonts.com/css/open-dyslexic');
|
||||
|
||||
// some variables
|
||||
$breakpoint-xs: 0px;
|
||||
$breakpoint-sm: 576px;
|
||||
$breakpoint-md: 768px;
|
||||
$breakpoint-lg: 992px;
|
||||
$breakpoint-xl: 1200px;
|
||||
$breakpoint-xxl: 1400px;
|
||||
|
||||
$gray-transparent: rgba(128, 128, 128, 0.3);
|
||||
|
||||
// general elements
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 0.5em;
|
||||
margin: 0.25em 0 0.25em 0 !important;
|
||||
border-left: 4px solid $gray-transparent; // overwrite this in the individual styles
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
background: var(--bs-light) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.dyslexic {
|
||||
font-family: 'Open-Dyslexic', sans-serif;
|
||||
}
|
||||
|
||||
// dashboard specific elements
|
||||
.icon {
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
height: 2.5em;
|
||||
width: 2.5em;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.banner {
|
||||
z-index: -20;
|
||||
width: 100%;
|
||||
height: 40vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-md) {
|
||||
.banner {
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
.description a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// bootstrap elements
|
||||
.container {
|
||||
flex: 1 0 auto !important;
|
||||
}
|
||||
|
||||
.nav-tabs * .nav-link {
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-color: $gray-transparent !important; // overwrite
|
||||
border-bottom: none !important
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.accordion-button::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
z-index: initial !important;
|
||||
}
|
||||
|
||||
// svelecte styling
|
||||
.svelecte-control .sv-control {
|
||||
border-color: $gray-transparent !important; // overwrite
|
||||
}
|
||||
|
||||
.svelecte-control .sv-control, .sv-content, .sv-dropdown, .sv-item, .sv-item-content {
|
||||
color: var(--bs-body-color) !important; //this can also be optionally overwritten
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
}
|
||||
|
||||
.sv-dd-item-active {
|
||||
outline: 5px auto Highlight;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.sv-dropdown {
|
||||
position: static !important;
|
||||
border: 1px solid rgba(128, 128, 128, 0.3) !important;
|
||||
}
|
||||
|
||||
.sv-item-btn {
|
||||
background-color: var(--bs-light) !important;
|
||||
}
|
||||
|
||||
.sv-item {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
// discord markdown styling
|
||||
.d-spoiler {
|
||||
color: var(--bs-dark); //overwrite
|
||||
background-color: var(--bs-dark); //overwrite
|
||||
border-radius: 2px;
|
||||
transition-delay: 6000s;
|
||||
}
|
||||
|
||||
.d-spoiler::selection {
|
||||
color: var(--bs-dark); //overwrite
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.d-spoiler:active {
|
||||
background-color: $gray-transparent; // overwrite
|
||||
color: var(--bs-body-color); //overwrite
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.d-emoji {
|
||||
height: 1.125em;
|
||||
width: auto;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1125em;
|
||||
}
|
||||
|
||||
//twemoji
|
||||
img.emoji {
|
||||
height: 1.125em;
|
||||
width: auto;
|
||||
margin: 0 .05em 0 .1em;
|
||||
vertical-align: -0.1125em;
|
||||
}
|
||||
|
||||
//misc
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
25
dashboard/styles/highlight.scss
Normal file
25
dashboard/styles/highlight.scss
Normal file
@ -0,0 +1,25 @@
|
||||
.dark {
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||
}
|
||||
|
||||
.light {
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
}
|
9
dashboard/styles/themes.scss
Normal file
9
dashboard/styles/themes.scss
Normal file
@ -0,0 +1,9 @@
|
||||
$primary: #da9317;
|
||||
$primary-alt: #da9317;
|
||||
@import "generic.scss";
|
||||
@import "bootstrap/scss/bootstrap.scss";
|
||||
@import 'highlight.scss';
|
||||
|
||||
// dark mode. dark.scss extends nightshade
|
||||
@import "bootstrap-nightshade.scss";
|
||||
@import "dark.scss";
|
7
dashboard/svelte.config.js
Normal file
7
dashboard/svelte.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import sveltePreprocess from 'svelte-preprocess'
|
||||
|
||||
export default {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: sveltePreprocess()
|
||||
}
|
20
dashboard/tsconfig.json
Normal file
20
dashboard/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "esnext",
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"importsNotUsedAsValues": "remove"
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
13
dashboard/vite.config.js
Normal file
13
dashboard/vite.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
import resolve from 'path';
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
optimizeDeps: { exclude: ["svelte-navigator"] },
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: true,
|
||||
}
|
||||
})
|
1239
dashboard/yarn.lock
Normal file
1239
dashboard/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,7 @@ module.exports = {
|
||||
nextLinks: true,
|
||||
prevLinks: true,
|
||||
nav: [
|
||||
{ text: "Web dashboard", link: "https://dash.pluralkit.me" },
|
||||
{ text: "Support server", link: "https://discord.gg/PczBt78" },
|
||||
{ text: "Invite bot", link: "https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904" }
|
||||
],
|
||||
|
@ -7673,6 +7673,13 @@ vuepress-plugin-container@^2.0.2:
|
||||
"@vuepress/shared-utils" "^1.2.0"
|
||||
markdown-it-container "^2.0.0"
|
||||
|
||||
vuepress-plugin-dehydrate@1.1.5:
|
||||
version "1.1.5"
|
||||
resolved "https://registry.yarnpkg.com/vuepress-plugin-dehydrate/-/vuepress-plugin-dehydrate-1.1.5.tgz#c29f08b85f337d3bc7c0ba09fe58cd9ed2f6e6c1"
|
||||
integrity sha512-9F2x1vLCK4poPUMkLupD4HsgWdbZ68Escvma+DE1Dk6aAJdH5FGwmfOMxj4sMCBwz7S4s6bTMna+QQgD3+bzBA==
|
||||
dependencies:
|
||||
"@vuepress/shared-utils" "^1.2.0"
|
||||
|
||||
vuepress-plugin-plausible-analytics@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/vuepress-plugin-plausible-analytics/-/vuepress-plugin-plausible-analytics-0.2.1.tgz#d6733b17305bd37d5b9797460c8a8d179b79fc2d"
|
||||
|
Loading…
Reference in New Issue
Block a user