Initial commit
							
								
								
									
										24
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,24 @@ | ||||
| # 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? | ||||
							
								
								
									
										2
									
								
								.prettierrc.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| printWidth = 100 | ||||
| tabWidth = 4 | ||||
							
								
								
									
										62
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,62 @@ | ||||
| <!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> | ||||
| <nav | ||||
| 		class="navbar is-spaced is-size-4 is-hidden-desktop dashboard-navbar" | ||||
| 		role="navigation" | ||||
| 		aria-label="main navigation" | ||||
| > | ||||
| 	<div class="navbar-brand"> | ||||
| 		<a class="navbar-item" href="/"> | ||||
| 			<figure class="image"> | ||||
| 				<img width="28px" height="28px" src="/static/img/logo_nobg.webp" alt="Reminder Bot Logo"> | ||||
| 			</figure> | ||||
| 		</a> | ||||
|  | ||||
| 		<p class="navbar-item pageTitle"></p> | ||||
|  | ||||
| 		<a | ||||
| 			role="button" | ||||
| 			class="dashboard-burger navbar-burger is-right" | ||||
| 			aria-label="menu" | ||||
| 			aria-expanded="false" | ||||
| 			data-target="mobileSidebar" | ||||
| 		> | ||||
| 			<span aria-hidden="true"></span> | ||||
| 			<span aria-hidden="true"></span> | ||||
| 			<span aria-hidden="true"></span> | ||||
| 		</a> | ||||
| 	</div> | ||||
| </nav> | ||||
| 	<div id="app"></div> | ||||
| 	<script type="module" src="/src/index.tsx"></script> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										4697
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										29
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,29 @@ | ||||
| { | ||||
| 	"name": "example", | ||||
| 	"private": true, | ||||
| 	"type": "module", | ||||
| 	"scripts": { | ||||
| 		"dev": "vite", | ||||
| 		"build": "vite build", | ||||
| 		"preview": "vite preview" | ||||
| 	}, | ||||
| 	"dependencies": { | ||||
| 		"air-datepicker": "^3.4.0", | ||||
| 		"axios": "^1.5.1", | ||||
| 		"bulma": "^0.9.4", | ||||
| 		"luxon": "^3.4.3", | ||||
| 		"preact": "^10.13.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", | ||||
| 		"typescript": "^5.2.2", | ||||
| 		"vite": "^4.3.2" | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										1
									
								
								public/static/css/bulma.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										91
									
								
								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
									
								
								public/static/css/fa.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										63
									
								
								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
									
								
								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; | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/android-chrome-192x192.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/android-chrome-512x512.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.8 KiB | 
							
								
								
									
										9
									
								
								public/static/favicon/browserconfig.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -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
										
									
								
								public/static/favicon/favicon-16x16.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/favicon-32x32.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/favicon.ico
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 7.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/favicon/mstile-150x150.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/bg.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 762 B | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/logo_flat.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 323 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/logo_flat.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 61 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/logo_nobg.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 81 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/slash-commands.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 23 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 35 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 55 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/3.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/cancel-1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/cancel-2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/cmd-1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 24 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/delete_reminder/cmd-2.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/iemanager/edit_spreadsheet.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/iemanager/format_text.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 40 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/iemanager/import.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 44 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/iemanager/select_export.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 18 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/support/iemanager/sheets_settings.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 20 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/img/tournament-demo.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 65 KiB | 
							
								
								
									
										131
									
								
								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
									
								
								public/static/js/chart.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										7
									
								
								public/static/js/chartjs-adapter-luxon.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
									
								
								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
									
								
								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
									
								
								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
									
								
								public/static/js/iro.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										2
									
								
								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
									
								
								public/static/js/luxon.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										1087
									
								
								public/static/js/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										16
									
								
								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
									
								
								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
									
								
								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
									
								
								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
										
									
								
								public/static/webfonts/fa-brands-400.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										3633
									
								
								public/static/webfonts/fa-brands-400.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 712 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-brands-400.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-brands-400.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-brands-400.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-duotone-900.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										15055
									
								
								public/static/webfonts/fa-duotone-900.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.5 MiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-duotone-900.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-duotone-900.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-duotone-900.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-light-300.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										12330
									
								
								public/static/webfonts/fa-light-300.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.3 MiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-light-300.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-light-300.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-light-300.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-regular-400.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										11256
									
								
								public/static/webfonts/fa-regular-400.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.1 MiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-regular-400.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-regular-400.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-regular-400.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-solid-900.eot
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										9588
									
								
								public/static/webfonts/fa-solid-900.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 MiB | 
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-solid-900.ttf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-solid-900.woff
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										
											BIN
										
									
								
								public/static/webfonts/fa-solid-900.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										20
									
								
								src/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | ||||
| import axios from "axios"; | ||||
|  | ||||
| type UserInfo = { | ||||
|     name: string; | ||||
|     patreon: boolean; | ||||
|     timezone: string | null; | ||||
| }; | ||||
|  | ||||
| export type GuildInfo = { | ||||
|     id: string; | ||||
|     name: string; | ||||
| }; | ||||
|  | ||||
| export function fetchUserInfo(): Promise<UserInfo> { | ||||
|     return axios.get("/api/user").then((resp) => resp.data) as Promise<UserInfo>; | ||||
| } | ||||
|  | ||||
| export function fetchUserGuilds(): Promise<GuildInfo[]> { | ||||
|     return axios.get("/api/user/guilds").then((resp) => resp.data) as Promise<GuildInfo[]>; | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/components/App/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,32 @@ | ||||
| import { Sidebar } from "../Sidebar"; | ||||
| import { QueryClient, QueryClientProvider } from "react-query"; | ||||
| import { Route, Router, Switch } from "wouter"; | ||||
| import { Welcome } from "../Welcome"; | ||||
| import { GuildReminders } from "../GuildReminders"; | ||||
|  | ||||
| export function App() { | ||||
|     const queryClient = new QueryClient(); | ||||
|  | ||||
|     return ( | ||||
|         <QueryClientProvider client={queryClient}> | ||||
|             <> | ||||
|                 <Router base={"/dashboard"}> | ||||
|                     <div class="columns is-gapless dashboard-frame"> | ||||
|                         <Sidebar></Sidebar> | ||||
|                         <div class="column is-main-content"> | ||||
|                             <Switch> | ||||
|                                 <Route | ||||
|                                     path={"/:guild/reminders"} | ||||
|                                     component={GuildReminders} | ||||
|                                 ></Route> | ||||
|                                 <Route> | ||||
|                                     <Welcome></Welcome> | ||||
|                                 </Route> | ||||
|                             </Switch> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </Router> | ||||
|             </> | ||||
|         </QueryClientProvider> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										7
									
								
								src/components/GuildReminders/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| import { useParams } from "wouter"; | ||||
|  | ||||
| export const GuildReminders = () => { | ||||
|     const params = useParams(); | ||||
|  | ||||
|     return <>{params.guild}</>; | ||||
| }; | ||||
							
								
								
									
										11
									
								
								src/components/Sidebar/Brand.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| export const Brand = () => ( | ||||
|     <div class="brand"> | ||||
|         <img | ||||
|             src="/static/img/logo_nobg.webp" | ||||
|             alt="Reminder bot logo" | ||||
|             width="52px" | ||||
|             height="52px" | ||||
|             class="dashboard-brand" | ||||
|         ></img> | ||||
|     </div> | ||||
| ); | ||||
							
								
								
									
										5
									
								
								src/components/Sidebar/DesktopSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| export const DesktopSidebar = ({ children }) => { | ||||
|     return ( | ||||
|         <div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch">{children}</div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										28
									
								
								src/components/Sidebar/GuildEntry.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,28 @@ | ||||
| import { GuildInfo } from "../../api"; | ||||
| import { Link, useLocation } from "wouter"; | ||||
|  | ||||
| type Props = { | ||||
|     guild: GuildInfo; | ||||
| }; | ||||
|  | ||||
| export const GuildEntry = ({ guild }: Props) => { | ||||
|     const [loc] = useLocation(); | ||||
|     const currentId = loc.match(/^\/(?<id>\d+)/); | ||||
|  | ||||
|     return ( | ||||
|         <li> | ||||
|             <Link | ||||
|                 class={guild.id === currentId.groups.id ? "is-active switch-pane" : "switch-pane"} | ||||
|                 data-pane="guild" | ||||
|                 data-guild={guild.id} | ||||
|                 data-name={guild.name} | ||||
|                 href={`/${guild.id}/reminders`} | ||||
|             > | ||||
|                 <span class="icon"> | ||||
|                     <i class="fas fa-map-pin"></i> | ||||
|                 </span>{" "} | ||||
|                 <span class="guild-name">{guild.name}</span> | ||||
|             </Link> | ||||
|         </li> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										7
									
								
								src/components/Sidebar/MobileSidebar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| export const MobileSidebar = ({ children }) => { | ||||
|     return ( | ||||
|         <div class="dashboard-sidebar mobile-sidebar is-hidden-desktop" id="mobileSidebar"> | ||||
|             {children} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										11
									
								
								src/components/Sidebar/Wave.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| export const Wave = () => ( | ||||
|     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 160"> | ||||
|         <g transform="scale(1, 0.5)"> | ||||
|             <path | ||||
|                 fill="#8fb677" | ||||
|                 fill-opacity="1" | ||||
|                 d="M0,192L60,170.7C120,149,240,107,360,96C480,85,600,107,720,138.7C840,171,960,213,1080,197.3C1200,181,1320,107,1380,69.3L1440,32L1440,0L1380,0C1320,0,1200,0,1080,0C960,0,840,0,720,0C600,0,480,0,360,0C240,0,120,0,60,0L0,0Z" | ||||
|             ></path> | ||||
|         </g> | ||||
|     </svg> | ||||
| ); | ||||
							
								
								
									
										80
									
								
								src/components/Sidebar/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,80 @@ | ||||
| import { useQuery } from "react-query"; | ||||
| import { DesktopSidebar } from "./DesktopSidebar"; | ||||
| import { MobileSidebar } from "./MobileSidebar"; | ||||
| import { Brand } from "./Brand"; | ||||
| import { Wave } from "./Wave"; | ||||
| import { GuildEntry } from "./GuildEntry"; | ||||
| import { fetchUserGuilds, GuildInfo } from "../../api"; | ||||
| import { QueryKeys } from "../../consts"; | ||||
|  | ||||
| type ContentProps = { | ||||
|     guilds: GuildInfo[]; | ||||
| }; | ||||
|  | ||||
| const SidebarContent = ({ guilds }: ContentProps) => { | ||||
|     const guildEntries = guilds.map((guild) => <GuildEntry guild={guild}></GuildEntry>); | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <a href="/"> | ||||
|                 <Brand></Brand> | ||||
|             </a> | ||||
|             <Wave></Wave> | ||||
|             <aside class="menu"> | ||||
|                 <p class="menu-label">Servers</p> | ||||
|                 <ul class="menu-list guildList">{guildEntries}</ul> | ||||
|                 <div class="aside-footer"> | ||||
|                     <p class="menu-label">Options</p> | ||||
|                     <ul class="menu-list"> | ||||
|                         <li> | ||||
|                             <a class="show-modal" data-modal="dataManagerModal"> | ||||
|                                 <span class="icon"> | ||||
|                                     <i class="fas fa-exchange"></i> | ||||
|                                 </span>{" "} | ||||
|                                 Import/Export | ||||
|                             </a> | ||||
|                             <a class="show-modal" data-modal="chooseTimezoneModal"> | ||||
|                                 <span class="icon"> | ||||
|                                     <i class="fas fa-map-marked"></i> | ||||
|                                 </span>{" "} | ||||
|                                 Timezone | ||||
|                             </a> | ||||
|                             <a href="/login/discord/logout"> | ||||
|                                 <span class="icon"> | ||||
|                                     <i class="fas fa-sign-out"></i> | ||||
|                                 </span>{" "} | ||||
|                                 Log out | ||||
|                             </a> | ||||
|                             <a href="https://discord.jellywx.com" class="feedback"> | ||||
|                                 <span class="icon"> | ||||
|                                     <i class="fab fa-discord"></i> | ||||
|                                 </span>{" "} | ||||
|                                 Give feedback | ||||
|                             </a> | ||||
|                         </li> | ||||
|                     </ul> | ||||
|                 </div> | ||||
|             </aside> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const Sidebar = () => { | ||||
|     const { status, data } = useQuery({ | ||||
|         queryKey: [QueryKeys.USER_GUILDS], | ||||
|         queryFn: fetchUserGuilds, | ||||
|         staleTime: Infinity, | ||||
|     }); | ||||
|  | ||||
|     let content = <SidebarContent guilds={[]}></SidebarContent>; | ||||
|     if (status === "success") { | ||||
|         content = <SidebarContent guilds={data}></SidebarContent>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <DesktopSidebar>{content}</DesktopSidebar> | ||||
|             <MobileSidebar>{content}</MobileSidebar> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										99
									
								
								src/components/TimezonePicker/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,99 @@ | ||||
| import { DateTime } from "luxon"; | ||||
| import { useQuery } from "react-query"; | ||||
| import { QueryKeys } from "../../consts"; | ||||
| import { fetchUserInfo } from "../../api"; | ||||
|  | ||||
| type DisplayProps = { | ||||
|     timezone: string; | ||||
| }; | ||||
|  | ||||
| const TimezoneDisplay = ({ timezone }: DisplayProps) => { | ||||
|     const now = DateTime.now().setZone(timezone); | ||||
|  | ||||
|     const hour = now.hour; | ||||
|     const minute = now.minute; | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <strong> | ||||
|                 <span class="set-timezone">{timezone}</span> | ||||
|             </strong>{" "} | ||||
|             ( | ||||
|             <span class="set-time"> | ||||
|                 {hour}:{minute} | ||||
|             </span> | ||||
|             ) | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| const TimezonePicker = () => { | ||||
|     const browserTimezone = DateTime.now().zone.name; | ||||
|  | ||||
|     const { isLoading, isError, data } = useQuery({ | ||||
|         queryKey: QueryKeys.USER_DATA, | ||||
|         queryFn: fetchUserInfo, | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|         <div class="modal" id="chooseTimezoneModal"> | ||||
|             <div class="modal-background"></div> | ||||
|             <div class="modal-card"> | ||||
|                 <header class="modal-card-head"> | ||||
|                     <label class="modal-card-title" for="urlInput"> | ||||
|                         Update Timezone{" "} | ||||
|                         <a href="/help/timezone"> | ||||
|                             <span> | ||||
|                                 <i class="fa fa-question-circle"></i> | ||||
|                             </span> | ||||
|                         </a> | ||||
|                     </label> | ||||
|                     <button class="delete close-modal" aria-label="close"></button> | ||||
|                 </header> | ||||
|                 <section class="modal-card-body"> | ||||
|                     <p> | ||||
|                         Your configured timezone is:{" "} | ||||
|                         <TimezoneDisplay timezone={browserTimezone}></TimezoneDisplay> | ||||
|                         <br></br> | ||||
|                         <br></br> | ||||
|                         Your browser timezone is:{" "} | ||||
|                         <TimezoneDisplay timezone={browserTimezone}></TimezoneDisplay> | ||||
|                         <br></br> | ||||
|                         {!isError && ( | ||||
|                             <> | ||||
|                                 Your bot timezone is:{" "} | ||||
|                                 {isLoading ? ( | ||||
|                                     <i className="fas fa-cog fa-spin"></i> | ||||
|                                 ) : ( | ||||
|                                     <TimezoneDisplay | ||||
|                                         timezone={data.timezone || "UTC"} | ||||
|                                     ></TimezoneDisplay> | ||||
|                                 )} | ||||
|                             </> | ||||
|                         )} | ||||
|                     </p> | ||||
|  | ||||
|                     <br></br> | ||||
|                     <div class="has-text-centered"> | ||||
|                         <button class="button is-success close-modal" id="set-browser-timezone"> | ||||
|                             <span>Use Browser Timezone</span>{" "} | ||||
|                             <span class="icon"> | ||||
|                                 <i class="fab fa-firefox-browser"></i> | ||||
|                             </span> | ||||
|                         </button> | ||||
|                         <button class="button is-link close-modal" id="set-bot-timezone"> | ||||
|                             <span>Use Bot Timezone</span>{" "} | ||||
|                             <span class="icon"> | ||||
|                                 <i class="fab fa-discord"></i> | ||||
|                             </span> | ||||
|                         </button> | ||||
|                         <button class="button is-warning close-modal" id="update-bot-timezone"> | ||||
|                             Set Bot Timezone | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </section> | ||||
|             </div> | ||||
|             <button class="modal-close is-large close-modal" aria-label="close"></button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										15
									
								
								src/components/Welcome/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| export const Welcome = () => ( | ||||
|     <section id="welcome"> | ||||
|         <div class="has-text-centered"> | ||||
|             <p class="title">Welcome!</p> | ||||
|             <p class="subtitle is-hidden-touch">Select an option from the side to get started</p> | ||||
|             <p class="subtitle is-hidden-desktop"> | ||||
|                 Press the{" "} | ||||
|                 <span class="icon"> | ||||
|                     <i class="fal fa-bars"></i> | ||||
|                 </span>{" "} | ||||
|                 to get started | ||||
|             </p> | ||||
|         </div> | ||||
|     </section> | ||||
| ); | ||||
							
								
								
									
										4
									
								
								src/consts.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| export enum QueryKeys { | ||||
|     USER_DATA = "userData", | ||||
|     USER_GUILDS = "userGuilds", | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| import { render } from "preact"; | ||||
| import { App } from "./components/App"; | ||||
|  | ||||
| render(<App />, document.getElementById("app")); | ||||
							
								
								
									
										13
									
								
								tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| { | ||||
| 	"compilerOptions": { | ||||
| 		"target": "ES2020", | ||||
| 		"module": "ESNext", | ||||
| 		"moduleResolution": "bundler", | ||||
| 		"noEmit": true, | ||||
| 		"allowJs": true, | ||||
| 		"checkJs": true, | ||||
| 		"jsx": "react-jsx", | ||||
| 		"jsxImportSource": "preact" | ||||
| 	}, | ||||
| 	"include": ["node_modules/vite/client.d.ts", "**/*"] | ||||
| } | ||||
							
								
								
									
										10
									
								
								vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| import { defineConfig } from "vite"; | ||||
| import preact from "@preact/preset-vite"; | ||||
|  | ||||
| // https://vitejs.dev/config/ | ||||
| export default defineConfig({ | ||||
|     plugins: [preact()], | ||||
|     build: { | ||||
|         assetsDir: "static/assets", | ||||
|     }, | ||||
| }); | ||||