Add dashboard to build
24
.gitignore
vendored
@ -5,3 +5,27 @@ target
|
|||||||
/.idea
|
/.idea
|
||||||
web/static/index.html
|
web/static/index.html
|
||||||
web/static/assets
|
web/static/assets
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
@ -51,6 +51,7 @@ assets = [
|
|||||||
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
["conf/Rocket.toml", "etc/reminder-rs/Rocket.toml", "600"],
|
||||||
["web/static/**/*", "lib/reminder-rs/static", "644"],
|
["web/static/**/*", "lib/reminder-rs/static", "644"],
|
||||||
["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"],
|
["reminder-dashboard/dist/static/**/*", "lib/reminder-rs/static", "644"],
|
||||||
|
["reminder-dashboard/dist/index.html", "lib/reminder-rs/static/index.html", "644"],
|
||||||
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
|
["web/templates/**/*", "lib/reminder-rs/templates", "644"],
|
||||||
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
|
["healthcheck", "lib/reminder-rs/healthcheck", "755"],
|
||||||
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
|
["cron.d/reminder_health", "etc/cron.d/reminder_health", "644"],
|
||||||
|
2
reminder-dashboard/.prettierrc.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
printWidth = 100
|
||||||
|
tabWidth = 4
|
19
reminder-dashboard/README.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# reminder-dashboard
|
||||||
|
|
||||||
|
The re-re-rewrite of the dashboard.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The existing beta variant of the dashboard is written using vanilla JavaScript. This is fine,
|
||||||
|
but annoying to update. This would've been okay if I was more dedicated to updating the vanilla
|
||||||
|
JavaScript too, but I want to experiment with "new" things.
|
||||||
|
|
||||||
|
This also allows me to expand my frontend skills, which is relevant to part of my job.
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
1. Download the parent repo: https://gitea.jellypro.xyz/jude/reminder-bot
|
||||||
|
2. Initialise the submodules: `git pull --recurse-submodules`
|
||||||
|
3. Run both `npm run dev` and `cargo run`
|
||||||
|
4. Symlink assets: assuming cloned into `$HOME`, `ln -s $HOME/reminder-bot/reminder-dashboard/dist/index.html $HOME/reminder-bot/web/static/index.html` and
|
||||||
|
`ln -s $HOME/reminder-bot/reminder-dashboard/dist/static/assets $HOME/reminder-bot/web/static/assets`
|
35
reminder-dashboard/index.html
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="EN">
|
||||||
|
<head>
|
||||||
|
<meta name="description" content="The most powerful Discord Reminders Bot">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
|
||||||
|
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
|
||||||
|
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
|
||||||
|
|
||||||
|
<!-- favicon -->
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180"
|
||||||
|
href="/static/favicon/apple-touch-icon.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32"
|
||||||
|
href="/static/favicon/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16"
|
||||||
|
href="/static/favicon/favicon-16x16.png">
|
||||||
|
<link rel="manifest" href="/static/site.webmanifest">
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
|
||||||
|
<title>Reminder Bot | Dashboard</title>
|
||||||
|
|
||||||
|
<!-- styles -->
|
||||||
|
<link rel="stylesheet" href="/static/css/bulma.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/fa.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/font.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/dtsel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
4844
reminder-dashboard/package-lock.json
generated
Normal file
31
reminder-dashboard/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "example",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite build --watch --mode development",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"package": "mkdir -p reminder-dashboard/usr/share/reminder-dashboard && vite build --mode production --outDir reminder-dashboard/usr/share/reminder-dashboard && dpkg-deb --build reminder-dashboard"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.5.1",
|
||||||
|
"bulma": "^0.9.4",
|
||||||
|
"luxon": "^3.4.3",
|
||||||
|
"preact": "^10.13.1",
|
||||||
|
"react-colorful": "^5.6.1",
|
||||||
|
"react-query": "^3.39.3",
|
||||||
|
"tributejs": "^5.1.3",
|
||||||
|
"wouter": "^2.12.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.5.0",
|
||||||
|
"@types/luxon": "^3.3.2",
|
||||||
|
"eslint": "^8.50.0",
|
||||||
|
"eslint-config-preact": "^1.3.0",
|
||||||
|
"prettier": "^3.0.3",
|
||||||
|
"react-datepicker": "^4.21.0",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^4.3.2"
|
||||||
|
}
|
||||||
|
}
|
1
reminder-dashboard/public/static/css/bulma.min.css
vendored
Normal file
91
reminder-dashboard/public/static/css/dtsel.css
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
.date-selector-wrapper {
|
||||||
|
width: 200px;
|
||||||
|
padding: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 1px 1px 10px 1px #5c5c5c;
|
||||||
|
position: absolute;
|
||||||
|
font-size: 12px;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-khtml-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-o-user-select: none;
|
||||||
|
/* user-select: none; */
|
||||||
|
}
|
||||||
|
.cal-header, .cal-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cal-cell, .cal-nav {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-day-names {
|
||||||
|
height: 25px;
|
||||||
|
line-height: 25px;
|
||||||
|
}
|
||||||
|
.cal-day-names .cal-cell {
|
||||||
|
cursor: default;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cal-cell-prev, .cal-cell-next {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
.cal-months .cal-row, .cal-years .cal-row {
|
||||||
|
height: 60px;
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
.cal-nav-prev, .cal-nav-next {
|
||||||
|
flex: 0.15;
|
||||||
|
}
|
||||||
|
.cal-nav-current {
|
||||||
|
flex: 0.75;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.cal-months .cal-cell, .cal-years .cal-cell {
|
||||||
|
flex: 0.25;
|
||||||
|
}
|
||||||
|
.cal-days .cal-cell {
|
||||||
|
flex: 0.143;
|
||||||
|
}
|
||||||
|
.cal-value {
|
||||||
|
color: #fff;
|
||||||
|
background-color: #286090;
|
||||||
|
}
|
||||||
|
.cal-cell:hover, .cal-nav:hover {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
.cal-value:hover {
|
||||||
|
background-color: #204d74;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* time footer */
|
||||||
|
.cal-time {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 27px;
|
||||||
|
line-height: 27px;
|
||||||
|
}
|
||||||
|
.cal-time-label, .cal-time-value {
|
||||||
|
flex: 0.12;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cal-time-slider {
|
||||||
|
flex: 0.77;
|
||||||
|
background-image: linear-gradient(to right, #d1d8dd, #d1d8dd);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 1px;
|
||||||
|
background-position: left 50%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.cal-time-slider input {
|
||||||
|
width: 100%;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
outline: 0;
|
||||||
|
user-select: auto;
|
||||||
|
}
|
12749
reminder-dashboard/public/static/css/fa.css
Normal file
63
reminder-dashboard/public/static/css/font.css
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18E.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDc.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18E.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7g.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Sans Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(https://fonts.gstatic.com/s/sourcesanspro/v13/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdr.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCs6KVjbNBYlgo6eA.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Ubuntu';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url(https://fonts.gstatic.com/s/ubuntu/v15/4iCv6KVjbNBYlgoCxCvTtw.ttf) format('truetype');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
888
reminder-dashboard/public/static/css/style.css
Normal file
@ -0,0 +1,888 @@
|
|||||||
|
* {
|
||||||
|
font-family: "Ubuntu Bold", "Ubuntu", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* override styles for when the div is collapsed */
|
||||||
|
div.reminderContent.is-collapsed .column.discord-frame {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .column.settings {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .reminder-settings {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .button-row {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .button-row-edit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .reminder-topbar {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .invert-collapses {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent .invert-collapses {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed input[name="name"] {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .hide-box {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent.is-collapsed .hide-box i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
/* END */
|
||||||
|
|
||||||
|
/* dashboard styles */
|
||||||
|
.hide-box {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-box:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.inline-btn {
|
||||||
|
height: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.change-color {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(-1rem - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disable-enable[data-action="enable"]:after {
|
||||||
|
content: "Enable";
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disable-enable[data-action="disable"]:after {
|
||||||
|
content: "Disable";
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-content {
|
||||||
|
overflow-x: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.discord-embed {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.split-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-grow: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-topbar > div {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-bar {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-button-bar {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patreon-only {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-row {
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-topbar {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings > .column {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderContent {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interval inputs */
|
||||||
|
div.interval-group {
|
||||||
|
height: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .clear:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .no-break {
|
||||||
|
text-wrap: avoid;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group .clear {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: 1px;
|
||||||
|
margin-right: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group > .interval-group-left input {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border-style: none;
|
||||||
|
background-color: #eee;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group > .interval-group-left input.w2 {
|
||||||
|
width: 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group > .interval-group-left input.w3 {
|
||||||
|
width: 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.interval-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
/* !Interval inputs */
|
||||||
|
|
||||||
|
.left-pad {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.inset-content {
|
||||||
|
margin-left: 10%;
|
||||||
|
margin-right: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.flash-message {
|
||||||
|
position: fixed;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
margin: 16px !important;
|
||||||
|
z-index: 99;
|
||||||
|
bottom: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.flash-message.is-active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.spacer {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .dashboard-button {
|
||||||
|
background: white ;
|
||||||
|
}
|
||||||
|
|
||||||
|
span.patreon-color {
|
||||||
|
color: #f96854;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pageTitle {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#welcome > div {
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 30vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#pageNavbar {
|
||||||
|
background-color: #363636;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#pageNavbar a {
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-burger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-item.pageTitle {
|
||||||
|
flex-shrink: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-burger, .dashboard-burger:active, .dashboard-burger.is-active {
|
||||||
|
background-color: #adc99c !important;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 6px;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
div#pageNavbar a:hover {
|
||||||
|
background-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.rounded-corners {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.brand {
|
||||||
|
text-align: center;
|
||||||
|
height: 52px;
|
||||||
|
background-color: #8fb677;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.dashboard-brand {
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dashboard-sidebar {
|
||||||
|
background-color: #363636;
|
||||||
|
width: 230px !important;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.guildList {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dashboard-sidebar:not(.mobile-sidebar) .aside-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
width: 226px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dashboard-sidebar svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.mobile-sidebar {
|
||||||
|
z-index: 100;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#expandAll {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.mobile-sidebar .aside-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.mobile-sidebar.is-active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.dashboard-frame {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-box[data-inlined="0"] .inline-btn > i {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-box[data-inlined="0"] {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-box[data-inlined="1"] {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .menu-label {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-navbar {
|
||||||
|
background-color: #8fb677 !important;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.autoresize {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea, input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.default-width {
|
||||||
|
width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input:placeholder-shown {
|
||||||
|
font-style: italic;
|
||||||
|
background-color: #40444b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
background-color: #40444b;
|
||||||
|
color: #fff;
|
||||||
|
width: 120px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.message-input::placeholder {
|
||||||
|
color: #72767b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 4px 0 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-username {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-message-header {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-content {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable img {
|
||||||
|
background-color: #72767b;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.is-20x20 img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.is-24x24 img {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.is-400x300 img {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.is-32x32 img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.thumbnail img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable input.imageInput {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 36px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.thumbnail input.imageInput {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -400px;
|
||||||
|
width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable input.is-active {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-frame {
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #36393f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-embed {
|
||||||
|
padding: 8px 16px 16px 12px;
|
||||||
|
margin: 0 20px 4px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 4px solid #fff;
|
||||||
|
background-color: #2f3136;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-author-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-author-box > .a {
|
||||||
|
flex: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-author-box > .b {
|
||||||
|
flex: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-footer-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-author-box .image {
|
||||||
|
margin: 0 8px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-footer-box .image {
|
||||||
|
margin: 0 8px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-embed-author {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-embed-footer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-body > .a {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-body input, .embed-body textarea {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-body > .b {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-field-title, .discord-field-value {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-field-title {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-box {
|
||||||
|
margin: 12px 8px 0 0;
|
||||||
|
max-width: 120px;
|
||||||
|
flex: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-multifield-box {
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-select {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-left: 48px;
|
||||||
|
display: inline-flex;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6e89da;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 2px;
|
||||||
|
border-bottom: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-selector {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.highlight {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-reminder {
|
||||||
|
flex-grow: 0;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row-template {
|
||||||
|
display: flex;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-template > div {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 1023px) {
|
||||||
|
p.title.pageTitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-frame {
|
||||||
|
margin-top: 4rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customizable.thumbnail img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
.button-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-reminder {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row .button-row-template > div {
|
||||||
|
flex-basis: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reminder-settings {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-row {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* loader */
|
||||||
|
#loader {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
width: 100vw;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader .title {
|
||||||
|
font-size: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* END */
|
||||||
|
|
||||||
|
div.reminderError {
|
||||||
|
margin: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorIcon {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorIcon .fas {
|
||||||
|
display: none
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="deleted"] .errorIcon {
|
||||||
|
background-color: #e7e5e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="failed"] .errorIcon {
|
||||||
|
background-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="sent"] .errorIcon {
|
||||||
|
background-color: #d9f99d;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="deleted"] .errorIcon .fas.fa-trash {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="failed"] .errorIcon .fas.fa-exclamation-triangle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError[data-case="sent"] .errorIcon .fas.fa-check {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead .reminderName {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .errorHead .reminderTime {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 1;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-color: #e5e5e5;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.reminderError .reminderMessage {
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgb(54, 54, 54);
|
||||||
|
flex-grow: 1;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* other stuff */
|
||||||
|
|
||||||
|
.half-rem {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad-left {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colorpicker-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-reminder {
|
||||||
|
margin: 0 12px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-success:not(.is-outlined) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-outlined.is-success {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.switch-pane {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-submenu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-submenu li {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.switch-pane.is-active ~ .guild-submenu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
background-color: #5865F2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked > :not(.patreon-invert) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked .patreon-invert {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.patreon-invert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked .foreground {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-locked .field:last-of-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-box {
|
||||||
|
flex-grow: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #fcfcfc;
|
||||||
|
border-color: #efefef;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
margin: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure-num {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
After Width: | Height: | Size: 7.2 KiB |
After Width: | Height: | Size: 20 KiB |
BIN
reminder-dashboard/public/static/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/mstile-150x150.png"/>
|
||||||
|
<TileColor>#da532c</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
BIN
reminder-dashboard/public/static/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
reminder-dashboard/public/static/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.3 KiB |
BIN
reminder-dashboard/public/static/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 7.2 KiB |
BIN
reminder-dashboard/public/static/favicon/mstile-150x150.png
Normal file
After Width: | Height: | Size: 4.9 KiB |
BIN
reminder-dashboard/public/static/img/bg.webp
Normal file
After Width: | Height: | Size: 762 B |
BIN
reminder-dashboard/public/static/img/icon.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
reminder-dashboard/public/static/img/logo_flat.jpg
Normal file
After Width: | Height: | Size: 323 KiB |
BIN
reminder-dashboard/public/static/img/logo_flat.webp
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
reminder-dashboard/public/static/img/logo_nobg.webp
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
reminder-dashboard/public/static/img/slash-commands.png
Normal file
After Width: | Height: | Size: 23 KiB |
After Width: | Height: | Size: 35 KiB |
After Width: | Height: | Size: 55 KiB |
After Width: | Height: | Size: 3.6 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 14 KiB |
After Width: | Height: | Size: 24 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 40 KiB |
After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 20 KiB |
BIN
reminder-dashboard/public/static/img/tournament-demo.png
Normal file
After Width: | Height: | Size: 65 KiB |
131
reminder-dashboard/public/static/js/admin.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
fetch("/admin/data")
|
||||||
|
.then((resp) => resp.json())
|
||||||
|
.then((data) => {
|
||||||
|
document.querySelector("#backlog").textContent = data.backlog;
|
||||||
|
document.querySelector("#reminders").textContent = data.count.reminders;
|
||||||
|
document.querySelector("#intervals").textContent = data.count.intervals;
|
||||||
|
|
||||||
|
let historySent = data.historyLong.sent.reduce(
|
||||||
|
(iv, frame) => iv + frame.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let historyFailed = data.historyLong.failed.reduce(
|
||||||
|
(iv, frame) => iv + frame.count,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let rate = historyFailed / (historySent + historyFailed);
|
||||||
|
let formatted = Math.round(rate * 10000) / 100;
|
||||||
|
|
||||||
|
document.querySelector("#historySent").textContent = historySent;
|
||||||
|
document.querySelector("#historyFailed").textContent = historyFailed;
|
||||||
|
document.querySelector("#failRate").textContent = `${formatted}%`;
|
||||||
|
|
||||||
|
new Chart(document.getElementById("schedule"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [
|
||||||
|
...data.scheduleShort.once,
|
||||||
|
...data.scheduleShort.interval,
|
||||||
|
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Reminders",
|
||||||
|
data: data.scheduleShort.once.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Intervals",
|
||||||
|
data: data.scheduleShort.interval.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "minute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("scheduleLong"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [
|
||||||
|
...data.scheduleLong.once,
|
||||||
|
...data.scheduleLong.interval,
|
||||||
|
].map((row) => luxon.DateTime.fromISO(row.time_key)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Reminders",
|
||||||
|
data: data.scheduleLong.once.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Intervals",
|
||||||
|
data: data.scheduleLong.interval.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "day",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("historyLong"), {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: [...data.historyLong.sent, ...data.historyLong.failed].map(
|
||||||
|
(row) => luxon.DateTime.fromISO(row.time_key)
|
||||||
|
),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Success",
|
||||||
|
data: data.historyLong.sent.map((row) => row.count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fail",
|
||||||
|
data: data.historyLong.failed.map((row) => row.count),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: true,
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "day",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
reminder-dashboard/public/static/js/chart.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/*!
|
||||||
|
* chartjs-adapter-luxon v1.3.1
|
||||||
|
* https://www.chartjs.org
|
||||||
|
* (c) 2023 chartjs-adapter-luxon Contributors
|
||||||
|
* Released under the MIT license
|
||||||
|
*/
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(require("chart.js"),require("luxon")):"function"==typeof define&&define.amd?define(["chart.js","luxon"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Chart,e.luxon)}(this,(function(e,t){"use strict";const n={datetime:t.DateTime.DATETIME_MED_WITH_SECONDS,millisecond:"h:mm:ss.SSS a",second:t.DateTime.TIME_WITH_SECONDS,minute:t.DateTime.TIME_SIMPLE,hour:{hour:"numeric"},day:{day:"numeric",month:"short"},week:"DD",month:{month:"short",year:"numeric"},quarter:"'Q'q - yyyy",year:{year:"numeric"}};e._adapters._date.override({_id:"luxon",_create:function(e){return t.DateTime.fromMillis(e,this.options)},init(e){this.options.locale||(this.options.locale=e.locale)},formats:function(){return n},parse:function(e,n){const i=this.options,r=typeof e;return null===e||"undefined"===r?null:("number"===r?e=this._create(e):"string"===r?e="string"==typeof n?t.DateTime.fromFormat(e,n,i):t.DateTime.fromISO(e,i):e instanceof Date?e=t.DateTime.fromJSDate(e,i):"object"!==r||e instanceof t.DateTime||(e=t.DateTime.fromObject(e,i)),e.isValid?e.valueOf():null)},format:function(e,t){const n=this._create(e);return"string"==typeof t?n.toFormat(t):n.toLocaleString(t)},add:function(e,t,n){const i={};return i[n]=t,this._create(e).plus(i).valueOf()},diff:function(e,t,n){return this._create(e).diff(this._create(t)).as(n).valueOf()},startOf:function(e,t,n){if("isoWeek"===t){n=Math.trunc(Math.min(Math.max(0,n),6));const t=this._create(e);return t.minus({days:(t.weekday-n+7)%7}).startOf("day").valueOf()}return t?this._create(e).startOf(t).valueOf():e},endOf:function(e,t){return this._create(e).endOf(t).valueOf()}})}));
|
931
reminder-dashboard/public/static/js/dtsel.js
Normal file
@ -0,0 +1,931 @@
|
|||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var BODYTYPES = ["DAYS", "MONTHS", "YEARS"];
|
||||||
|
var MONTHS = [
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
var WEEKDAYS = [
|
||||||
|
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @typedef {Object.<string, Function[]>} Handlers */
|
||||||
|
/** @typedef {function(String, Function): null} AddHandler */
|
||||||
|
/** @typedef {("DAYS"|"MONTHS"|"YEARS")} BodyType */
|
||||||
|
/** @typedef {string|number} StringNum */
|
||||||
|
/** @typedef {Object.<string, StringNum>} StringNumObj */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local state
|
||||||
|
* @typedef {Object} InstanceState
|
||||||
|
* @property {Date} value
|
||||||
|
* @property {Number} year
|
||||||
|
* @property {Number} month
|
||||||
|
* @property {Number} day
|
||||||
|
* @property {Number} time
|
||||||
|
* @property {Number} hours
|
||||||
|
* @property {Number} minutes
|
||||||
|
* @property {Number} seconds
|
||||||
|
* @property {BodyType} bodyType
|
||||||
|
* @property {Boolean} visible
|
||||||
|
* @property {Number} cancelBlur
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Config
|
||||||
|
* @property {String} dateFormat
|
||||||
|
* @property {String} timeFormat
|
||||||
|
* @property {Boolean} showDate
|
||||||
|
* @property {Boolean} showTime
|
||||||
|
* @property {Number} paddingX
|
||||||
|
* @property {Number} paddingY
|
||||||
|
* @property {BodyType} defaultView
|
||||||
|
* @property {"TOP"|"BOTTOM"} direction
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @param {HTMLElement} elem
|
||||||
|
* @param {Config} config
|
||||||
|
*/
|
||||||
|
function DTS(elem, config) {
|
||||||
|
var config = config || {};
|
||||||
|
|
||||||
|
/** @type {Config} */
|
||||||
|
var defaultConfig = {
|
||||||
|
defaultView: BODYTYPES[0],
|
||||||
|
dateFormat: "yyyy-mm-dd",
|
||||||
|
timeFormat: "HH:MM:SS",
|
||||||
|
showDate: true,
|
||||||
|
showTime: false,
|
||||||
|
paddingX: 5,
|
||||||
|
paddingY: 5,
|
||||||
|
direction: 'TOP'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elem) {
|
||||||
|
throw TypeError("input element or selector required for contructor");
|
||||||
|
}
|
||||||
|
if (Object.getPrototypeOf(elem) === String.prototype) {
|
||||||
|
var _elem = document.querySelectorAll(elem);
|
||||||
|
if (!_elem[0]){
|
||||||
|
throw Error('"' + elem + '" not found.');
|
||||||
|
}
|
||||||
|
elem = _elem[0];
|
||||||
|
}
|
||||||
|
this.config = setDefaults(config, defaultConfig);
|
||||||
|
this.dateFormat = this.config.dateFormat;
|
||||||
|
this.timeFormat = this.config.timeFormat;
|
||||||
|
this.dateFormatRegEx = new RegExp("yyyy|yy|mm|dd", "gi");
|
||||||
|
this.timeFormatRegEx = new RegExp("hh|mm|ss|a", "gi");
|
||||||
|
this.inputElem = elem;
|
||||||
|
this.dtbox = null;
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
DTS.prototype.setup = function () {
|
||||||
|
var handler = this.inputElemHandler.bind(this);
|
||||||
|
this.inputElem.addEventListener("focus", handler, false)
|
||||||
|
this.inputElem.addEventListener("blur", handler, false);
|
||||||
|
}
|
||||||
|
DTS.prototype.inputElemHandler = function (e) {
|
||||||
|
if (e.type == "focus") {
|
||||||
|
if (!this.dtbox) {
|
||||||
|
this.dtbox = new DTBox(e.target, this);
|
||||||
|
}
|
||||||
|
this.dtbox.visible = true;
|
||||||
|
} else if (e.type == "blur" && this.dtbox && this.dtbox.visible) {
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function () {
|
||||||
|
if (self.dtbox.cancelBlur > 0) {
|
||||||
|
self.dtbox.cancelBlur -= 1;
|
||||||
|
} else {
|
||||||
|
self.dtbox.visible = false;
|
||||||
|
self.inputElem.blur();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @param {HTMLElement} elem
|
||||||
|
* @param {DTS} settings
|
||||||
|
*/
|
||||||
|
function DTBox(elem, settings) {
|
||||||
|
/** @type {DTBox} */
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
/** @type {Handlers} */
|
||||||
|
var handlers = {};
|
||||||
|
|
||||||
|
/** @type {InstanceState} */
|
||||||
|
var localState = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} key
|
||||||
|
* @param {*} default_val
|
||||||
|
*/
|
||||||
|
function getterSetter(key, default_val) {
|
||||||
|
return {
|
||||||
|
get: function () {
|
||||||
|
var val = localState[key];
|
||||||
|
return val === undefined ? default_val : val;
|
||||||
|
},
|
||||||
|
set: function (val) {
|
||||||
|
var prevState = self.state;
|
||||||
|
var _handlers = handlers[key] || [];
|
||||||
|
localState[key] = val;
|
||||||
|
for (var i = 0; i < _handlers.length; i++) {
|
||||||
|
_handlers[i].bind(self)(localState, prevState);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {AddHandler} */
|
||||||
|
function addHandler(key, handlerFn) {
|
||||||
|
if (!key || !handlerFn) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!handlers[key]) {
|
||||||
|
handlers[key] = [];
|
||||||
|
}
|
||||||
|
handlers[key].push(handlerFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperties(this, {
|
||||||
|
visible: getterSetter("visible", false),
|
||||||
|
bodyType: getterSetter("bodyType", settings.config.defaultView),
|
||||||
|
value: getterSetter("value"),
|
||||||
|
year: getterSetter("year", 0),
|
||||||
|
month: getterSetter("month", 0),
|
||||||
|
day: getterSetter("day", 0),
|
||||||
|
hours: getterSetter("hours", 0),
|
||||||
|
minutes: getterSetter("minutes", 0),
|
||||||
|
seconds: getterSetter("seconds", 0),
|
||||||
|
cancelBlur: getterSetter("cancelBlur", 0),
|
||||||
|
addHandler: {value: addHandler},
|
||||||
|
month_long: {
|
||||||
|
get: function () {
|
||||||
|
return MONTHS[self.month];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
month_short: {
|
||||||
|
get: function () {
|
||||||
|
return self.month_long.slice(0, 3);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
get: function () {
|
||||||
|
return Object.assign({}, localState);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
get: function() {
|
||||||
|
var hours = self.hours * 60 * 60 * 1000;
|
||||||
|
var minutes = self.minutes * 60 * 1000;
|
||||||
|
var seconds = self.seconds * 1000;
|
||||||
|
return hours + minutes + seconds;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.el = {};
|
||||||
|
this.settings = settings;
|
||||||
|
this.elem = elem;
|
||||||
|
this.setup();
|
||||||
|
}
|
||||||
|
DTBox.prototype.setup = function () {
|
||||||
|
Object.defineProperties(this.el, {
|
||||||
|
wrapper: { value: null, configurable: true },
|
||||||
|
header: { value: null, configurable: true },
|
||||||
|
body: { value: null, configurable: true },
|
||||||
|
footer: { value: null, configurable: true }
|
||||||
|
});
|
||||||
|
this.setupWrapper();
|
||||||
|
if (this.settings.config.showDate) {
|
||||||
|
this.setupHeader();
|
||||||
|
this.setupBody();
|
||||||
|
}
|
||||||
|
if (this.settings.config.showTime) {
|
||||||
|
this.setupFooter();
|
||||||
|
}
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
this.addHandler("visible", function (state, prevState) {
|
||||||
|
if (state.visible && !prevState.visible){
|
||||||
|
document.body.appendChild(this.el.wrapper);
|
||||||
|
|
||||||
|
var parts = self.elem.value.split(/\s*,\s*/);
|
||||||
|
var startDate = undefined;
|
||||||
|
var startTime = 0;
|
||||||
|
if (self.settings.config.showDate) {
|
||||||
|
startDate = parseDate(parts[0], self.settings);
|
||||||
|
}
|
||||||
|
if (self.settings.config.showTime) {
|
||||||
|
startTime = parseTime(parts[parts.length-1], self.settings);
|
||||||
|
startTime = startTime || 0;
|
||||||
|
}
|
||||||
|
if (!(startDate && startDate.getTime())) {
|
||||||
|
startDate = new Date();
|
||||||
|
startDate = new Date(
|
||||||
|
startDate.getFullYear(),
|
||||||
|
startDate.getMonth(),
|
||||||
|
startDate.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var value = new Date(startDate.getTime() + startTime);
|
||||||
|
self.value = value;
|
||||||
|
self.year = value.getFullYear();
|
||||||
|
self.month = value.getMonth();
|
||||||
|
self.day = value.getDate();
|
||||||
|
self.hours = value.getHours();
|
||||||
|
self.minutes = value.getMinutes();
|
||||||
|
self.seconds = value.getSeconds();
|
||||||
|
|
||||||
|
if (self.settings.config.showDate) {
|
||||||
|
self.setHeaderContent();
|
||||||
|
self.setBodyContent();
|
||||||
|
}
|
||||||
|
if (self.settings.config.showTime) {
|
||||||
|
self.setFooterContent();
|
||||||
|
}
|
||||||
|
} else if (!state.visible && prevState.visible) {
|
||||||
|
document.body.removeChild(this.el.wrapper);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DTBox.prototype.setupWrapper = function () {
|
||||||
|
if (!this.el.wrapper) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.classList.add("date-selector-wrapper");
|
||||||
|
Object.defineProperty(this.el, "wrapper", { value: el });
|
||||||
|
}
|
||||||
|
var self = this;
|
||||||
|
var htmlRoot = document.getElementsByTagName('html')[0];
|
||||||
|
function setPosition(e){
|
||||||
|
var minTopSpace = 300;
|
||||||
|
var box = getOffset(self.elem);
|
||||||
|
var config = self.settings.config;
|
||||||
|
var paddingY = config.paddingY || 5;
|
||||||
|
var paddingX = config.paddingX || 5;
|
||||||
|
var top = box.top + self.elem.offsetHeight + paddingY;
|
||||||
|
var left = box.left + paddingX;
|
||||||
|
var bottom = htmlRoot.clientHeight - box.top + paddingY;
|
||||||
|
|
||||||
|
self.el.wrapper.style.left = `${left}px`;
|
||||||
|
if (box.top > minTopSpace && config.direction != 'BOTTOM') {
|
||||||
|
self.el.wrapper.style.bottom = `${bottom}px`;
|
||||||
|
self.el.wrapper.style.top = '';
|
||||||
|
} else {
|
||||||
|
self.el.wrapper.style.top = `${top}px`;
|
||||||
|
self.el.wrapper.style.bottom = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler(e) {
|
||||||
|
self.cancelBlur += 1;
|
||||||
|
setTimeout(function(){
|
||||||
|
self.elem.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
setPosition();
|
||||||
|
this.setPosition = setPosition;
|
||||||
|
this.el.wrapper.addEventListener("mousedown", handler, false);
|
||||||
|
this.el.wrapper.addEventListener("touchstart", handler, false);
|
||||||
|
window.addEventListener('resize', this.setPosition);
|
||||||
|
}
|
||||||
|
DTBox.prototype.setupHeader = function () {
|
||||||
|
if (!this.el.header) {
|
||||||
|
var row = document.createElement("div");
|
||||||
|
var classes = ["cal-nav-prev", "cal-nav-current", "cal-nav-next"];
|
||||||
|
row.classList.add("cal-header");
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
var cell = document.createElement("div");
|
||||||
|
cell.classList.add("cal-nav", classes[i]);
|
||||||
|
cell.onclick = this.onHeaderChange.bind(this);
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
row.children[0].innerHTML = "<";
|
||||||
|
row.children[2].innerHTML = ">";
|
||||||
|
Object.defineProperty(this.el, "header", { value: row });
|
||||||
|
tryAppendChild(row, this.el.wrapper);
|
||||||
|
}
|
||||||
|
this.setHeaderContent();
|
||||||
|
}
|
||||||
|
DTBox.prototype.setHeaderContent = function () {
|
||||||
|
var content = this.year;
|
||||||
|
if ("DAYS" == this.bodyType) {
|
||||||
|
content = this.month_long + " " + content;
|
||||||
|
} else if ("YEARS" == this.bodyType) {
|
||||||
|
var start = this.year + 10 - (this.year % 10);
|
||||||
|
content = start - 10 + "-" + (start - 1);
|
||||||
|
}
|
||||||
|
this.el.header.children[1].innerText = content;
|
||||||
|
}
|
||||||
|
DTBox.prototype.setupBody = function () {
|
||||||
|
if (!this.el.body) {
|
||||||
|
var el = document.createElement("div");
|
||||||
|
el.classList.add("cal-body");
|
||||||
|
Object.defineProperty(this.el, "body", { value: el });
|
||||||
|
tryAppendChild(el, this.el.wrapper);
|
||||||
|
}
|
||||||
|
var toAppend = null;
|
||||||
|
function makeGrid(rows, cols, className, firstRowClass, clickHandler) {
|
||||||
|
var grid = document.createElement("div");
|
||||||
|
grid.classList.add(className);
|
||||||
|
for (var i = 1; i < rows + 1; i++) {
|
||||||
|
var row = document.createElement("div");
|
||||||
|
row.classList.add("cal-row", "cal-row-" + i);
|
||||||
|
if (i == 1 && firstRowClass) {
|
||||||
|
row.classList.add(firstRowClass);
|
||||||
|
}
|
||||||
|
for (var j = 1; j < cols + 1; j++) {
|
||||||
|
var col = document.createElement("div");
|
||||||
|
col.classList.add("cal-cell", "cal-col-" + j);
|
||||||
|
col.onclick = clickHandler;
|
||||||
|
row.appendChild(col);
|
||||||
|
}
|
||||||
|
grid.appendChild(row);
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
if ("DAYS" == this.bodyType) {
|
||||||
|
toAppend = this.el.body.calDays;
|
||||||
|
if (!toAppend) {
|
||||||
|
toAppend = makeGrid(7, 7, "cal-days", "cal-day-names", this.onDateSelected.bind(this));
|
||||||
|
for (var i = 0; i < 7; i++) {
|
||||||
|
var cell = toAppend.children[0].children[i];
|
||||||
|
cell.innerText = WEEKDAYS[i].slice(0, 2);
|
||||||
|
cell.onclick = null;
|
||||||
|
}
|
||||||
|
this.el.body.calDays = toAppend;
|
||||||
|
}
|
||||||
|
} else if ("MONTHS" == this.bodyType) {
|
||||||
|
toAppend = this.el.body.calMonths;
|
||||||
|
if (!toAppend) {
|
||||||
|
toAppend = makeGrid(3, 4, "cal-months", null, this.onMonthSelected.bind(this));
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
for (var j = 0; j < 4; j++) {
|
||||||
|
var monthShort = MONTHS[4 * i + j].slice(0, 3);
|
||||||
|
toAppend.children[i].children[j].innerText = monthShort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.el.body.calMonths = toAppend;
|
||||||
|
}
|
||||||
|
} else if ("YEARS" == this.bodyType) {
|
||||||
|
toAppend = this.el.body.calYears;
|
||||||
|
if (!toAppend) {
|
||||||
|
toAppend = makeGrid(3, 4, "cal-years", null, this.onYearSelected.bind(this));
|
||||||
|
this.el.body.calYears = toAppend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
empty(this.el.body);
|
||||||
|
tryAppendChild(toAppend, this.el.body);
|
||||||
|
this.setBodyContent();
|
||||||
|
}
|
||||||
|
DTBox.prototype.setBodyContent = function () {
|
||||||
|
var grid = this.el.body.children[0];
|
||||||
|
var classes = ["cal-cell-prev", "cal-cell-next", "cal-value"];
|
||||||
|
if ("DAYS" == this.bodyType) {
|
||||||
|
var oneDayMilliSecs = 24 * 60 * 60 * 1000;
|
||||||
|
var start = new Date(this.year, this.month, 1);
|
||||||
|
var adjusted = new Date(start.getTime() - oneDayMilliSecs * start.getDay());
|
||||||
|
|
||||||
|
grid.children[6].style.display = "";
|
||||||
|
for (var i = 1; i < 7; i++) {
|
||||||
|
for (var j = 0; j < 7; j++) {
|
||||||
|
var cell = grid.children[i].children[j];
|
||||||
|
var month = adjusted.getMonth();
|
||||||
|
var date = adjusted.getDate();
|
||||||
|
|
||||||
|
cell.innerText = date;
|
||||||
|
cell.classList.remove(classes[0], classes[1], classes[2]);
|
||||||
|
if (month != this.month) {
|
||||||
|
if (i == 6 && j == 0) {
|
||||||
|
grid.children[6].style.display = "none";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cell.classList.add(month < this.month ? classes[0] : classes[1]);
|
||||||
|
} else if (isEqualDate(adjusted, this.value)){
|
||||||
|
cell.classList.add(classes[2]);
|
||||||
|
}
|
||||||
|
adjusted = new Date(adjusted.getTime() + oneDayMilliSecs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ("YEARS" == this.bodyType) {
|
||||||
|
var year = this.year - (this.year % 10) - 1;
|
||||||
|
for (i = 0; i < 3; i++) {
|
||||||
|
for (j = 0; j < 4; j++) {
|
||||||
|
grid.children[i].children[j].innerText = year;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid.children[0].children[0].classList.add(classes[0]);
|
||||||
|
grid.children[2].children[3].classList.add(classes[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Event} e */
|
||||||
|
DTBox.prototype.onTimeChange = function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type == 'mousedown') {
|
||||||
|
this.cancelBlur += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var el = e.target;
|
||||||
|
this[el.name] = parseInt(el.value) || 0;
|
||||||
|
this.setupFooter();
|
||||||
|
if (e.type == 'change') {
|
||||||
|
var self = this;
|
||||||
|
setTimeout(function(){
|
||||||
|
self.elem.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
this.setInputValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
DTBox.prototype.setupFooter = function() {
|
||||||
|
if (!this.el.footer) {
|
||||||
|
var footer = document.createElement("div");
|
||||||
|
var handler = this.onTimeChange.bind(this);
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
function makeRow(label, name, range, changeHandler) {
|
||||||
|
var row = document.createElement("div");
|
||||||
|
row.classList.add('cal-time');
|
||||||
|
|
||||||
|
var labelCol = row.appendChild(document.createElement("div"));
|
||||||
|
labelCol.classList.add('cal-time-label');
|
||||||
|
labelCol.innerText = label;
|
||||||
|
|
||||||
|
var valueCol = row.appendChild(document.createElement("div"));
|
||||||
|
valueCol.classList.add('cal-time-value');
|
||||||
|
valueCol.innerText = '00';
|
||||||
|
|
||||||
|
var inputCol = row.appendChild(document.createElement("div"));
|
||||||
|
var slider = inputCol.appendChild(document.createElement("input"));
|
||||||
|
Object.assign(slider, {step:1, min:0, max:range, name:name, type:'range'});
|
||||||
|
Object.defineProperty(footer, name, {value: slider});
|
||||||
|
inputCol.classList.add('cal-time-slider');
|
||||||
|
slider.onchange = changeHandler;
|
||||||
|
slider.oninput = changeHandler;
|
||||||
|
slider.onmousedown = changeHandler;
|
||||||
|
self[name] = self[name] || parseInt(slider.value) || 0;
|
||||||
|
footer.appendChild(row)
|
||||||
|
}
|
||||||
|
makeRow('HH:', 'hours', 23, handler);
|
||||||
|
makeRow('MM:', 'minutes', 59, handler);
|
||||||
|
makeRow('SS:', 'seconds', 59, handler);
|
||||||
|
|
||||||
|
footer.classList.add("cal-footer");
|
||||||
|
Object.defineProperty(this.el, "footer", { value: footer });
|
||||||
|
tryAppendChild(footer, this.el.wrapper);
|
||||||
|
}
|
||||||
|
this.setFooterContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
DTBox.prototype.setFooterContent = function() {
|
||||||
|
if (this.el.footer) {
|
||||||
|
var footer = this.el.footer;
|
||||||
|
footer.hours.value = this.hours;
|
||||||
|
footer.children[0].children[1].innerText = padded(this.hours, 2);
|
||||||
|
footer.minutes.value = this.minutes;
|
||||||
|
footer.children[1].children[1].innerText = padded(this.minutes, 2);
|
||||||
|
footer.seconds.value = this.seconds;
|
||||||
|
footer.children[2].children[1].innerText = padded(this.seconds, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DTBox.prototype.setInputValue = function() {
|
||||||
|
var date = new Date(this.year, this.month, this.day);
|
||||||
|
var strings = [];
|
||||||
|
if (this.settings.config.showDate) {
|
||||||
|
strings.push(renderDate(date, this.settings));
|
||||||
|
}
|
||||||
|
if (this.settings.config.showTime) {
|
||||||
|
var joined = new Date(date.getTime() + this.time);
|
||||||
|
strings.push(renderTime(joined, this.settings));
|
||||||
|
}
|
||||||
|
this.elem.value = strings.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
DTBox.prototype.onDateSelected = function (e) {
|
||||||
|
var row = e.target.parentNode;
|
||||||
|
var date = parseInt(e.target.innerText);
|
||||||
|
if (!(row.nextSibling && row.nextSibling.nextSibling) && date < 8) {
|
||||||
|
this.month += 1;
|
||||||
|
} else if (!(row.previousSibling && row.previousSibling.previousSibling) && date > 7) {
|
||||||
|
this.month -= 1;
|
||||||
|
}
|
||||||
|
this.day = parseInt(e.target.innerText);
|
||||||
|
this.value = new Date(this.year, this.month, this.day);
|
||||||
|
this.setInputValue();
|
||||||
|
this.setHeaderContent();
|
||||||
|
this.setBodyContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Event} e */
|
||||||
|
DTBox.prototype.onMonthSelected = function (e) {
|
||||||
|
var col = 0;
|
||||||
|
var row = 2;
|
||||||
|
var cell = e.target;
|
||||||
|
if (cell.parentNode.nextSibling){
|
||||||
|
row = cell.parentNode.previousSibling ? 1: 0;
|
||||||
|
}
|
||||||
|
if (cell.previousSibling) {
|
||||||
|
col = 3;
|
||||||
|
if (cell.nextSibling) {
|
||||||
|
col = cell.previousSibling.previousSibling ? 2 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.month = 4 * row + col;
|
||||||
|
this.bodyType = "DAYS";
|
||||||
|
this.setHeaderContent();
|
||||||
|
this.setupBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Event} e */
|
||||||
|
DTBox.prototype.onYearSelected = function (e) {
|
||||||
|
this.year = parseInt(e.target.innerText);
|
||||||
|
this.bodyType = "MONTHS";
|
||||||
|
this.setHeaderContent();
|
||||||
|
this.setupBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Event} e */
|
||||||
|
DTBox.prototype.onHeaderChange = function (e) {
|
||||||
|
var cell = e.target;
|
||||||
|
if (cell.previousSibling && cell.nextSibling) {
|
||||||
|
var idx = BODYTYPES.indexOf(this.bodyType);
|
||||||
|
if (idx < 0 || !BODYTYPES[idx + 1]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.bodyType = BODYTYPES[idx + 1];
|
||||||
|
this.setupBody();
|
||||||
|
} else {
|
||||||
|
var sign = cell.previousSibling ? 1 : -1;
|
||||||
|
switch (this.bodyType) {
|
||||||
|
case "DAYS":
|
||||||
|
this.month += sign * 1;
|
||||||
|
break;
|
||||||
|
case "MONTHS":
|
||||||
|
this.year += sign * 1;
|
||||||
|
break;
|
||||||
|
case "YEARS":
|
||||||
|
this.year += sign * 10;
|
||||||
|
}
|
||||||
|
if (this.month > 11 || this.month < 0) {
|
||||||
|
this.year += Math.floor(this.month / 11);
|
||||||
|
this.month = this.month > 11 ? 0 : 11;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setHeaderContent();
|
||||||
|
this.setBodyContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} elem
|
||||||
|
* @returns {{left:number, top:number}}
|
||||||
|
*/
|
||||||
|
function getOffset(elem) {
|
||||||
|
var box = elem.getBoundingClientRect();
|
||||||
|
var left = window.pageXOffset !== undefined ? window.pageXOffset :
|
||||||
|
(document.documentElement || document.body.parentNode || document.body).scrollLeft;
|
||||||
|
var top = window.pageYOffset !== undefined ? window.pageYOffset :
|
||||||
|
(document.documentElement || document.body.parentNode || document.body).scrollTop;
|
||||||
|
return { left: box.left + left, top: box.top + top };
|
||||||
|
}
|
||||||
|
function empty(e) {
|
||||||
|
for (; e.children.length; ) e.removeChild(e.children[0]);
|
||||||
|
}
|
||||||
|
function tryAppendChild(newChild, refNode) {
|
||||||
|
try {
|
||||||
|
refNode.appendChild(newChild);
|
||||||
|
return newChild;
|
||||||
|
} catch (e) {
|
||||||
|
console.trace(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @class */
|
||||||
|
function hookFuncs() {
|
||||||
|
/** @type {Handlers} */
|
||||||
|
this._funcs = {};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {Function} func
|
||||||
|
*/
|
||||||
|
hookFuncs.prototype.add = function(key, func){
|
||||||
|
if (!this._funcs[key]){
|
||||||
|
this._funcs[key] = [];
|
||||||
|
}
|
||||||
|
this._funcs[key].push(func)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @param {String} key
|
||||||
|
* @returns {Function[]} handlers
|
||||||
|
*/
|
||||||
|
hookFuncs.prototype.get = function(key){
|
||||||
|
return this._funcs[key] ? this._funcs[key] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array.<string>} arr
|
||||||
|
* @param {String} string
|
||||||
|
* @returns {Array.<string>} sorted string
|
||||||
|
*/
|
||||||
|
function sortByStringIndex(arr, string) {
|
||||||
|
return arr.sort(function(a, b){
|
||||||
|
var h = string.indexOf(a);
|
||||||
|
var l = string.indexOf(b);
|
||||||
|
var rank = 0;
|
||||||
|
if (h < l) {
|
||||||
|
rank = -1;
|
||||||
|
} else if (l < h) {
|
||||||
|
rank = 1;
|
||||||
|
} else if (a.length > b.length) {
|
||||||
|
rank = -1;
|
||||||
|
} else if (b.length > a.length) {
|
||||||
|
rank = 1;
|
||||||
|
}
|
||||||
|
return rank;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keys from array that are not in format
|
||||||
|
* @param {string[]} keys
|
||||||
|
* @param {string} format
|
||||||
|
* @returns {string[]} new filtered array
|
||||||
|
*/
|
||||||
|
function filterFormatKeys(keys, format) {
|
||||||
|
var out = [];
|
||||||
|
var formatIdx = 0;
|
||||||
|
for (var i = 0; i<keys.length; i++) {
|
||||||
|
var key = keys[i];
|
||||||
|
if (format.slice(formatIdx).indexOf(key) > -1) {
|
||||||
|
formatIdx += key.length;
|
||||||
|
out.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template {StringNumObj} FormatObj
|
||||||
|
* @param {string} value
|
||||||
|
* @param {string} format
|
||||||
|
* @param {FormatObj} formatObj
|
||||||
|
* @param {function(Object.<string, hookFuncs>): null} setHooks
|
||||||
|
* @returns {FormatObj} formatObj
|
||||||
|
*/
|
||||||
|
function parseData(value, format, formatObj, setHooks) {
|
||||||
|
var hooks = {
|
||||||
|
canSkip: new hookFuncs(),
|
||||||
|
updateValue: new hookFuncs(),
|
||||||
|
}
|
||||||
|
var keys = sortByStringIndex(Object.keys(formatObj), format);
|
||||||
|
var filterdKeys = filterFormatKeys(keys, format);
|
||||||
|
var vstart = 0; // value start
|
||||||
|
if (setHooks) {
|
||||||
|
setHooks(hooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
var key = keys[i];
|
||||||
|
var fstart = format.indexOf(key);
|
||||||
|
var _vstart = vstart; // next value start
|
||||||
|
var val = null;
|
||||||
|
var canSkip = false;
|
||||||
|
var funcs = hooks.canSkip.get(key);
|
||||||
|
|
||||||
|
vstart = vstart || fstart;
|
||||||
|
|
||||||
|
for (var j = 0; j < funcs.length; j++) {
|
||||||
|
if (funcs[j](formatObj)){
|
||||||
|
canSkip = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fstart > -1 && !canSkip) {
|
||||||
|
var sep = null;
|
||||||
|
var stop = vstart + key.length;
|
||||||
|
var fnext = -1;
|
||||||
|
var nextKeyIdx = i + 1;
|
||||||
|
_vstart += key.length; // set next value start if current key is found
|
||||||
|
|
||||||
|
// get next format token used to determine separator
|
||||||
|
while (fnext == -1 && nextKeyIdx < keys.length){
|
||||||
|
var nextKey = keys[nextKeyIdx];
|
||||||
|
nextKeyIdx += 1;
|
||||||
|
if (filterdKeys.indexOf(nextKey) === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fnext = nextKey ? format.indexOf(nextKey) : -1; // next format start
|
||||||
|
}
|
||||||
|
if (fnext > -1){
|
||||||
|
sep = format.slice(stop, fnext);
|
||||||
|
if (sep) {
|
||||||
|
var _stop = value.slice(vstart).indexOf(sep);
|
||||||
|
if (_stop && _stop > -1){
|
||||||
|
stop = _stop + vstart;
|
||||||
|
_vstart = stop + sep.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = parseInt(value.slice(vstart, stop));
|
||||||
|
|
||||||
|
var funcs = hooks.updateValue.get(key);
|
||||||
|
for (var k = 0; k < funcs.length; k++) {
|
||||||
|
val = funcs[k](val, formatObj, vstart, stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
formatObj[key] = { index: vstart, value: val };
|
||||||
|
vstart = _vstart; // set next value start
|
||||||
|
}
|
||||||
|
return formatObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} value
|
||||||
|
* @param {DTS} settings
|
||||||
|
* @returns {Date} date object
|
||||||
|
*/
|
||||||
|
function parseDate(value, settings) {
|
||||||
|
/** @type {{yyyy:number=, yy:number=, mm:number=, dd:number=}} */
|
||||||
|
var formatObj = {yyyy:null, yy:null, mm:null, dd:null};
|
||||||
|
var format = ((settings.dateFormat) || '').toLowerCase();
|
||||||
|
if (!format) {
|
||||||
|
throw new TypeError('dateFormat not found (' + settings.dateFormat + ')');
|
||||||
|
}
|
||||||
|
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||||
|
hooks.canSkip.add("yy", function(data){
|
||||||
|
return data["yyyy"].value;
|
||||||
|
});
|
||||||
|
hooks.updateValue.add("yy", function(val){
|
||||||
|
return 100 * Math.floor(new Date().getFullYear() / 100) + val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var year = formatObj["yyyy"].value || formatObj["yy"].value;
|
||||||
|
var month = formatObj["mm"].value - 1;
|
||||||
|
var date = formatObj["dd"].value;
|
||||||
|
var result = new Date(year, month, date);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} value
|
||||||
|
* @param {DTS} settings
|
||||||
|
* @returns {Number} time in milliseconds <= (24 * 60 * 60 * 1000) - 1
|
||||||
|
*/
|
||||||
|
function parseTime(value, settings) {
|
||||||
|
var format = ((settings.timeFormat) || '').toLowerCase();
|
||||||
|
if (!format) {
|
||||||
|
throw new TypeError('timeFormat not found (' + settings.timeFormat + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {{hh:number=, mm:number=, ss:number=, a:string=}} */
|
||||||
|
var formatObj = {hh:null, mm:null, ss:null, a:null};
|
||||||
|
var formatObj = parseData(value, format, formatObj, function(hooks){
|
||||||
|
hooks.updateValue.add("a", function(val, data, start, stop){
|
||||||
|
return value.slice(start, start + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var hours = formatObj["hh"].value;
|
||||||
|
var minutes = formatObj["mm"].value;
|
||||||
|
var seconds = formatObj["ss"].value;
|
||||||
|
var am_pm = formatObj["a"].value;
|
||||||
|
var am_pm_lower = am_pm ? am_pm.toLowerCase() : am_pm;
|
||||||
|
if (am_pm && ["am", "pm"].indexOf(am_pm_lower) > -1){
|
||||||
|
if (am_pm_lower == 'am' && hours == 12){
|
||||||
|
hours = 0;
|
||||||
|
} else if (am_pm_lower == 'pm') {
|
||||||
|
hours += 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var time = hours * 60 * 60 * 1000 + minutes * 60 * 1000 + seconds * 1000;
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Date} value
|
||||||
|
* @param {DTS} settings
|
||||||
|
* @returns {String} date string
|
||||||
|
*/
|
||||||
|
function renderDate(value, settings) {
|
||||||
|
var format = settings.dateFormat.toLowerCase();
|
||||||
|
var date = value.getDate();
|
||||||
|
var month = value.getMonth() + 1;
|
||||||
|
var year = value.getFullYear();
|
||||||
|
var yearShort = year % 100;
|
||||||
|
var formatObj = {
|
||||||
|
dd: date < 10 ? "0" + date : date,
|
||||||
|
mm: month < 10 ? "0" + month : month,
|
||||||
|
yyyy: year,
|
||||||
|
yy: yearShort < 10 ? "0" + yearShort : yearShort
|
||||||
|
};
|
||||||
|
var str = format.replace(settings.dateFormatRegEx, function (found) {
|
||||||
|
return formatObj[found];
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Date} value
|
||||||
|
* @param {DTS} settings
|
||||||
|
* @returns {String} date string
|
||||||
|
*/
|
||||||
|
function renderTime(value, settings) {
|
||||||
|
var Format = settings.timeFormat;
|
||||||
|
var format = Format.toLowerCase();
|
||||||
|
var hours = value.getHours();
|
||||||
|
var minutes = value.getMinutes();
|
||||||
|
var seconds = value.getSeconds();
|
||||||
|
var am_pm = null;
|
||||||
|
var hh_am_pm = null;
|
||||||
|
if (format.indexOf('a') > -1) {
|
||||||
|
am_pm = hours >= 12 ? 'pm' : 'am';
|
||||||
|
am_pm = Format.indexOf('A') > -1 ? am_pm.toUpperCase() : am_pm;
|
||||||
|
hh_am_pm = hours == 0 ? '12' : (hours > 12 ? hours%12 : hours);
|
||||||
|
}
|
||||||
|
var formatObj = {
|
||||||
|
hh: am_pm ? hh_am_pm : (hours < 10 ? "0" + hours : hours),
|
||||||
|
mm: minutes < 10 ? "0" + minutes : minutes,
|
||||||
|
ss: seconds < 10 ? "0" + seconds : seconds,
|
||||||
|
a: am_pm,
|
||||||
|
};
|
||||||
|
var str = format.replace(settings.timeFormatRegEx, function (found) {
|
||||||
|
return formatObj[found];
|
||||||
|
});
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* checks if two dates are equal
|
||||||
|
* @param {Date} date1
|
||||||
|
* @param {Date} date2
|
||||||
|
* @returns {Boolean} true or false
|
||||||
|
*/
|
||||||
|
function isEqualDate(date1, date2) {
|
||||||
|
if (!(date1 && date2)) return false;
|
||||||
|
return (date1.getFullYear() == date2.getFullYear() &&
|
||||||
|
date1.getMonth() == date2.getMonth() &&
|
||||||
|
date1.getDate() == date2.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Number} val
|
||||||
|
* @param {Number} pad
|
||||||
|
* @param {*} default_val
|
||||||
|
* @returns {String} padded string
|
||||||
|
*/
|
||||||
|
function padded(val, pad, default_val) {
|
||||||
|
var default_val = default_val || 0;
|
||||||
|
var valStr = '' + (parseInt(val) || default_val);
|
||||||
|
var diff = Math.max(pad, valStr.length) - valStr.length;
|
||||||
|
return ('' + default_val).repeat(diff) + valStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template X
|
||||||
|
* @template Y
|
||||||
|
* @param {X} obj
|
||||||
|
* @param {Y} objDefaults
|
||||||
|
* @returns {X|Y} merged object
|
||||||
|
*/
|
||||||
|
function setDefaults(obj, objDefaults) {
|
||||||
|
var keys = Object.keys(objDefaults);
|
||||||
|
for (var i=0; i<keys.length; i++) {
|
||||||
|
var key = keys[i];
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
obj[key] = objDefaults[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
window.dtsel = Object.create({},{
|
||||||
|
DTS: { value: DTS },
|
||||||
|
DTObj: { value: DTBox },
|
||||||
|
fn: {
|
||||||
|
value: Object.defineProperties({}, {
|
||||||
|
empty: { value: empty },
|
||||||
|
appendAfter: {
|
||||||
|
value: function (newElem, refNode) {
|
||||||
|
refNode.parentNode.insertBefore(newElem, refNode.nextSibling);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getOffset: { value: getOffset },
|
||||||
|
parseDate: { value: parseDate },
|
||||||
|
renderDate: { value: renderDate },
|
||||||
|
parseTime: {value: parseTime},
|
||||||
|
renderTime: {value: renderTime},
|
||||||
|
setDefaults: {value: setDefaults},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
23
reminder-dashboard/public/static/js/expand.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
function collapse_all() {
|
||||||
|
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||||
|
el.classList.add("is-collapsed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expand_all() {
|
||||||
|
document.querySelectorAll("div.reminderContent:not(.creator)").forEach((el) => {
|
||||||
|
el.classList.remove("is-collapsed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandAll = document.querySelector("#expandAll");
|
||||||
|
|
||||||
|
expandAll.addEventListener("change", (ev) => {
|
||||||
|
if (ev.target.value === "expand") {
|
||||||
|
expand_all();
|
||||||
|
} else if (ev.target.value === "collapse") {
|
||||||
|
collapse_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
ev.target.value = "";
|
||||||
|
});
|
94
reminder-dashboard/public/static/js/interval.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
function get_interval(element) {
|
||||||
|
let months = element.querySelector('input[name="interval_months"]').value;
|
||||||
|
let days = element.querySelector('input[name="interval_days"]').value;
|
||||||
|
let hours = element.querySelector('input[name="interval_hours"]').value;
|
||||||
|
let minutes = element.querySelector('input[name="interval_minutes"]').value;
|
||||||
|
let seconds = element.querySelector('input[name="interval_seconds"]').value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
months: parseInt(months) || null,
|
||||||
|
days: parseInt(days) || null,
|
||||||
|
seconds:
|
||||||
|
(parseInt(hours) || 0) * 3600 +
|
||||||
|
(parseInt(minutes) || 0) * 60 +
|
||||||
|
(parseInt(seconds) || 0) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_interval(element) {
|
||||||
|
let months = element.querySelector('input[name="interval_months"]');
|
||||||
|
let days = element.querySelector('input[name="interval_days"]');
|
||||||
|
let hours = element.querySelector('input[name="interval_hours"]');
|
||||||
|
let minutes = element.querySelector('input[name="interval_minutes"]');
|
||||||
|
let seconds = element.querySelector('input[name="interval_seconds"]');
|
||||||
|
|
||||||
|
let interval = get_interval(element);
|
||||||
|
|
||||||
|
if (interval.months === null && interval.days === null && interval.seconds === null) {
|
||||||
|
months.value = "";
|
||||||
|
days.value = "";
|
||||||
|
hours.value = "";
|
||||||
|
minutes.value = "";
|
||||||
|
seconds.value = "";
|
||||||
|
} else {
|
||||||
|
months.value = months.value.padStart(1, "0");
|
||||||
|
days.value = days.value.padStart(1, "0");
|
||||||
|
hours.value = hours.value.padStart(2, "0");
|
||||||
|
minutes.value = minutes.value.padStart(2, "0");
|
||||||
|
seconds.value = seconds.value.padStart(2, "0");
|
||||||
|
|
||||||
|
if (seconds.value >= 60) {
|
||||||
|
let quotient = Math.floor(seconds.value / 60);
|
||||||
|
let remainder = seconds.value % 60;
|
||||||
|
|
||||||
|
seconds.value = String(remainder).padStart(2, "0");
|
||||||
|
minutes.value = String(Number(minutes.value) + Number(quotient)).padStart(
|
||||||
|
2,
|
||||||
|
"0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (minutes.value >= 60) {
|
||||||
|
let quotient = Math.floor(minutes.value / 60);
|
||||||
|
let remainder = minutes.value % 60;
|
||||||
|
|
||||||
|
minutes.value = String(remainder).padStart(2, "0");
|
||||||
|
hours.value = String(Number(hours.value) + Number(quotient)).padStart(2, "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const $intervalGroup = document.querySelector(".interval-group");
|
||||||
|
|
||||||
|
document.querySelector(".interval-group").addEventListener(
|
||||||
|
"blur",
|
||||||
|
(ev) => {
|
||||||
|
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||||
|
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("remindersLoaded", (event) => {
|
||||||
|
for (reminder of event.detail) {
|
||||||
|
let $intervalGroup = reminder.node.querySelector(".interval-group");
|
||||||
|
|
||||||
|
$intervalGroup.addEventListener(
|
||||||
|
"blur",
|
||||||
|
(ev) => {
|
||||||
|
if (ev.target.nodeName !== "BUTTON") update_interval($intervalGroup);
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$intervalGroup.querySelector("button.clear").addEventListener("click", () => {
|
||||||
|
$intervalGroup.querySelectorAll("input").forEach((el) => {
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
7
reminder-dashboard/public/static/js/iro.js
Normal file
2
reminder-dashboard/public/static/js/js.cookie.min.js
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/*! js-cookie v3.0.0-rc.0 | MIT */
|
||||||
|
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self,function(){var r=e.Cookies,n=e.Cookies=t();n.noConflict=function(){return e.Cookies=r,n}}())}(this,function(){"use strict";function e(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var n in r)e[n]=r[n]}return e}var t={read:function(e){return e.replace(/%3B/g,";")},write:function(e){return e.replace(/;/g,"%3B")}};return function r(n,i){function o(r,o,u){if("undefined"!=typeof document){"number"==typeof(u=e({},i,u)).expires&&(u.expires=new Date(Date.now()+864e5*u.expires)),u.expires&&(u.expires=u.expires.toUTCString()),r=t.write(r).replace(/=/g,"%3D"),o=n.write(String(o),r);var c="";for(var f in u)u[f]&&(c+="; "+f,!0!==u[f]&&(c+="="+u[f].split(";")[0]));return document.cookie=r+"="+o+c}}return Object.create({set:o,get:function(e){if("undefined"!=typeof document&&(!arguments.length||e)){for(var r=document.cookie?document.cookie.split("; "):[],i={},o=0;o<r.length;o++){var u=r[o].split("="),c=u.slice(1).join("="),f=t.read(u[0]).replace(/%3D/g,"=");if(i[f]=n.read(c,f),e===f)break}return e?i[e]:i}},remove:function(t,r){o(t,"",e({},r,{expires:-1}))},withAttributes:function(t){return r(this.converter,e({},this.attributes,t))},withConverter:function(t){return r(e({},this.converter,t),this.attributes)}},{attributes:{value:Object.freeze(i)},converter:{value:Object.freeze(n)}})}(t,{path:"/"})});
|
1
reminder-dashboard/public/static/js/luxon.min.js
vendored
Normal file
1087
reminder-dashboard/public/static/js/main.js
Normal file
16
reminder-dashboard/public/static/js/reporter.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
const REPORTER_ID = crypto.randomUUID();
|
||||||
|
|
||||||
|
window.addEventListener("error", async (ev) => {
|
||||||
|
await fetch("/report", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
reporterId: REPORTER_ID,
|
||||||
|
url: window.location.href,
|
||||||
|
relativeTimestamp: ev.timeStamp,
|
||||||
|
errorMessage: ev.message,
|
||||||
|
errorLine: ev.lineno,
|
||||||
|
errorFile: ev.filename,
|
||||||
|
errorType: ev.type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
70
reminder-dashboard/public/static/js/sort.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
let guildReminders = document.querySelector("#guildReminders");
|
||||||
|
|
||||||
|
function sort_by(cond) {
|
||||||
|
if (cond === "channel") {
|
||||||
|
[...guildReminders.children]
|
||||||
|
.sort((a, b) => {
|
||||||
|
let channel1 = a.querySelector("select.channel-selector").value;
|
||||||
|
let channel2 = b.querySelector("select.channel-selector").value;
|
||||||
|
|
||||||
|
return channel1 > channel2 ? 1 : -1;
|
||||||
|
})
|
||||||
|
.forEach((node) => guildReminders.appendChild(node));
|
||||||
|
|
||||||
|
// go through and add channel categories
|
||||||
|
let currentChannelGroup = null;
|
||||||
|
for (let child of guildReminders.querySelectorAll("div.reminderContent")) {
|
||||||
|
let thisChannelGroup = child.querySelector("select.channel-selector").value;
|
||||||
|
|
||||||
|
if (currentChannelGroup !== thisChannelGroup) {
|
||||||
|
let newNode = document.createElement("div");
|
||||||
|
newNode.textContent =
|
||||||
|
"#" + channels.find((a) => a.id === thisChannelGroup).name;
|
||||||
|
newNode.classList.add("channel-tag");
|
||||||
|
|
||||||
|
guildReminders.insertBefore(newNode, child);
|
||||||
|
|
||||||
|
currentChannelGroup = thisChannelGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// remove any channel tags if previous ordering was by channel
|
||||||
|
guildReminders.querySelectorAll("div.channel-tag").forEach((el) => {
|
||||||
|
el.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cond === "time") {
|
||||||
|
[...guildReminders.children]
|
||||||
|
.sort((a, b) => {
|
||||||
|
let time1 = luxon.DateTime.fromISO(
|
||||||
|
a.querySelector('input[name="time"]').value
|
||||||
|
);
|
||||||
|
let time2 = luxon.DateTime.fromISO(
|
||||||
|
b.querySelector('input[name="time"]').value
|
||||||
|
);
|
||||||
|
|
||||||
|
return time1 > time2 ? 1 : -1;
|
||||||
|
})
|
||||||
|
.forEach((node) => guildReminders.appendChild(node));
|
||||||
|
} else {
|
||||||
|
[...guildReminders.children]
|
||||||
|
.sort((a, b) => {
|
||||||
|
let name1 = a.querySelector('input[name="name"]').value;
|
||||||
|
let name2 = b.querySelector('input[name="name"]').value;
|
||||||
|
|
||||||
|
return name1 > name2 ? 1 : -1;
|
||||||
|
})
|
||||||
|
.forEach((node) => guildReminders.appendChild(node));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selector = document.querySelector("#orderBy");
|
||||||
|
|
||||||
|
selector.addEventListener("change", () => {
|
||||||
|
sort_by(selector.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("remindersLoaded", () => {
|
||||||
|
sort_by(selector.value);
|
||||||
|
});
|
57
reminder-dashboard/public/static/js/timezone.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
let timezone = luxon.DateTime.now().zone.name;
|
||||||
|
const browserTimezone = luxon.DateTime.now().zone.name;
|
||||||
|
let botTimezone = "UTC";
|
||||||
|
|
||||||
|
function update_times() {
|
||||||
|
document.querySelectorAll("span.set-timezone").forEach((element) => {
|
||||||
|
element.textContent = timezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.set-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.browser-timezone").forEach((element) => {
|
||||||
|
element.textContent = browserTimezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.browser-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.bot-timezone").forEach((element) => {
|
||||||
|
element.textContent = botTimezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.bot-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setInterval(() => {
|
||||||
|
update_times();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
document.getElementById("set-bot-timezone").addEventListener("click", () => {
|
||||||
|
timezone = botTimezone;
|
||||||
|
update_times();
|
||||||
|
});
|
||||||
|
document.getElementById("set-browser-timezone").addEventListener("click", () => {
|
||||||
|
timezone = browserTimezone;
|
||||||
|
update_times();
|
||||||
|
});
|
||||||
|
document.getElementById("update-bot-timezone").addEventListener("click", () => {
|
||||||
|
timezone = browserTimezone;
|
||||||
|
fetch("/dashboard/api/user", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ timezone: timezone }),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
show_error(data.error);
|
||||||
|
} else {
|
||||||
|
botTimezone = browserTimezone;
|
||||||
|
update_times();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
20
reminder-dashboard/public/static/site.webmanifest
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Reminder Bot Dashboard",
|
||||||
|
"short_name": "Reminders",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/favicon/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/favicon/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
BIN
reminder-dashboard/public/static/webfonts/fa-brands-400.eot
Normal file
3633
reminder-dashboard/public/static/webfonts/fa-brands-400.svg
Normal file
After Width: | Height: | Size: 712 KiB |
BIN
reminder-dashboard/public/static/webfonts/fa-brands-400.ttf
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-brands-400.woff
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-brands-400.woff2
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-duotone-900.eot
Normal file
15055
reminder-dashboard/public/static/webfonts/fa-duotone-900.svg
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
reminder-dashboard/public/static/webfonts/fa-duotone-900.ttf
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-duotone-900.woff
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-duotone-900.woff2
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-light-300.eot
Normal file
12330
reminder-dashboard/public/static/webfonts/fa-light-300.svg
Normal file
After Width: | Height: | Size: 2.3 MiB |
BIN
reminder-dashboard/public/static/webfonts/fa-light-300.ttf
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-light-300.woff
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-light-300.woff2
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-regular-400.eot
Normal file
11256
reminder-dashboard/public/static/webfonts/fa-regular-400.svg
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
reminder-dashboard/public/static/webfonts/fa-regular-400.ttf
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-regular-400.woff
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-regular-400.woff2
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-solid-900.eot
Normal file
9588
reminder-dashboard/public/static/webfonts/fa-solid-900.svg
Normal file
After Width: | Height: | Size: 1.7 MiB |
BIN
reminder-dashboard/public/static/webfonts/fa-solid-900.ttf
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-solid-900.woff
Normal file
BIN
reminder-dashboard/public/static/webfonts/fa-solid-900.woff2
Normal file
199
reminder-dashboard/src/api.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
name: string;
|
||||||
|
patreon: boolean;
|
||||||
|
timezone: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GuildInfo = {
|
||||||
|
patreon: boolean;
|
||||||
|
name: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EmbedField = {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
inline: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Reminder = {
|
||||||
|
attachment: string | null;
|
||||||
|
attachment_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
channel: string;
|
||||||
|
content: string;
|
||||||
|
embed_author: string;
|
||||||
|
embed_author_url: string | null;
|
||||||
|
embed_color: number;
|
||||||
|
embed_description: string;
|
||||||
|
embed_footer: string;
|
||||||
|
embed_footer_url: string | null;
|
||||||
|
embed_image_url: string | null;
|
||||||
|
embed_thumbnail_url: string | null;
|
||||||
|
embed_title: string;
|
||||||
|
embed_fields: EmbedField[] | null;
|
||||||
|
enabled: boolean;
|
||||||
|
expires: DateTime | null;
|
||||||
|
interval_seconds: number | null;
|
||||||
|
interval_days: number | null;
|
||||||
|
interval_months: number | null;
|
||||||
|
name: string;
|
||||||
|
restartable: boolean;
|
||||||
|
tts: boolean;
|
||||||
|
uid: string;
|
||||||
|
username: string;
|
||||||
|
utc_time: DateTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoleInfo = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Template = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
attachment: string | null;
|
||||||
|
attachment_name: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
channel: string;
|
||||||
|
content: string;
|
||||||
|
embed_author: string;
|
||||||
|
embed_author_url: string | null;
|
||||||
|
embed_color: number;
|
||||||
|
embed_description: string;
|
||||||
|
embed_footer: string;
|
||||||
|
embed_footer_url: string | null;
|
||||||
|
embed_image_url: string | null;
|
||||||
|
embed_thumbnail_url: string | null;
|
||||||
|
embed_title: string;
|
||||||
|
embed_fields: EmbedField[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_INFO_STALE_TIME = 120_000;
|
||||||
|
const GUILD_INFO_STALE_TIME = 300_000;
|
||||||
|
const OTHER_STALE_TIME = 15_000;
|
||||||
|
|
||||||
|
export const fetchUserInfo = () => ({
|
||||||
|
queryKey: ["USER_INFO"],
|
||||||
|
queryFn: () => axios.get("/dashboard/api/user").then((resp) => resp.data) as Promise<UserInfo>,
|
||||||
|
staleTime: USER_INFO_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchUserInfo = () => ({
|
||||||
|
mutationFn: (timezone: string) => axios.patch(`/dashboard/api/user`, { timezone }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchUserGuilds = () => ({
|
||||||
|
queryKey: ["USER_GUILDS"],
|
||||||
|
queryFn: () =>
|
||||||
|
axios.get("/dashboard/api/user/guilds").then((resp) => resp.data) as Promise<GuildInfo[]>,
|
||||||
|
staleTime: USER_INFO_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchGuildInfo = (guild: string) => ({
|
||||||
|
queryKey: ["GUILD_INFO", guild],
|
||||||
|
queryFn: () =>
|
||||||
|
axios.get(`/dashboard/api/guild/${guild}`).then((resp) => resp.data) as Promise<GuildInfo>,
|
||||||
|
staleTime: GUILD_INFO_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchGuildChannels = (guild: string) => ({
|
||||||
|
queryKey: ["GUILD_CHANNELS", guild],
|
||||||
|
queryFn: () =>
|
||||||
|
axios.get(`/dashboard/api/guild/${guild}/channels`).then((resp) => resp.data) as Promise<
|
||||||
|
ChannelInfo[]
|
||||||
|
>,
|
||||||
|
staleTime: GUILD_INFO_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchGuildRoles = (guild: string) => ({
|
||||||
|
queryKey: ["GUILD_ROLES", guild],
|
||||||
|
queryFn: () =>
|
||||||
|
axios.get(`/dashboard/api/guild/${guild}/roles`).then((resp) => resp.data) as Promise<
|
||||||
|
RoleInfo[]
|
||||||
|
>,
|
||||||
|
staleTime: GUILD_INFO_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchGuildReminders = (guild: string) => ({
|
||||||
|
queryKey: ["GUILD_REMINDERS", guild],
|
||||||
|
queryFn: () =>
|
||||||
|
axios
|
||||||
|
.get(`/dashboard/api/guild/${guild}/reminders`)
|
||||||
|
.then((resp) => resp.data)
|
||||||
|
.then((value) =>
|
||||||
|
value.map((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
utc_time: DateTime.fromISO(reminder.utc_time, { zone: "UTC" }),
|
||||||
|
expires:
|
||||||
|
reminder.expires === null
|
||||||
|
? null
|
||||||
|
: DateTime.fromISO(reminder.expires, { zone: "UTC" }),
|
||||||
|
})),
|
||||||
|
) as Promise<Reminder[]>,
|
||||||
|
staleTime: OTHER_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchGuildReminder = (guild: string) => ({
|
||||||
|
mutationFn: (reminder: Reminder) =>
|
||||||
|
axios.patch(`/dashboard/api/guild/${guild}/reminders`, {
|
||||||
|
...reminder,
|
||||||
|
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postGuildReminder = (guild: string) => ({
|
||||||
|
mutationFn: (reminder: Reminder) =>
|
||||||
|
axios
|
||||||
|
.post(`/dashboard/api/guild/${guild}/reminders`, {
|
||||||
|
...reminder,
|
||||||
|
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
|
||||||
|
})
|
||||||
|
.then((resp) => resp.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteGuildReminder = (guild: string) => ({
|
||||||
|
mutationFn: (reminder: Reminder) =>
|
||||||
|
axios.delete(`/dashboard/api/guild/${guild}/reminders`, {
|
||||||
|
data: {
|
||||||
|
uid: reminder.uid,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchGuildTemplates = (guild: string) => ({
|
||||||
|
queryKey: ["GUILD_TEMPLATES", guild],
|
||||||
|
queryFn: () =>
|
||||||
|
axios.get(`/dashboard/api/guild/${guild}/templates`).then((resp) => resp.data) as Promise<
|
||||||
|
Template[]
|
||||||
|
>,
|
||||||
|
staleTime: OTHER_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postGuildTemplate = (guild: string) => ({
|
||||||
|
mutationFn: (reminder: Reminder) =>
|
||||||
|
axios
|
||||||
|
.post(`/dashboard/api/guild/${guild}/templates`, {
|
||||||
|
...reminder,
|
||||||
|
utc_time: reminder.utc_time.toFormat("yyyy-LL-dd'T'HH:mm:ss"),
|
||||||
|
})
|
||||||
|
.then((resp) => resp.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteGuildTemplate = (guild: string) => ({
|
||||||
|
mutationFn: (template: Template) =>
|
||||||
|
axios.delete(`/dashboard/api/guild/${guild}/templates`, {
|
||||||
|
data: {
|
||||||
|
id: template.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
7
reminder-dashboard/src/components/App/FlashContext.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createContext } from "preact";
|
||||||
|
import { useContext } from "preact/compat";
|
||||||
|
import { Message } from "./FlashProvider";
|
||||||
|
|
||||||
|
export const FlashContext = createContext(null as (message: Message) => void);
|
||||||
|
|
||||||
|
export const useFlash = () => useContext(FlashContext);
|
43
reminder-dashboard/src/components/App/FlashProvider.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { FlashContext } from "./FlashContext";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { MESSAGE_FLASH_TIME } from "../../consts";
|
||||||
|
|
||||||
|
export type Message = {
|
||||||
|
message: string;
|
||||||
|
type: "error" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FlashProvider = ({ children }) => {
|
||||||
|
const [messages, setMessages] = useState([] as Message[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashContext.Provider
|
||||||
|
value={(message: Message) => {
|
||||||
|
setMessages((messages: Message[]) => [...messages, message]);
|
||||||
|
setTimeout(() => {
|
||||||
|
setMessages((messages) => [...messages].splice(1));
|
||||||
|
}, MESSAGE_FLASH_TIME);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div class="flash-container">
|
||||||
|
{messages.map((message) => {
|
||||||
|
const className = message.type === "error" ? "is-danger" : "is-success";
|
||||||
|
const icon =
|
||||||
|
message.type === "error" ? "fa-exclamation-circle" : "fa-check";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`notification flash-message is-active ${className}`}>
|
||||||
|
<span class="icon">
|
||||||
|
<i class={`far ${icon}`}></i>
|
||||||
|
</span>{" "}
|
||||||
|
<span class="error-message">{message.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</FlashContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
20
reminder-dashboard/src/components/App/TimezoneProvider.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createContext } from "preact";
|
||||||
|
import { useContext } from "preact/compat";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
|
type TTimezoneContext = [string, (tz: string) => void];
|
||||||
|
|
||||||
|
const TimezoneContext = createContext(["UTC", () => {}] as TTimezoneContext);
|
||||||
|
|
||||||
|
export const TimezoneProvider = ({ children }) => {
|
||||||
|
const [timezone, setTimezone] = useState(DateTime.now().zoneName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimezoneContext.Provider value={[timezone, setTimezone]}>
|
||||||
|
{children}
|
||||||
|
</TimezoneContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTimezone = () => useContext(TimezoneContext);
|
33
reminder-dashboard/src/components/App/index.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Sidebar } from "../Sidebar";
|
||||||
|
import { QueryClient, QueryClientProvider } from "react-query";
|
||||||
|
import { Route, Router, Switch } from "wouter";
|
||||||
|
import { Welcome } from "../Welcome";
|
||||||
|
import { Guild } from "../Guild";
|
||||||
|
import { FlashProvider } from "./FlashProvider";
|
||||||
|
import { TimezoneProvider } from "./TimezoneProvider";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimezoneProvider>
|
||||||
|
<FlashProvider>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Router base={"/dashboard"}>
|
||||||
|
<div class="columns is-gapless dashboard-frame">
|
||||||
|
<Sidebar />
|
||||||
|
<div class="column is-main-content">
|
||||||
|
<Switch>
|
||||||
|
<Route path={"/:guild/reminders"} component={Guild}></Route>
|
||||||
|
<Route>
|
||||||
|
<Welcome />
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</FlashProvider>
|
||||||
|
</TimezoneProvider>
|
||||||
|
);
|
||||||
|
}
|
25
reminder-dashboard/src/components/Guild/GuildError.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const GuildError = () => {
|
||||||
|
return (
|
||||||
|
<div class="hero is-fullheight">
|
||||||
|
<div class="hero-body">
|
||||||
|
<div class="container has-text-centered">
|
||||||
|
<p class="title">We couldn't get this server's data</p>
|
||||||
|
<p class="subtitle">
|
||||||
|
Please check Reminder Bot is in the server, and has correct permissions.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
class="button is-size-4 is-rounded is-success"
|
||||||
|
href="https://invite.reminder-bot.com"
|
||||||
|
>
|
||||||
|
<p class="is-size-4">
|
||||||
|
<span>Add to Server</span>{" "}
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
130
reminder-dashboard/src/components/Guild/GuildReminders.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { useParams } from "wouter";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { fetchGuildChannels, fetchGuildReminders } from "../../api";
|
||||||
|
import { EditReminder } from "../Reminder/EditReminder";
|
||||||
|
import { CreateReminder } from "../Reminder/CreateReminder";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
|
||||||
|
enum Sort {
|
||||||
|
Time = "time",
|
||||||
|
Name = "name",
|
||||||
|
Channel = "channel",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GuildReminders = () => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
|
||||||
|
const { isSuccess, data: guildReminders } = useQuery(fetchGuildReminders(guild));
|
||||||
|
const { data: channels } = useQuery(fetchGuildChannels(guild));
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [sort, setSort] = useState(Sort.Time);
|
||||||
|
|
||||||
|
let prevReminder = null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ margin: "0 12px 12px 12px" }}>
|
||||||
|
<strong>Create Reminder</strong>
|
||||||
|
<div id={"reminderCreator"}>
|
||||||
|
<CreateReminder />
|
||||||
|
</div>
|
||||||
|
<br></br>
|
||||||
|
<div class={"field"}>
|
||||||
|
<div class={"columns is-mobile"}>
|
||||||
|
<div class={"column"}>
|
||||||
|
<strong>Reminders</strong>
|
||||||
|
</div>
|
||||||
|
<div class={"column is-narrow"}>
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select
|
||||||
|
id="orderBy"
|
||||||
|
onInput={(ev) => {
|
||||||
|
setSort(ev.currentTarget.value as Sort);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={Sort.Time} selected={sort == Sort.Time}>
|
||||||
|
Time
|
||||||
|
</option>
|
||||||
|
<option value={Sort.Name} selected={sort == Sort.Name}>
|
||||||
|
Name
|
||||||
|
</option>
|
||||||
|
<option value={Sort.Channel} selected={sort == Sort.Channel}>
|
||||||
|
Channel
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="icon is-small is-left">
|
||||||
|
<i class="fas fa-sort-amount-down"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class={"column is-narrow"}>
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<div class="select is-small">
|
||||||
|
<select
|
||||||
|
id="expandAll"
|
||||||
|
onInput={(ev) => {
|
||||||
|
if (ev.currentTarget.value === "expand") {
|
||||||
|
setCollapsed(false);
|
||||||
|
} else if (ev.currentTarget.value === "collapse") {
|
||||||
|
setCollapsed(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" selected></option>
|
||||||
|
<option value="expand">Expand All</option>
|
||||||
|
<option value="collapse">Collapse All</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="icon is-small is-left">
|
||||||
|
<i class="fas fa-expand-arrows"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id={"guildReminders"}>
|
||||||
|
{isSuccess &&
|
||||||
|
guildReminders
|
||||||
|
.sort((r1, r2) => {
|
||||||
|
if (sort === Sort.Time) {
|
||||||
|
return r1.utc_time > r2.utc_time ? 1 : -1;
|
||||||
|
} else if (sort === Sort.Name) {
|
||||||
|
return r1.name > r2.name ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return r1.channel > r2.channel ? 1 : -1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((reminder) => {
|
||||||
|
let breaker = <></>;
|
||||||
|
if (sort === Sort.Channel && channels) {
|
||||||
|
if (
|
||||||
|
prevReminder === null ||
|
||||||
|
prevReminder.channel !== reminder.channel
|
||||||
|
) {
|
||||||
|
const channel = channels.find(
|
||||||
|
(ch) => ch.id === reminder.channel,
|
||||||
|
);
|
||||||
|
breaker = <div class={"channel-tag"}>#{channel.name}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevReminder = reminder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{breaker}
|
||||||
|
<EditReminder
|
||||||
|
key={reminder.uid}
|
||||||
|
reminder={reminder}
|
||||||
|
globalCollapse={collapsed}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
27
reminder-dashboard/src/components/Guild/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { fetchGuildInfo } from "../../api";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import { GuildReminders } from "./GuildReminders";
|
||||||
|
import { GuildError } from "./GuildError";
|
||||||
|
import { createPortal } from "preact/compat";
|
||||||
|
import { Import } from "../Import";
|
||||||
|
|
||||||
|
export const Guild = () => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
const { isSuccess, data: guildInfo } = useQuery(fetchGuildInfo(guild));
|
||||||
|
|
||||||
|
if (!isSuccess) {
|
||||||
|
return <></>;
|
||||||
|
} else if (guildInfo.error) {
|
||||||
|
return <GuildError />;
|
||||||
|
} else {
|
||||||
|
const importModal = createPortal(<Import />, document.getElementById("bottom-sidebar"));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{importModal}
|
||||||
|
<GuildReminders />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
144
reminder-dashboard/src/components/Import/index.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { Modal } from "../Modal";
|
||||||
|
import { useRef, useState } from "preact/hooks";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import axios from "axios";
|
||||||
|
import { useFlash } from "../App/FlashContext";
|
||||||
|
|
||||||
|
export const Import = () => {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
class="show-modal"
|
||||||
|
data-modal="chooseTimezoneModal"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-exchange"></i>
|
||||||
|
</span>{" "}
|
||||||
|
Import/Export
|
||||||
|
</a>
|
||||||
|
{modalOpen && <ImportModal setModalOpen={setModalOpen} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportModal = ({ setModalOpen }) => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
|
||||||
|
const aRef = useRef<HTMLAnchorElement>();
|
||||||
|
const inputRef = useRef<HTMLInputElement>();
|
||||||
|
const flash = useFlash();
|
||||||
|
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
setModalOpen={setModalOpen}
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
Import/Export Manager{" "}
|
||||||
|
<a href="/help/iemanager">
|
||||||
|
<span>
|
||||||
|
<i class="fa fa-question-circle"></i>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div class="control">
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="default-width"
|
||||||
|
name="exportSelect"
|
||||||
|
value="reminders"
|
||||||
|
checked
|
||||||
|
/>
|
||||||
|
Reminders
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<div style="color: red">
|
||||||
|
Please first read the <a href="/help/iemanager">support page</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="button is-success is-outlined"
|
||||||
|
style={{ margin: "2px" }}
|
||||||
|
id="import-data"
|
||||||
|
disabled={isImporting}
|
||||||
|
onClick={() => {
|
||||||
|
inputRef.current.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-success"
|
||||||
|
style={{ margin: "2px" }}
|
||||||
|
id="export-data"
|
||||||
|
onClick={() =>
|
||||||
|
axios
|
||||||
|
.get(`/dashboard/api/guild/${guild}/export/reminders`)
|
||||||
|
.then(({ data, status }) => {
|
||||||
|
if (status === 200) {
|
||||||
|
aRef.current.href = `data:text/plain;charset=utf-8,${encodeURIComponent(
|
||||||
|
data.body,
|
||||||
|
)}`;
|
||||||
|
aRef.current.click();
|
||||||
|
} else {
|
||||||
|
flash({
|
||||||
|
message: `Unexpected status ${status}`,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Export Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<a ref={aRef} id="downloader" download="export.csv" class="is-hidden" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="uploader"
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
onChange={() => {
|
||||||
|
new Promise((resolve) => {
|
||||||
|
let fileReader = new FileReader();
|
||||||
|
fileReader.onload = (e) => resolve(fileReader.result);
|
||||||
|
fileReader.readAsDataURL(inputRef.current.files[0]);
|
||||||
|
}).then((dataUrl: string) => {
|
||||||
|
setIsImporting(true);
|
||||||
|
|
||||||
|
axios
|
||||||
|
.put(`/dashboard/api/guild/${guild}/export/reminders`, {
|
||||||
|
body: JSON.stringify({ body: dataUrl.split(",")[1] }),
|
||||||
|
})
|
||||||
|
.then(({ data }) => {
|
||||||
|
setIsImporting(false);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
flash({ message: data.error, type: "error" });
|
||||||
|
} else {
|
||||||
|
flash({ message: data.message, type: "success" });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
delete inputRef.current.files[0];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
61
reminder-dashboard/src/components/Modal/index.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { JSX } from "preact";
|
||||||
|
import { createPortal } from "preact/compat";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setModalOpen: (open: boolean) => never;
|
||||||
|
title: string | JSX.Element;
|
||||||
|
onSubmitText?: string;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Modal = ({ setModalOpen, title, onSubmit, onSubmitText, children }: Props) => {
|
||||||
|
const body = document.querySelector("body");
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div class="modal is-active">
|
||||||
|
<div
|
||||||
|
class="modal-background"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<label class="modal-card-title">{title}</label>
|
||||||
|
<button
|
||||||
|
class="delete close-modal"
|
||||||
|
aria-label="close"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">{children}</section>
|
||||||
|
{onSubmit && (
|
||||||
|
<footer class="modal-card-foot">
|
||||||
|
<button class="button is-success" onInput={onSubmit}>
|
||||||
|
{onSubmitText || "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button close-modal"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="modal-close is-large close-modal"
|
||||||
|
aria-label="close"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
></button>
|
||||||
|
</div>,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
};
|
46
reminder-dashboard/src/components/Reminder/Attachment.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useReminder } from "./ReminderContext";
|
||||||
|
|
||||||
|
export const Attachment = () => {
|
||||||
|
const [{ attachment_name }, setReminder] = useReminder();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="file is-small is-boxed">
|
||||||
|
<label class="file-label">
|
||||||
|
<input
|
||||||
|
class="file-input"
|
||||||
|
type="file"
|
||||||
|
name="attachment"
|
||||||
|
onInput={async (ev) => {
|
||||||
|
const input = ev.currentTarget;
|
||||||
|
|
||||||
|
let file = input.files[0];
|
||||||
|
|
||||||
|
if (file.size >= 8 * 1024 * 1024) {
|
||||||
|
return { error: "File too large." };
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachment: string = await new Promise((resolve) => {
|
||||||
|
let fileReader = new FileReader();
|
||||||
|
fileReader.onload = () => resolve(fileReader.result as string);
|
||||||
|
fileReader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
attachment = attachment.split(",")[1];
|
||||||
|
const attachment_name = file.name;
|
||||||
|
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
attachment,
|
||||||
|
attachment_name,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
<span class="file-cta">
|
||||||
|
<span class="file-label">{attachment_name || "Add Attachment"}</span>
|
||||||
|
<span class="file-icon">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,122 @@
|
|||||||
|
import { LoadTemplate } from "../LoadTemplate";
|
||||||
|
import { useReminder } from "../ReminderContext";
|
||||||
|
import { useMutation, useQueryClient } from "react-query";
|
||||||
|
import { postGuildReminder, postGuildTemplate } from "../../../api";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { ICON_FLASH_TIME } from "../../../consts";
|
||||||
|
import { useFlash } from "../../App/FlashContext";
|
||||||
|
|
||||||
|
export const CreateButtonRow = () => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
const [reminder] = useReminder();
|
||||||
|
|
||||||
|
const [recentlyCreated, setRecentlyCreated] = useState(false);
|
||||||
|
const [templateRecentlyCreated, setTemplateRecentlyCreated] = useState(false);
|
||||||
|
|
||||||
|
const flash = useFlash();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
...postGuildReminder(guild),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.error) {
|
||||||
|
flash({
|
||||||
|
message: data.error,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
flash({
|
||||||
|
message: "Reminder created",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["GUILD_REMINDERS", guild],
|
||||||
|
});
|
||||||
|
setRecentlyCreated(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setRecentlyCreated(false);
|
||||||
|
}, ICON_FLASH_TIME);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateMutation = useMutation({
|
||||||
|
...postGuildTemplate(guild),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.error) {
|
||||||
|
flash({
|
||||||
|
message: data.error,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
flash({
|
||||||
|
message: "Template created",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["GUILD_TEMPLATES", guild],
|
||||||
|
});
|
||||||
|
setTemplateRecentlyCreated(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setTemplateRecentlyCreated(false);
|
||||||
|
}, ICON_FLASH_TIME);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="button-row">
|
||||||
|
<div class="button-row-reminder">
|
||||||
|
<button
|
||||||
|
class="button is-success"
|
||||||
|
onClick={() => {
|
||||||
|
mutation.mutate(reminder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Create Reminder</span>{" "}
|
||||||
|
{mutation.isLoading ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-spin fa-cog"></i>
|
||||||
|
</span>
|
||||||
|
) : recentlyCreated ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-sparkles"></i>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="button-row-template">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="button is-success is-outlined"
|
||||||
|
onClick={() => {
|
||||||
|
templateMutation.mutate(reminder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Create Template</span>{" "}
|
||||||
|
{templateMutation.isLoading ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-spin fa-cog"></i>
|
||||||
|
</span>
|
||||||
|
) : templateRecentlyCreated ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-file-spreadsheet"></i>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<LoadTemplate />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { Modal } from "../../Modal";
|
||||||
|
import { useMutation, useQueryClient } from "react-query";
|
||||||
|
import { useReminder } from "../ReminderContext";
|
||||||
|
import { deleteGuildReminder } from "../../../api";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import { useFlash } from "../../App/FlashContext";
|
||||||
|
|
||||||
|
export const DeleteButton = () => {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="button is-danger delete-reminder"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{modalOpen && <DeleteModal setModalOpen={setModalOpen}></DeleteModal>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteModal = ({ setModalOpen }) => {
|
||||||
|
const [reminder] = useReminder();
|
||||||
|
const { guild } = useParams();
|
||||||
|
|
||||||
|
const flash = useFlash();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const mutation = useMutation({
|
||||||
|
...deleteGuildReminder(guild),
|
||||||
|
onSuccess: () => {
|
||||||
|
flash({
|
||||||
|
message: "Reminder deleted",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["GUILD_REMINDERS", guild],
|
||||||
|
});
|
||||||
|
setModalOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal setModalOpen={setModalOpen} title={"Delete Reminder"}>
|
||||||
|
<>
|
||||||
|
<p>This reminder will be permanently deleted. Are you sure?</p>
|
||||||
|
<br></br>
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<button
|
||||||
|
class="button is-danger"
|
||||||
|
onClick={() => {
|
||||||
|
mutation.mutate(reminder);
|
||||||
|
}}
|
||||||
|
disabled={mutation.isLoading}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-light close-modal"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,88 @@
|
|||||||
|
import { useRef, useState } from "preact/hooks";
|
||||||
|
import { useMutation, useQueryClient } from "react-query";
|
||||||
|
import { patchGuildReminder } from "../../../api";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import { ICON_FLASH_TIME } from "../../../consts";
|
||||||
|
import { DeleteButton } from "./DeleteButton";
|
||||||
|
import { useReminder } from "../ReminderContext";
|
||||||
|
import { useFlash } from "../../App/FlashContext";
|
||||||
|
|
||||||
|
export const EditButtonRow = () => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
const [reminder, setReminder] = useReminder();
|
||||||
|
|
||||||
|
const [recentlySaved, setRecentlySaved] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const iconFlashTimeout = useRef(0);
|
||||||
|
|
||||||
|
const flash = useFlash();
|
||||||
|
const mutation = useMutation({
|
||||||
|
...patchGuildReminder(guild),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["GUILD_REMINDERS", guild],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (iconFlashTimeout.current !== null) {
|
||||||
|
clearTimeout(iconFlashTimeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.errors.length > 0) {
|
||||||
|
setRecentlySaved(false);
|
||||||
|
|
||||||
|
for (const error of response.data.errors) {
|
||||||
|
flash({ message: error, type: "error" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setRecentlySaved(true);
|
||||||
|
|
||||||
|
iconFlashTimeout.current = setTimeout(() => {
|
||||||
|
setRecentlySaved(false);
|
||||||
|
}, ICON_FLASH_TIME);
|
||||||
|
|
||||||
|
setReminder(response.data.reminder);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="button-row-edit">
|
||||||
|
<button
|
||||||
|
class="button is-success save-btn"
|
||||||
|
onClick={() => {
|
||||||
|
mutation.mutate(reminder);
|
||||||
|
}}
|
||||||
|
disabled={mutation.isLoading}
|
||||||
|
>
|
||||||
|
<span>Save</span>{" "}
|
||||||
|
{mutation.isLoading ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-spin fa-cog"></i>
|
||||||
|
</span>
|
||||||
|
) : recentlySaved ? (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button is-warning"
|
||||||
|
onClick={() => {
|
||||||
|
mutation.mutate({
|
||||||
|
...reminder,
|
||||||
|
enabled: !reminder.enabled,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={mutation.isLoading}
|
||||||
|
>
|
||||||
|
{reminder.enabled ? "Disable" : "Enable"}
|
||||||
|
</button>
|
||||||
|
<DeleteButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,32 @@
|
|||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
import { fetchGuildChannels } from "../../api";
|
||||||
|
|
||||||
|
export const ChannelSelector = ({ channel, setChannel }) => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
const { isSuccess, data } = useQuery(fetchGuildChannels(guild));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="control has-icons-left">
|
||||||
|
<div class="select">
|
||||||
|
<select
|
||||||
|
name="channel"
|
||||||
|
class="channel-selector"
|
||||||
|
onInput={(ev) => {
|
||||||
|
setChannel(ev.currentTarget.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSuccess &&
|
||||||
|
data.map((c) => (
|
||||||
|
<option value={c.id} selected={c.id === channel}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="icon is-small is-left">
|
||||||
|
<i class="fas fa-hashtag"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
25
reminder-dashboard/src/components/Reminder/Content.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useReminder } from "./ReminderContext";
|
||||||
|
|
||||||
|
export const Content = () => {
|
||||||
|
const [reminder, setReminder] = useReminder();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label class="is-sr-only">Content</label>
|
||||||
|
<textarea
|
||||||
|
class="message-input autoresize discord-content"
|
||||||
|
placeholder="Content..."
|
||||||
|
maxlength={2000}
|
||||||
|
name="content"
|
||||||
|
rows={1}
|
||||||
|
value={reminder.content}
|
||||||
|
onInput={(ev) => {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
content: ev.currentTarget.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { fetchGuildChannels, Reminder } from "../../api";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { CreateButtonRow } from "./ButtonRow/CreateButtonRow";
|
||||||
|
import { TopBar } from "./TopBar";
|
||||||
|
import { Message } from "./Message";
|
||||||
|
import { Settings } from "./Settings";
|
||||||
|
import { ReminderContext } from "./ReminderContext";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { useParams } from "wouter";
|
||||||
|
|
||||||
|
function defaultReminder(): Reminder {
|
||||||
|
return {
|
||||||
|
attachment: null,
|
||||||
|
attachment_name: null,
|
||||||
|
avatar: null,
|
||||||
|
channel: null,
|
||||||
|
content: "",
|
||||||
|
embed_author: "",
|
||||||
|
embed_author_url: null,
|
||||||
|
embed_color: 0,
|
||||||
|
embed_description: "",
|
||||||
|
embed_fields: [],
|
||||||
|
embed_footer: "",
|
||||||
|
embed_footer_url: null,
|
||||||
|
embed_image_url: null,
|
||||||
|
embed_thumbnail_url: null,
|
||||||
|
embed_title: "",
|
||||||
|
enabled: true,
|
||||||
|
expires: null,
|
||||||
|
interval_days: null,
|
||||||
|
interval_months: null,
|
||||||
|
interval_seconds: null,
|
||||||
|
name: "",
|
||||||
|
restartable: false,
|
||||||
|
tts: false,
|
||||||
|
uid: "",
|
||||||
|
username: "",
|
||||||
|
utc_time: DateTime.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateReminder = () => {
|
||||||
|
const { guild } = useParams();
|
||||||
|
|
||||||
|
const [reminder, setReminder] = useState(defaultReminder());
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const { isSuccess, data: guildChannels } = useQuery(fetchGuildChannels(guild));
|
||||||
|
|
||||||
|
if (isSuccess && reminder.channel === null) {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
channel: reminder.channel || guildChannels[0].id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReminderContext.Provider value={[reminder, setReminder]}>
|
||||||
|
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
|
||||||
|
<TopBar
|
||||||
|
toggleCollapsed={() => {
|
||||||
|
setCollapsed(!collapsed);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="columns reminder-settings">
|
||||||
|
<Message />
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
<CreateButtonRow />
|
||||||
|
</div>
|
||||||
|
</ReminderContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
45
reminder-dashboard/src/components/Reminder/EditReminder.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Reminder } from "../../api";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import { EditButtonRow } from "./ButtonRow/EditButtonRow";
|
||||||
|
import { Message } from "./Message";
|
||||||
|
import { Settings } from "./Settings";
|
||||||
|
import { ReminderContext } from "./ReminderContext";
|
||||||
|
import { TopBar } from "./TopBar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reminder: Reminder;
|
||||||
|
globalCollapse: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Props) => {
|
||||||
|
const [propReminder, setPropReminder] = useState(initialReminder);
|
||||||
|
const [reminder, setReminder] = useState(initialReminder);
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsed(globalCollapse);
|
||||||
|
}, [globalCollapse]);
|
||||||
|
|
||||||
|
// Reminder updated from web response
|
||||||
|
if (propReminder !== initialReminder) {
|
||||||
|
setReminder(initialReminder);
|
||||||
|
setPropReminder(initialReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReminderContext.Provider value={[reminder, setReminder]}>
|
||||||
|
<div class={collapsed ? "reminderContent is-collapsed" : "reminderContent"}>
|
||||||
|
<TopBar
|
||||||
|
toggleCollapsed={() => {
|
||||||
|
setCollapsed(!collapsed);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="columns reminder-settings">
|
||||||
|
<Message />
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
<EditButtonRow />
|
||||||
|
</div>
|
||||||
|
</ReminderContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
50
reminder-dashboard/src/components/Reminder/Embed/Author.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { ImagePicker } from "../ImagePicker";
|
||||||
|
import { Reminder } from "../../../api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
setReminder: (r: (reminder: Reminder) => Reminder) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Author = ({ name, icon, setReminder }: Props) => {
|
||||||
|
return (
|
||||||
|
<div class="embed-author-box">
|
||||||
|
<div class="a">
|
||||||
|
<p class="image is-24x24 customizable">
|
||||||
|
<ImagePicker
|
||||||
|
class="is-rounded embed_author_url"
|
||||||
|
url={icon}
|
||||||
|
alt="Image for embed author"
|
||||||
|
setImage={(url) => {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
embed_author_url: url,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></ImagePicker>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="b">
|
||||||
|
<label class="is-sr-only" for="embedAuthor">
|
||||||
|
Embed Author
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="discord-embed-author message-input autoresize"
|
||||||
|
placeholder="Embed Author..."
|
||||||
|
rows={1}
|
||||||
|
maxlength={256}
|
||||||
|
name="embed_author"
|
||||||
|
value={name}
|
||||||
|
onInput={(ev) => {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
embed_author: ev.currentTarget.value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
68
reminder-dashboard/src/components/Reminder/Embed/Color.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { HexColorPicker } from "react-colorful";
|
||||||
|
import { Modal } from "../../Modal";
|
||||||
|
import { Reminder } from "../../../api";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
color: string;
|
||||||
|
setReminder: (r: (reminder: Reminder) => Reminder) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function colorToInt(hex: string) {
|
||||||
|
return parseInt(hex.substring(1), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Color = ({ color, setReminder }: Props) => {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{modalOpen && (
|
||||||
|
<ColorModal
|
||||||
|
color={color}
|
||||||
|
setModalOpen={setModalOpen}
|
||||||
|
setReminder={setReminder}
|
||||||
|
></ColorModal>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
class="change-color button is-rounded is-small"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="is-sr-only">Choose embed color</span>
|
||||||
|
<i class="fas fa-eye-dropper"></i>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ColorModal = ({ setModalOpen, color, setReminder }) => {
|
||||||
|
return (
|
||||||
|
<Modal setModalOpen={setModalOpen} title={"Select Color"}>
|
||||||
|
<div class="colorpicker-container">
|
||||||
|
<HexColorPicker
|
||||||
|
color={color}
|
||||||
|
onInput={(color) => {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
embed_color: colorToInt(color),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></HexColorPicker>
|
||||||
|
</div>
|
||||||
|
<br></br>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
id="colorInput"
|
||||||
|
value={color}
|
||||||
|
onInput={(ev) => {
|
||||||
|
setReminder((reminder) => ({
|
||||||
|
...reminder,
|
||||||
|
embed_color: colorToInt(ev.currentTarget.value),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
></input>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
export const Description = ({ description, onInput }) => (
|
||||||
|
<>
|
||||||
|
<label class="is-sr-only" for="embedDescription">
|
||||||
|
Embed Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="discord-description message-input autoresize "
|
||||||
|
placeholder="Embed Description..."
|
||||||
|
maxlength={4096}
|
||||||
|
name="embed_description"
|
||||||
|
rows={1}
|
||||||
|
value={description}
|
||||||
|
onInput={(ev) => {
|
||||||
|
onInput(ev.currentTarget.value);
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
</>
|
||||||
|
);
|
@ -0,0 +1,57 @@
|
|||||||
|
export const Field = ({ title, value, inline, index, onUpdate }) => {
|
||||||
|
return (
|
||||||
|
<div data-inlined={inline ? "1" : "0"} class="embed-field-box" key={index}>
|
||||||
|
<label class="is-sr-only" for="embedFieldTitle">
|
||||||
|
Field Title
|
||||||
|
</label>
|
||||||
|
<div class="is-flex">
|
||||||
|
<textarea
|
||||||
|
class="discord-field-title field-input message-input autoresize"
|
||||||
|
placeholder="Field Title..."
|
||||||
|
rows={1}
|
||||||
|
maxlength={256}
|
||||||
|
name="embed_field_title[]"
|
||||||
|
value={title}
|
||||||
|
onInput={(ev) =>
|
||||||
|
onUpdate({
|
||||||
|
index,
|
||||||
|
title: ev.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{(value !== "" || title !== "") && (
|
||||||
|
<button
|
||||||
|
class="button is-small inline-btn"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdate({
|
||||||
|
index,
|
||||||
|
inline: !inline,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="is-sr-only">Toggle field inline</span>
|
||||||
|
<i class="fas fa-arrows-h"></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="is-sr-only" for="embedFieldValue">
|
||||||
|
Field Value
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="discord-field-value field-input message-input autoresize "
|
||||||
|
placeholder="Field Value..."
|
||||||
|
maxlength={1024}
|
||||||
|
name="embed_field_value[]"
|
||||||
|
rows={1}
|
||||||
|
value={value}
|
||||||
|
onInput={(ev) =>
|
||||||
|
onUpdate({
|
||||||
|
index,
|
||||||
|
value: ev.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|