refactor(dashboard): re-organize dashboard files

This commit is contained in:
Jake Fulmine
2022-11-26 13:23:59 +01:00
parent e0cde35b3d
commit 1b3f188997
41 changed files with 165 additions and 166 deletions

View File

@@ -0,0 +1,105 @@
<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 '../../components/system/Main.svelte';
import List from '../../components/list/List.svelte';
import { System } from '../../api/types';
import api from '../../api';
// 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|number = params && params.get("tab") || "system";
let listView: string = params && params.get("view") || "list";
// change the URL when changing tabs
function navigateTo(tab: string|number, view: string) {
let url = "./dash";
if (tab || view) url += "?";
if (tab) url += `tab=${tab}`
if (tab && view) url += "&";
if (view) url += `view=${view}`
navigate(url);
tabPane = tab;
}
// 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" on:tab={(e) => navigateTo(e.detail, listView)}>
<TabPane tabId="system" tab="System" active={tabPane === "system"}>
<SystemMain bind:user={user} isPublic={false} />
</TabPane>
<TabPane tabId="members" tab="Members" active={tabPane === "members"}>
<List on:viewChange={(e) => navigateTo("members", e.detail)} bind:groups={groups} bind:members={members} isPublic={false} itemType={"member"} bind:view={listView} isDash={true} />
</TabPane>
<TabPane tabId="groups" tab="Groups" active={tabPane === "groups"}>
<List on:viewChange={(e) => navigateTo("members", e.detail)} bind:members={members} bind:groups={groups} isPublic={false} itemType={"group"} bind:view={listView} isDash={true} />
</TabPane>
</TabContent>
</Col>
</Row>
</Container>
{/if}
<svelte:head>
<title>PluralKit | dash</title>
</svelte:head>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { Container, Row, Col, Alert, Spinner, Card, CardHeader, CardBody, CardTitle, Tooltip } from "sveltestrap";
import Body from '../../../components/group/Body.svelte';
import { useParams, Link, navigate, useLocation } from 'svelte-navigator';
import { onMount } from 'svelte';
import api from "../../../api";
import { Member, Group } from "../../../api/types";
import CardsHeader from "../../../components/common/CardsHeader.svelte";
import FaUsers from 'svelte-icons/fa/FaUsers.svelte';
import FaList from 'svelte-icons/fa/FaList.svelte';
import ListPagination from '../../../components/common/ListPagination.svelte';
import ListView from '../../../components/list/ListView.svelte';
import CardView from '../../../components/list/CardView.svelte';
// get the state from the navigator so that we know which tab to start on
let location = useLocation();
let urlParams = $location.search && new URLSearchParams($location.search);
let listView: string = urlParams && urlParams.get("view") || "list";
let loading = true;
let memberLoading = false;
let params = useParams();
let err = "";
let memberErr = "";
let group: Group;
let members: Member[] = [];
let systemMembers: Group[] = [];
let isDeleted = false;
let notOwnSystem = false;
let copied = false;
const isPage = true;
export let isPublic = true;
let settings = JSON.parse(localStorage.getItem("pk-settings"));
let currentPage = 1;
let itemsPerPage = listView === "card" ? 12 : 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);
}
async function copyShortLink(event?) {
if (event) {
let ctrlDown = event.ctrlKey||event.metaKey; // mac support
if (!(ctrlDown && event.key === "c") && event.key !== "Enter") return;
}
try {
await navigator.clipboard.writeText(`https://pk.mt/g/${group.id}`);
copied = true;
await new Promise(resolve => setTimeout(resolve, 2000));
copied = false;
} catch (error) {
console.log(error);
}
}
</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}>
<div slot="icon" style="cursor: pointer;" id={`group-copy-${group.id}`} on:click|stopPropagation={() => copyShortLink()} on:keydown={(e) => copyShortLink(e)} tabindex={0} >
<FaUsers slot="icon" />
</div>
</CardsHeader>
<Tooltip placement="top" target={`group-copy-${group.id}`}>{copied ? "Copied!" : "Copy public link"}</Tooltip>
</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 listView === "card"}
<CardView list={slicedMembers} {isPublic} itemType="member" isDash={false} />
{:else}
<ListView on:deletion={(e) => deleteMemberFromList(e)} bind:list={slicedMembers} isPublic={isPublic} itemType="member" itemsPerPage={itemsPerPage} currentPage={currentPage} fullLength={members.length} />
<ListPagination bind:currentPage bind:pageAmount />
{/if}
{/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>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { Container, Row, Col, Alert, Spinner, Card, CardHeader, CardBody, CardTitle, Tooltip } from "sveltestrap";
import Body from '../../../components/member/Body.svelte';
import ListView from '../../../components/list/ListView.svelte';
import { useParams, Link, navigate, useLocation } from 'svelte-navigator';
import { onMount } from 'svelte';
import api from "../../../api";
import { Member, Group } from "../../../api/types";
import CardsHeader from "../../../components/common/CardsHeader.svelte";
import FaAddressCard from 'svelte-icons/fa/FaAddressCard.svelte'
import FaList from 'svelte-icons/fa/FaList.svelte'
import ListPagination from '../../../components/common/ListPagination.svelte';
import CardView from '../../../components/list/CardView.svelte';
// get the state from the navigator so that we know which tab to start on
let location = useLocation();
let urlParams = $location.search && new URLSearchParams($location.search);
let listView: string = urlParams && urlParams.get("view") || "list";
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 isDeleted = false;
let notOwnSystem = false;
let copied = false;
const isPage = true;
export let isPublic = true;
let settings = JSON.parse(localStorage.getItem("pk-settings"));
let currentPage = 1;
let itemsPerPage = listView === "card" ? 12 : 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 deleteGroupFromList(event: any) {
groups = groups.filter(group => group.id !== event.detail);
systemGroups = systemGroups.filter(group => group.id !== event.detail);
}
async function copyShortLink(event?) {
if (event) {
let ctrlDown = event.ctrlKey||event.metaKey; // mac support
if (!(ctrlDown && event.key === "c") && event.key !== "Enter") return;
}
try {
await navigator.clipboard.writeText(`https://pk.mt/m/${member.id}`);
copied = true;
await new Promise(resolve => setTimeout(resolve, 2000));
copied = false;
} catch (error) {
console.log(error);
}
}
</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}>
<div slot="icon" style="cursor: pointer;" id={`member-copy-${member.id}`} on:click|stopPropagation={() => copyShortLink()} on:keydown={(e) => copyShortLink(e)} tabindex={0} >
<FaAddressCard slot="icon" />
</div>
</CardsHeader>
<Tooltip placement="top" target={`member-copy-${member.id}`}>{copied ? "Copied!" : "Copy public link"}</Tooltip>
</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 listView === "card"}
<CardView list={slicedGroups} {isPublic} itemType="group" isDash={false} />
{:else}
<ListView on:deletion={(e) => deleteGroupFromList(e)} bind:list={slicedGroups} isPublic={isPublic} itemType="group" itemsPerPage={itemsPerPage} currentPage={currentPage} fullLength={groups.length} />
<ListPagination bind:currentPage bind:pageAmount />
{/if}
{/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>

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { Container, Col, Row, TabContent, TabPane, Alert, Spinner } from 'sveltestrap';
import { useParams, useLocation, navigate } from "svelte-navigator";
import { onMount } from 'svelte';
import SystemMain from '../../components/system/Main.svelte';
import List from '../../components/list/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|number = urlParams && urlParams.get("tab") || "system";
let listView: string = urlParams && urlParams.get("view") || "list";
// change the URL when changing tabs
function navigateTo(tab: string|number, view: string) {
let url = `./${id}`;
if (tab || view) url += "?";
if (tab) url += `tab=${tab}`
if (tab && view) url += "&";
if (view) url += `view=${view}`
navigate(url);
tabPane = tab;
}
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" on:tab={(e) => navigateTo(e.detail, listView)}>
<TabPane tabId="system" tab="System" active={tabPane === "system"}>
<SystemMain bind:user isPublic={true} />
</TabPane>
<TabPane tabId="members" tab="Members" active={tabPane === "members"}>
<List on:viewChange={(e) => navigateTo("members", e.detail)} members={members} groups={groups} isPublic={true} itemType={"member"} bind:view={listView} isDash={false}/>
</TabPane>
<TabPane tabId="groups" tab="Groups" active={tabPane === "groups"}>
<List on:viewChange={(e) => navigateTo("groups", e.detail)} members={members} groups={groups} isPublic={true} itemType={"group"} bind:view={listView} isDash={false} />
</TabPane>
</TabContent>
{/if}
</Col>
</Row>
</Container>
<svelte:head>
<title>PluralKit | {title}</title>
</svelte:head>

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Link, useLocation, useParams, navigate } from 'svelte-navigator';
import { Alert, Col, Container, Row, Card, CardBody, CardHeader, CardTitle, Input, Label, Button, Accordion, AccordionHeader, AccordionItem } from 'sveltestrap';
import FaRandom from 'svelte-icons/fa/FaRandom.svelte'
import CardsList from '../../components/list/ListView.svelte';
import api from '../../api';
import { Group, Member } from '../../api/types';
export let isPublic: boolean = false;
export let type: string = "member";
export let pickFromGroup: boolean = false;
let list: Member[]|Group[] = [];
let randomList: Member[]|Group[] = [];
let loading = true;
let err: string;
let params = useParams();
$: id = $params.id;
$: groupId = $params.groupId;
let location = useLocation();
let searchParams = $location.search && new URLSearchParams($location.search);
let path = $location.pathname;
let amount: number = 1;
if (searchParams && searchParams.get("amount")) {
amount = parseInt(searchParams.get("amount"));
if (amount === NaN) amount = 1;
else if (amount > 5) amount = 5;
}
let usePrivateItems = false;
if (searchParams && searchParams.get("all") === "true") usePrivateItems = true;
let allowDoubles = false;
if (searchParams && searchParams.get("doubles") === "true") allowDoubles = true;
// just a hidden option to expand the cards by default regardless of your global settings
let openByDefault = false;
if (searchParams && searchParams.get("open") === "true") openByDefault = true;
let rollCounter = 1;
onMount(async () => {
await fetchList(amount, usePrivateItems);
});
async function fetchList(amount: number, usePrivateMembers?: boolean|string) {
err = "";
loading = true;
try {
if (type === "member") {
if (pickFromGroup) {
const res: Member[] = await api().groups(groupId).members.get({auth: !isPublic});
list = res;
} else {
const res: Member[] = await api().systems(isPublic ? id : "@me").members.get({ auth: !isPublic });
list = res;
}
}
else if (type === "group") {
const res: Group[] = await api().systems(isPublic ? id : "@me").groups.get({ auth: !isPublic });
list = res;
}
else throw new Error(`Unknown list type ${type}`);
randomList = randomizeList(amount, usePrivateMembers, allowDoubles);
} catch (error) {
console.log(error);
err = error.message;
}
loading = false;
}
function randomizeList(amount: number, usePrivateMembers?: boolean|string, allowDoubles?: boolean|string) {
err = "";
let filteredList = [...list];
if (!isPublic && (!usePrivateMembers || usePrivateMembers === "false")) filteredList = (list as Member[]).filter(item => item.privacy && item.privacy.visibility === "public" ? true : false);
let cappedAmount = amount;
if (amount > filteredList.length) cappedAmount = filteredList.length;
if (cappedAmount === 0) err = `No valid ${type}s could be randomized. ${!isPublic ? `If every ${type} is privated, roll again with private ${type}s included.` : ""}`;
let tempList = [];
for (let i = 0; i < cappedAmount; i++) {
let index = Math.floor(Math.random() * filteredList.length);
tempList.push(filteredList[index]);
if (!allowDoubles || allowDoubles === "false") {
filteredList.splice(index, 1);
}
}
return tempList;
}
function rerollList() {
let amount = parseInt(optionAmount);
let paramArray = [];
if (amount > 1) paramArray.push(`amount=${amount}`);
if (optionAllowDoubles === "true") paramArray.push("doubles=true");
if (optionUsePrivateItems === "true") paramArray.push("all=true");
if (openByDefault === true) paramArray.push("open=true");
randomList = randomizeList(parseInt(optionAmount), optionUsePrivateItems, optionAllowDoubles);
navigate(`${path}${paramArray.length > 0 ? `?${paramArray.join('&')}` : ""}`);
rollCounter ++;
}
function capitalizeFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
let optionAmount = amount.toString();
let optionUsePrivateItems = "false";
if (usePrivateItems === true) optionUsePrivateItems = "true";
let optionAllowDoubles = "false";
if (allowDoubles === true) optionAllowDoubles = "true";
function getItemLink(item: Member | Group): string {
let url: string;
if (isPublic) url = "/dash/";
else url = "/profile/";
if (type === "member") url += "m/";
else if (type === "group") url += "g/";
url += item.id;
return url;
}
function getBackUrl() {
let str: string;
if (isPublic) {
str = "/profile";
if (!pickFromGroup) str += `/s/${id}`;
} else str = "/dash"
if (pickFromGroup) str += `/g/${groupId}`;
return str;
}
</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">
<FaRandom />
</div>Randomize {capitalizeFirstLetter(type)}s {isPublic && id ? `(${id})` : pickFromGroup ? `(${groupId})` : ""}</CardTitle>
</CardHeader>
<CardBody>
<Row>
<Col xs={12} lg={4} class="mb-2">
<Label>Amount:</Label>
<Input bind:value={optionAmount} type="select" aria-label="amount">
<option default={amount === 1}>1</option>
<option default={amount === 2}>2</option>
<option default={amount === 3}>3</option>
<option default={amount === 4}>4</option>
<option default={amount === 5}>5</option>
</Input>
</Col>
<Col xs={12} lg={4} class="mb-2">
<Label>Allow duplicates:</Label>
<Input bind:value={optionAllowDoubles} type="select" aria-label="allow duplicates">
<option value="false" default={allowDoubles === false}>no</option>
<option value="true" default={allowDoubles === true}>yes</option>
</Input>
</Col>
{#if !isPublic}
<Col xs={12} lg={4} class="mb-2">
<Label>Use all {type}s:</Label>
<Input bind:value={optionUsePrivateItems} type="select" aria-label="include private members">
<option value="false" default={usePrivateItems === false}>no (only public {type}s)</option>
<option value="true" default={usePrivateItems === true}>yes (include private {type}s)</option>
</Input>
</Col>
{/if}
</Row>
<Button color="primary" on:click={() => {rerollList()}}>
Reroll list
</Button>
<Link to={getBackUrl()}>
<Button color="secondary" tabindex={-1} aria-label={`back to ${pickFromGroup ? "group" : "system"}`}>
Back to {pickFromGroup ? "group" : "system"}
</Button>
</Link>
</CardBody>
</Card>
{#if loading}
<span>loading...</span>
{:else if err}
<Alert color="danger">{err}</Alert>
{:else}
<CardsList openByDefault={openByDefault} bind:list={randomList} isPublic={true} itemType={type} itemsPerPage={5} currentPage={rollCounter} fullLength={5 * rollCounter - (5 - randomList.length)} />
{/if}
</Col>
</Row>
</Container>

View 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}

View 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}

View File

@@ -0,0 +1,40 @@
<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"))
{
let res: any;
try {
res = await api().private.discord.callback.post({ data: { code: params.get("code"), redirect_domain: window.location.origin } });
} catch(e) {
text = "Error: " + e.data.error;
return;
}
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>

View 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>

View 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>

View 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>

View 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 { autoresize } from 'svelte-textarea-autoresize';
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:autoresize placeholder={descriptions[0]} aria-label="Description template 1"/>
<br>
<b>Template 2</b>
<textarea class="form-control" bind:value={descriptions[1]} maxlength={1000} use:autoresize placeholder={descriptions[1]} aria-label="Description template 2"/>
<br>
<b>Template 3</b>
<textarea class="form-control" bind:value={descriptions[2]} maxlength={1000} use:autoresize 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>

View File

@@ -0,0 +1,225 @@
<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 '../../components/status/Shard.svelte';
import api from '../../api';
let message = "Loading...";
let shards = [];
let clusters = {};
let pingAverage = "";
let foundShard = {
id: 1,
status: 1,
ping:"",
disconnection_count:0,
last_connection:0,
last_heartbeat:0,
heartbeat_minutes_ago: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.heartbeat_minutes_ago = heartbeatMinutesAgo(shard);
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;
});
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";
}
};
function heartbeatMinutesAgo(shard) {
// difference in milliseconds
const msDifference = Math.abs((Number(new Date(Number(shard.last_heartbeat) * 1000)) - Date.now()));
// convert to minutes
const minuteDifference = msDifference / (60 * 1000);
return minuteDifference;
}
</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">
<FaInfoCircle />
</div>
Bot status
</CardTitle>
</CardHeader>
<CardBody>
<br>
<noscript>Please enable JavaScript to view this page!</noscript>
<Row>
<Col class="mb-2" xs={12} md={6} lg={4} >
<span>{ shards.length } shards ({ shards.filter(x => x.status == "up").length } up)</span>
</Col>
<Col class="mb-2" xs={12} md={6} lg={4}>
<span>Average latency: { pingAverage }ms</span>
</Col>
<Col class="mb-2" xs={12} md={6} lg={4}>
<span>More statistics available at <a href="https://stats.pluralkit.me">https://stats.pluralkit.me</a></span>
</Col>
</Row>
<hr/>
<h3 style="font-size: 1.2rem;">Find my shard</h3>
<p>Enter a server ID or a message link to find the shard currently assigned to your server</p>
<label for="shardInput">Server ID or message link</label>
<input id="shardInput" class="form form-control" bind:value={findShardInput} on:input={shardInfoHandler} />
{#if shardInfoMsg || foundShard}
<Card class="mt-4">
<CardHeader>
<CardTitle>
{#if shardInfoMsg}
{shardInfoMsg}
{/if}
{#if foundShard}
Your shard is: Shard { foundShard.id }
{/if}
</CardTitle>
</CardHeader>
{#if valid}
<CardBody>
<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 } UTC</span><br>
<span>Last heartbeat: { foundShard.last_heartbeat } UTC</span><br>
</CardBody>
{/if}
</Card>
{/if}
</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} />
{/each}
</CardBody>
</Card>
</Col>
</Row>
{/if}
<Row>
<Col class="mx-auto" xs={12} lg={11} xl={10}>
<div class="cluster-grid">
{#each Object.keys(clusters) as key}
<div class="cluster-card">
<span class="cluster-text">Cluster {key} &nbsp</span>
</div>
<div class="cluster-shards">
{#each clusters[key] as shard}
<ShardItem shard={shard} />
{/each}
</div>
{/each}
</div>
</Col>
</Row>
</Container>
<style>
.cluster-shards {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
margin-bottom: 0.5em;
}
.cluster-grid {
display: grid;
grid-template-columns: 100%;
gap: 0.5em;
}
@media (min-width: 576px) {
.cluster-card {
text-align: right;
margin-bottom: 0.5em;
}
.cluster-text {
line-height: 3em;
}
.cluster-grid {
grid-template-columns: max-content 1fr;
}
}
</style>