diff options
Diffstat (limited to 'pse-dashboard/src')
38 files changed, 2614 insertions, 0 deletions
diff --git a/pse-dashboard/src/App.vue b/pse-dashboard/src/App.vue new file mode 100644 index 0000000..bd387ff --- /dev/null +++ b/pse-dashboard/src/App.vue @@ -0,0 +1,13 @@ +<script setup> +import { ErrorLog, NavBar, HelpModal } from '@/components'; +</script> + +<template> + <NavBar /> + <router-view /> + <HelpModal /> + <ErrorLog /> +</template> + +<style scoped> +</style> diff --git a/pse-dashboard/src/api/gpodder.js b/pse-dashboard/src/api/gpodder.js new file mode 100644 index 0000000..057e5a7 --- /dev/null +++ b/pse-dashboard/src/api/gpodder.js @@ -0,0 +1,97 @@ +import axios from 'axios'; + +// export default function useGpodder({ +export default function useGPodder({ + baseURL, + throwHandler = (err) => err, + gPodderUser, + useCredentials + }) { + + const gpodder = axios.create({ + baseURL, + credentials: useCredentials ? "include" : "omit", + headers: { + 'Content-Type': 'application/json', + } + }); + + let auth = { + username: gPodderUser?.username || "", + password: gPodderUser?.password || "" + }; + + gpodder.interceptors.response.use((response) => response, (error) => { + // whatever you want to do with the error + throwHandler(error); + throw error; + }); + + return { + + /******************************************************************************/ + /* Authentication API */ + /******************************************************************************/ + + async register(gPodderUser) { + return gpodder.post(`/api/2/auth/register.json`, gPodderUser); + }, + + async login(gPodderUserData) { + auth = gPodderUserData; + gPodderUser = gPodderUserData + + return gpodder.post(`/api/2/auth/${gPodderUser.username}/login.json`, {}, {auth}); + }, + + async logout() { + return gpodder.post(`/api/2/auth/${gPodderUser.username}/logout.json`, {}, {auth}); + }, + + async changePassword(passwordChange) { + return gpodder.put(`/api/2/auth/${gPodderUser.username}/changepassword.json`, passwordChange, {auth}); + }, + + async forgotPassword({email}) { + return gpodder.post(`/api/2/auth/${email}/forgot.json`, {}); + }, + + // no auth! + async resetPassword({username, password, token}) { + return gpodder.put(`/api/2/auth/${username}/resetpassword.json?token=${token}`, {password}); + }, + + async deleteAccount(passwordData) { + return gpodder.delete(`/api/2/auth/${gPodderUser.username}/delete.json`, {auth, data: passwordData}); + }, + + /******************************************************************************/ + /* Subscription API */ + /******************************************************************************/ + + async getTitles() { + return gpodder.get(`/subscriptions/titles/${gPodderUser.username}.json`, {auth}); + }, + + async putSubscriptions(subscriptions) { + return gpodder.put(`/subscriptions/${gPodderUser.username}/device.json`, subscriptions, {auth}); + }, + + async postSubscriptions(subscriptions) { + return gpodder.post(`/api/2/subscriptions/${gPodderUser.username}/device.json`, subscriptions, {auth}); + }, + + /******************************************************************************/ + /* EpisodeActions API */ + /******************************************************************************/ + + async getEpisodeActions() { + return gpodder.get(`/api/2/episodes/${gPodderUser.username}.json`, {auth}); + }, + + async postEpisodeActions(episodeActions) { + return gpodder.post(`/api/2/episodes/${gPodderUser.username}.json`, episodeActions, {auth}); + }, + } +} + diff --git a/pse-dashboard/src/api/gpodder.test.js b/pse-dashboard/src/api/gpodder.test.js new file mode 100644 index 0000000..bf88ddd --- /dev/null +++ b/pse-dashboard/src/api/gpodder.test.js @@ -0,0 +1,29 @@ +import * as GPodder from './gpodder.js' + +const user = new GPodder.GPodderUser({ + username: "iam@not.real", + password: "12345678aB@" +}); + +console.log(user) +console.log(user.username, user.password); + +GPodder.init({ + baseURL: "http://localhost:8080", + throwHandler: err => err +}) + +async function testGPodder() { + const register = await GPodder.register(user); + console.log(register.status); + + const login = await GPodder.login(user) + console.log(login.status, login.headers); + + const response = await GPodder.getTitles(); + const json = await response.text(); + console.log(`${response.status} "${json}"`); + +} +testGPodder(); + diff --git a/pse-dashboard/src/api/pse-squared.js b/pse-dashboard/src/api/pse-squared.js new file mode 100644 index 0000000..641542b --- /dev/null +++ b/pse-dashboard/src/api/pse-squared.js @@ -0,0 +1,61 @@ +import useGPodder from '@/api/gpodder.js' +import { useLogger } from '@/logger.js' + +const logger = useLogger(); + +function errorHandler(error) { + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + console.log(error.response.data); + console.log(error.response.status); + console.log(error.response.headers); + + switch (error.response.status) { + case 400: logger.badRequestError(); break; + case 401: logger.unauthorizedError(); break; + case 404: logger.notFoundError(); break; + } + + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + logger.connectionLostError() + + console.log(error.request); + } else { + // Something happened in setting up the request that triggered an Error + console.log('Error', error.message); + logger.append({ + type: "danger", + message: err.message + }); + } +} + +const backendURL = import.meta.env.VITE_BACKEND_URL || "http://localhost:8080"; +console.log("Backend-URL", backendURL); + +const pseSquared = useGPodder({ + // baseURL: process.env.VUE_APP_BASE_URL || "http://localhost:8080", + // baseURL: "http://api.pse-squared.de", + baseURL: backendURL, + throwHandler: error => errorHandler(error) +}); + +export const { + changePassword, + deleteAccount, + forgotPassword, + getEpisodeActions, + getTitles, + login, + logout, + postEpisodeActions, + postSubscriptions, + putSubscriptions, + register, + resetPassword, +} = pseSquared; + diff --git a/pse-dashboard/src/assets/logo.svg b/pse-dashboard/src/assets/logo.svg new file mode 100644 index 0000000..1609066 --- /dev/null +++ b/pse-dashboard/src/assets/logo.svg @@ -0,0 +1,211 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg + version="1.2" + width="87.589989mm" + height="52.16547mm" + viewBox="0 0 8758.9989 5216.547" + preserveAspectRatio="xMidYMid" + fill-rule="evenodd" + stroke-width="28.222" + stroke-linejoin="round" + xml:space="preserve" + id="svg206" + xmlns="http://www.w3.org/2000/svg" + xmlns:svg="http://www.w3.org/2000/svg"> + <defs + class="ClipPathGroup" + id="defs8" /> + <defs + id="defs51"><font + id="EmbeddedFont_1" + horiz-adv-x="2048" + horiz-origin-x="0" + horiz-origin-y="0" + vert-origin-x="512" + vert-origin-y="768" + vert-adv-y="1024"> + <font-face + font-family="Noto Sans Display Light embedded" + units-per-em="2048" + font-weight="normal" + font-style="normal" + ascent="2170" + descent="609" + id="font-face10" /> + <missing-glyph + horiz-adv-x="2048" + d="M 0,0 L 2047,0 2047,2047 0,2047 0,0 Z" + id="missing-glyph12" /> + <glyph + unicode="y" + horiz-adv-x="927" + d="M 2,1089 L 127,1089 367,413 C 388,352 407,298 422,250 437,203 449,161 457,124 L 463,124 C 471,156 483,196 498,246 513,296 530,350 551,409 L 786,1089 913,1089 453,-193 C 418,-290 377,-364 328,-416 279,-468 213,-494 131,-494 107,-494 84,-493 63,-490 43,-487 25,-482 8,-476 L 8,-377 C 23,-383 40,-387 57,-391 75,-394 95,-396 117,-396 170,-396 214,-378 249,-342 284,-307 314,-252 340,-179 L 403,4 2,1089 Z" + id="glyph14" /> + <glyph + unicode="t" + horiz-adv-x="610" + d="M 465,80 C 494,80 521,82 547,87 573,91 595,97 614,105 L 614,11 C 594,3 569,-5 541,-11 512,-17 481,-20 449,-20 363,-20 296,5 249,56 202,106 178,189 178,304 L 178,996 27,996 27,1061 178,1100 219,1350 295,1350 295,1090 608,1090 608,996 295,996 295,310 C 295,157 352,80 465,80 Z" + id="glyph16" /> + <glyph + unicode="s" + horiz-adv-x="742" + d="M 817,289 C 817,191 782,115 713,61 643,7 545,-20 418,-20 347,-20 284,-14 228,-1 173,12 126,29 88,50 L 88,162 C 134,138 186,118 244,102 301,86 360,78 420,78 520,78 592,96 636,133 680,169 702,218 702,281 702,341 679,387 632,419 585,451 515,484 422,519 359,542 303,565 255,589 207,613 169,643 142,680 116,717 102,768 102,832 102,919 136,987 204,1037 271,1086 361,1110 473,1110 535,1110 592,1104 646,1092 700,1080 750,1063 795,1043 L 754,946 C 713,964 667,980 617,993 568,1006 518,1012 469,1012 388,1012 326,997 283,967 239,937 217,893 217,836 217,792 228,758 249,733 270,707 301,686 342,668 383,650 433,630 492,609 553,585 608,562 657,537 707,512 745,481 774,443 803,405 817,353 817,289 Z" + id="glyph18" /> + <glyph + unicode="r" + horiz-adv-x="583" + d="M 596,1108 C 646,1108 692,1102 733,1091 L 717,983 C 674,995 632,1001 590,1001 497,1001 423,964 368,890 312,817 285,719 285,598 L 285,-1 168,-1 168,1089 266,1089 279,886 285,886 C 311,948 350,1000 402,1043 455,1086 520,1108 596,1108 Z" + id="glyph20" /> + <glyph + unicode="o" + horiz-adv-x="927" + d="M 1030,547 C 1030,433 1012,333 976,248 940,164 887,98 818,51 749,4 665,-20 565,-20 471,-20 390,3 322,50 253,96 201,162 164,247 127,333 109,433 109,547 109,723 150,861 232,961 315,1061 429,1110 575,1110 672,1110 755,1087 822,1040 890,993 941,927 977,842 1012,757 1030,659 1030,547 Z M 229,547 C 229,407 257,294 312,208 368,123 453,80 567,80 685,80 771,123 826,209 882,295 909,408 909,547 909,637 898,717 875,787 851,856 815,911 766,951 717,990 653,1010 573,1010 459,1010 373,969 315,887 258,805 229,692 229,547 Z" + id="glyph22" /> + <glyph + unicode="n" + horiz-adv-x="847" + d="M 633,1110 C 749,1110 838,1078 900,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1090 262,1090 279,901 287,901 C 314,962 357,1011 416,1051 474,1091 547,1110 633,1110 Z" + id="glyph24" /> + <glyph + unicode="m" + horiz-adv-x="1430" + d="M 1245,1110 C 1348,1110 1427,1080 1485,1018 1542,957 1571,860 1571,727 L 1571,1 1454,1 1454,723 C 1454,820 1434,892 1393,939 1352,986 1296,1010 1227,1010 1130,1010 1056,980 1005,919 953,858 928,764 928,637 L 928,1 811,1 811,723 C 811,820 791,892 750,939 709,986 653,1010 584,1010 487,1010 413,978 361,914 310,850 285,751 285,619 L 285,1 168,1 168,1090 262,1090 279,918 287,918 C 313,971 352,1016 403,1054 455,1092 521,1110 600,1110 675,1110 739,1093 791,1059 842,1025 879,975 899,908 L 907,908 C 936,972 980,1022 1038,1057 1097,1093 1166,1110 1245,1110 Z" + id="glyph26" /> + <glyph + unicode="i" + horiz-adv-x="187" + d="M 227,1493 C 279,1493 305,1464 305,1405 305,1345 279,1315 227,1315 175,1315 150,1345 150,1405 150,1464 175,1493 227,1493 Z M 285,1090 L 285,0 168,0 168,1090 285,1090 Z" + id="glyph28" /> + <glyph + unicode="h" + horiz-adv-x="847" + d="M 285,1059 C 285,1031 284,1003 283,977 281,951 279,926 276,901 L 285,901 C 312,962 355,1011 413,1051 471,1091 543,1110 629,1110 746,1110 836,1078 899,1014 962,950 993,850 993,713 L 993,1 877,1 877,705 C 877,809 854,885 810,935 766,985 701,1010 616,1010 395,1010 285,871 285,594 L 285,1 168,1 168,1557 285,1557 285,1059 Z" + id="glyph30" /> + <glyph + unicode="f" + horiz-adv-x="689" + d="M 575,995 L 332,995 332,-1 213,-1 213,995 27,995 27,1058 213,1093 213,1202 C 213,1445 316,1566 522,1566 559,1566 593,1563 623,1557 653,1551 680,1544 705,1536 L 678,1439 C 655,1448 630,1454 603,1460 577,1465 550,1468 524,1468 456,1468 407,1447 377,1405 347,1362 332,1295 332,1202 L 332,1089 575,1089 575,995 Z" + id="glyph32" /> + <glyph + unicode="e" + horiz-adv-x="874" + d="M 559,1110 C 646,1110 720,1089 779,1046 839,1003 883,944 913,869 943,794 958,708 958,611 L 958,531 229,531 C 231,386 262,275 325,198 387,121 476,82 592,82 656,82 712,88 759,100 806,111 858,130 915,156 L 915,50 C 865,25 814,7 764,-4 713,-15 655,-20 588,-20 434,-20 315,30 232,129 150,229 109,365 109,537 109,648 126,746 162,832 197,918 249,986 315,1036 382,1085 464,1110 559,1110 Z M 559,1012 C 465,1012 389,979 333,912 276,845 243,750 233,627 L 838,627 C 838,742 815,835 769,906 723,977 653,1012 559,1012 Z" + id="glyph34" /> + <glyph + unicode="d" + horiz-adv-x="900" + d="M 535,-20 C 398,-20 293,27 219,120 145,214 109,352 109,535 109,722 147,865 224,963 301,1061 408,1110 545,1110 629,1110 698,1090 752,1050 805,1010 845,961 872,904 L 881,904 C 879,935 878,970 876,1009 873,1048 872,1084 872,1117 L 872,1557 989,1557 989,0 895,0 879,191 872,191 C 845,132 805,82 751,41 697,0 625,-20 535,-20 Z M 553,80 C 669,80 752,119 801,195 850,271 875,382 875,527 L 875,545 C 875,695 850,810 801,890 752,970 671,1010 559,1010 451,1010 369,969 313,886 257,804 229,686 229,533 229,385 256,273 309,196 363,119 444,80 553,80 Z" + id="glyph36" /> + <glyph + unicode="c" + horiz-adv-x="768" + d="M 580,-20 C 429,-20 313,29 231,127 150,226 109,363 109,539 109,662 129,766 170,850 211,935 269,1000 343,1044 417,1088 504,1110 602,1110 651,1110 698,1106 742,1096 787,1087 825,1074 858,1057 L 825,957 C 791,972 754,984 714,993 673,1002 636,1006 600,1006 481,1006 390,964 326,881 261,798 229,685 229,541 229,405 258,294 314,210 371,126 459,84 580,84 630,84 678,90 723,101 768,112 809,125 846,142 L 846,37 C 812,20 773,6 729,-5 685,-15 636,-20 580,-20 Z" + id="glyph38" /> + <glyph + unicode="a" + horiz-adv-x="822" + d="M 535,1108 C 651,1108 737,1078 795,1018 852,958 881,863 881,734 L 881,0 793,0 772,185 766,185 C 729,123 684,74 631,36 578,-1 503,-20 408,-20 311,-20 233,6 176,59 119,111 90,187 90,285 90,394 132,477 215,533 298,589 420,621 580,629 L 764,639 764,715 C 764,822 744,897 705,942 665,987 606,1010 528,1010 477,1010 426,1002 378,987 329,972 281,953 231,928 L 195,1022 C 242,1047 295,1067 352,1084 410,1100 470,1108 535,1108 Z M 594,543 C 466,536 370,512 307,470 244,429 213,367 213,285 213,217 232,165 271,131 310,96 363,78 430,78 535,78 617,111 676,176 735,240 764,330 764,445 L 764,551 594,543 Z" + id="glyph40" /> + <glyph + unicode="S" + horiz-adv-x="875" + d="M 956,381 C 956,294 936,220 894,160 852,100 796,55 724,25 652,-5 571,-20 479,-20 396,-20 324,-14 262,-2 201,11 147,26 102,46 L 102,162 C 152,142 209,124 273,109 338,94 409,87 485,87 589,87 673,110 738,158 803,206 836,278 836,373 836,431 823,478 798,515 772,553 734,586 682,614 630,642 565,670 485,699 410,726 345,757 291,791 236,825 194,868 164,919 134,970 119,1035 119,1112 119,1192 138,1259 176,1314 214,1369 267,1411 333,1440 399,1469 474,1483 559,1483 626,1483 689,1476 750,1463 810,1449 868,1430 924,1405 L 883,1303 C 772,1352 663,1377 555,1377 462,1377 386,1354 328,1310 269,1266 240,1200 240,1114 240,1052 252,1001 278,964 303,926 340,895 389,869 438,843 498,817 567,791 648,762 717,731 775,698 833,664 878,623 909,573 941,523 956,459 956,381 Z" + id="glyph42" /> + <glyph + unicode="P" + horiz-adv-x="848" + d="M 539,1462 C 869,1462 1034,1325 1034,1049 1034,908 992,798 907,718 823,638 690,598 510,598 L 311,598 311,0 193,0 193,1462 539,1462 Z M 528,1358 L 311,1358 311,702 498,702 C 629,702 730,727 803,776 875,825 911,914 911,1043 911,1152 880,1232 818,1282 756,1333 659,1358 528,1358 Z" + id="glyph44" /> + <glyph + unicode="E" + horiz-adv-x="769" + d="M 950,0 L 193,0 193,1462 950,1462 950,1356 311,1356 311,821 913,821 913,715 311,715 311,107 950,107 950,0 Z" + id="glyph46" /> + <glyph + unicode=" " + horiz-adv-x="503" + id="glyph48" /> + </font></defs> + <defs + class="TextShapeIndex" + id="defs55" /> + <defs + class="EmbeddedBulletChars" + id="defs87" /> + + <g + id="id10" + clip-path="none" + transform="translate(-700.00001,-2550)"> + + <text + class="SVGTextShape" + id="text151" + x="217.60002" + y="-56.506969" + style="letter-spacing:4.7625px;word-spacing:104.775px"><tspan + class="TextParagraph" + font-family="'Noto Sans Display Light', sans-serif" + font-size="494px" + font-weight="400" + id="tspan149"><tspan + class="TextPosition" + x="650.59998" + y="7647.4932" + id="tspan147"><tspan + fill="#808080" + stroke="none" + style="white-space:pre" + id="tspan145" + dx="2.1199999">Podcast Synchronisation made Efficient</tspan></tspan></tspan></text> + </g><g + id="g1277" + transform="translate(-700.00001,-2550)"><path + id="path131-3" + d="m 6694.0006,4763 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1166 989.0352,986.0379 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.1649 -719.0259,-748.0163 12.7874,-421.0793 236.9242,-746.3999 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.9957,0 V 4763 Z" + style="fill:#0084d1;fill-opacity:1" + clip-path="none" /><path + id="path131" + d="m 6685.0183,2562.996 c -559.5515,4.407 -980.3924,428.0893 -986.038,985.9863 18.2894,552.8957 454.3127,974.1167 989.0352,986.038 v -235.0244 c -406.8751,-27.3888 -715.1078,-362.165 -719.0259,-748.0164 12.7874,-421.0792 236.9242,-746.3998 750.0318,-749.98 895.0688,1.4728 1915.5158,0 2730.996,0 V 2562.996 Z" + style="fill:#0084d1;fill-opacity:1" + clip-path="none" /></g><g + id="g1263" + transform="translate(-700.00001,-2550)"><path + fill="none" + stroke="#069a2e" + stroke-width="265" + stroke-linejoin="round" + d="m 2793,5962 c 1283,0 429,-2762 1712,-2762" + id="path124" /><path + id="path110-6" + d="M 3198.0212,6550 V 6300.0411 H 2448.0411 V 6050.0305 5550.0094 H 2198.0305 V 6300.0411 6550 H 2448.0411 2698 Z" + style="fill:#069a2e;fill-opacity:1" /><path + id="path110" + d="m 4111.997,2550.0252 v 249.9589 h 749.9801 v 250.0106 500.0211 h 250.0106 v -750.0317 -249.9589 h -250.0106 -249.9589 z" + style="fill:#069a2e;fill-opacity:1" /></g> + <g + id="g1249" + transform="translate(-700.00001,-2550)"><path + fill="#ff8000" + stroke="none" + d="M 2215,4164 C 2412.6918,3832.6035 2379.3124,3383.7591 2135.4189,3084.8193 1956.8564,2857.4026 1671.3097,2718.8851 1382,2721 c 0,-52.6667 0,-105.3333 0,-158 -60.1303,-1.0792 190.6585,-1.2724 121.9814,2.7933 434.6311,30.3756 832.6257,336.7309 974.9655,748.2365 148.7336,402.6249 41.0263,883.7477 -266.0719,1183.8452 C 1996.6852,4716.1853 1689.0048,4838.6187 1382,4830 c 0,-61.6667 0,-123.3333 0,-185 338.199,2.9253 666.226,-186.816 833,-481 z" + id="path193" /><path + fill="#ff8000" + stroke="none" + d="m 1936,3979 c 175.8129,-283.3241 59.7943,-700.6319 -239.8255,-849.407 -94.2713,-50.9942 -201.887,-77.9344 -309.1745,-74.593 0,-57 0,-114 0,-171 396.3865,-24.0968 777.5367,297.6517 818.5878,693.1841 44.0348,337.5467 -149.4277,694.9971 -466.4953,826.9493 -110.1027,49.0687 -231.5272,73.543 -352.0925,67.8666 0,-61.6667 0,-123.3333 0,-185 220.6353,7.5804 440.8554,-115.3286 549,-308 z" + id="path186" /><path + fill="#ff8000" + stroke="none" + d="m 1659,3822 c 86.3933,-138.0398 18.2474,-344.669 -134.6962,-402.3205 C 1483.44,3402.2177 1438.3524,3394.2432 1394,3398 c 0,-56.6667 0,-113.3333 0,-170 223.1964,-19.6143 444.7886,153.3254 478.4886,375.1817 32.646,181.6951 -54.6854,381.4331 -217.3127,472.2205 -78.2316,46.131 -170.3991,69.3119 -261.1759,62.5978 0,-58.3333 0,-116.6667 0,-175 105.6736,9.5509 212.8962,-49.2218 265,-141 z" + id="path179" /><rect + class="BoundingBox" + stroke="none" + fill="none" + x="700" + y="2550" + width="501" + height="4001" + id="rect136" + style="fill:#ff8000;fill-opacity:1" /></g> + +</svg> diff --git a/pse-dashboard/src/components/DashboardLayout.vue b/pse-dashboard/src/components/DashboardLayout.vue new file mode 100644 index 0000000..55695ae --- /dev/null +++ b/pse-dashboard/src/components/DashboardLayout.vue @@ -0,0 +1,10 @@ +<script setup> +</script> +<template> + <div class="container p-5 my-5 mt-2"> + <slot /> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/EpisodeEntry.vue b/pse-dashboard/src/components/EpisodeEntry.vue new file mode 100644 index 0000000..c651484 --- /dev/null +++ b/pse-dashboard/src/components/EpisodeEntry.vue @@ -0,0 +1,53 @@ +<script setup> +import { LastUpdate, ProgressTime } from '@/components' + +const props = defineProps({ + action: { + type: Object, + default: undefined + } +}); + +</script> +<template> + <a + href="#" + class="list-group-item list-group-item-action py-3" + aria-current="true" + > + <!-- title with timestamp --> + <div class="d-flex gap-3"> + <i + class="fa fa-podcast rounded-circle flex-shring-0" + style="font-size: 32px" + /> + <div class="d-flex gap-2 w-100 justify-content-between"> + <div> + <h6 class="mb-0">{{ action.title }}</h6> + <p class="mb-0 opacity-75">{{ action.description }}</p> + </div> + <small class="opacity-50 text-nowrap"> + <LastUpdate :iso="action.timestamp" /> + </small> + </div> + </div> + + <!-- Progress bar with Progress time --> + <div class="d-flex"> + <ProgressTime :unix="action.position" /> + <div + class="progress flex-grow-1 m-2" + style="height:10px; " + > + <div + class="progress-bar" + :style="{width: 100*action.position/action.total + '%'}" + /> + </div> + <ProgressTime :unix="action.total" /> + </div> + </a> +</template> +<style> +</style> + diff --git a/pse-dashboard/src/components/ErrorLog.vue b/pse-dashboard/src/components/ErrorLog.vue new file mode 100644 index 0000000..3c36359 --- /dev/null +++ b/pse-dashboard/src/components/ErrorLog.vue @@ -0,0 +1,36 @@ +<script setup> +import { Logger } from '@/logger.js' + +const icon = { + success: "fa-trophy", + info: "fa-circle-exclamation", + warning: "fa-triangle-exclamation", + danger: "fa-fire", +}; + +</script> +<template> + <div class="row"> + <div class="col-sm-12 col-md-6 col-lg-5 position-fixed bottom-0 end-0 my-1"> + <div + v-for="(item, index) in Logger.items" + :key="index" + class="alert alert-dismissible d-flex align-items-center" + :class="'alert-' + item.type" + > + <button + type="button" + class="btn-close" + @click="Logger.delete(item)" + /> + <i + class="fs-3 me-3 fa" + :class="icon[item.type]" + /> {{ item.message }} + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/FloatingLabelInput.vue b/pse-dashboard/src/components/FloatingLabelInput.vue new file mode 100644 index 0000000..6f718fd --- /dev/null +++ b/pse-dashboard/src/components/FloatingLabelInput.vue @@ -0,0 +1,35 @@ +<script setup> +defineProps({ + type: { + type: String, + default: "text" + }, + label: { + type: String, + default: "Text" + }, + modelValue: { + type: String, + default: "" + } +}); +defineEmits(['update:modelValue']); + +</script> +<template> + <div class="form-floating form-input"> + <input + :id="$.uid" + :type="type" + class="form-control" + :value="modelValue" + :placeholder="label" + required + @input="$emit('update:modelValue', $event.target.value)" + > + <label :for="$.uid">{{ label }}</label> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/FormLayout.vue b/pse-dashboard/src/components/FormLayout.vue new file mode 100644 index 0000000..549339f --- /dev/null +++ b/pse-dashboard/src/components/FormLayout.vue @@ -0,0 +1,24 @@ +<script setup> +</script> +<template> + <!-- centers the form --> + <div class="container my-5 text-center"> + <div class="row"> + <div class="col-sm-9 col-md-7 col-lg-5 mx-auto"> + <!-- Logo --> + <img + class="mb-4" + src="../assets/logo.svg" + alt="" + width="250" + height="120" + > + + <slot /> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/HelpModal.vue b/pse-dashboard/src/components/HelpModal.vue new file mode 100644 index 0000000..b4f45ef --- /dev/null +++ b/pse-dashboard/src/components/HelpModal.vue @@ -0,0 +1,44 @@ +<script setup> +</script> +<template> + <div + id="help" + class="modal fade" + > + <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable"> + <div class="modal-content"> + <!-- Modal Header --> + <div class="modal-header"> + <h5 class="modal-title"> + {{ $t("message.help") }} + </h5> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + /> + </div> + + <!-- Modal Body --> + <div + class="modal-body" + v-html="$t('message.helpModal')" + /> + + <!-- Modal Footer --> + <div class="modal-footer"> + <button + type="button" + class="btn btn-secondary" + data-bs-dismiss="modal" + > + {{ $t("message.close") }} + </button> + </div> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/LastUpdate.vue b/pse-dashboard/src/components/LastUpdate.vue new file mode 100644 index 0000000..b30b254 --- /dev/null +++ b/pse-dashboard/src/components/LastUpdate.vue @@ -0,0 +1,46 @@ +<script setup> +import { computed } from 'vue' +import { useI18n } from 'vue-i18n'; +import dayjs from 'dayjs/esm' +import relativeTime from 'dayjs/esm/plugin/relativeTime' +import utc from 'dayjs/esm/plugin/utc' +import 'dayjs/esm/locale/de.js' +dayjs.extend(relativeTime) +dayjs.extend(utc) + +const props = defineProps({ + iso: { + type: String, + default: "" + }, + unix: { + type: Number, + default: 0 + } +}); + +const { locale } = useI18n(); + +const tzOffset = (new Date()).getTimezoneOffset(); + +// compute difference from given iso string and now whenever data changes +const lastUpdate = computed(() => { + let lastUpdateTime; + + if (props.iso) { + lastUpdateTime = dayjs(props.iso).utc(true); + } else { + // lastUpdateTime = dayjs(props.unix * 1000); + lastUpdateTime = dayjs(props.unix * 1000).utcOffset(tzOffset).utc(true); + } + + return lastUpdateTime.locale(locale.value).fromNow(); +}); + +</script> +<template> + {{ lastUpdate }} +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/LoadingConditional.vue b/pse-dashboard/src/components/LoadingConditional.vue new file mode 100644 index 0000000..2bf233c --- /dev/null +++ b/pse-dashboard/src/components/LoadingConditional.vue @@ -0,0 +1,18 @@ +<script setup> +defineProps({ + waitingFor: Boolean +}); + +</script> +<template> + <slot v-if="waitingFor" /> + <div + v-else + class="text-center" + > + <div class="spinner-border text-center" /> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/NavBar.vue b/pse-dashboard/src/components/NavBar.vue new file mode 100644 index 0000000..2cc36e5 --- /dev/null +++ b/pse-dashboard/src/components/NavBar.vue @@ -0,0 +1,134 @@ +<script setup> +import { store } from '@/store.js'; +</script> +<template> + <nav class="navbar navbar-expand-sm bg-light"> + <div class="container"> + <!-- brand --> + <router-link + class="navbar-brand" + to="/subscriptions" + > + <img + src="@/assets/logo.svg" + alt="" + width="200" + height="45" + > + </router-link> + + <!-- mobile view --> + <button + class="navbar-toggler" + type="button" + data-bs-toggle="collapse" + data-bs-target="#mynavbar" + > + <span class="navbar-toggler-icon" /> + </button> + <div + id="mynavbar" + class="collapse navbar-collapse" + > + <!-- routes --> + <div class="me-auto"> + <ul + v-if="store.isLoggedIn" + class="navbar-nav" + > + <li class="nav-item"> + <router-link + to="/subscriptions" + class="nav-link" + > + {{ $t("message.podcast", {n:2}) }} + </router-link> + </li> + <li class="nav-item"> + <router-link + to="/episodes" + class="nav-link" + > + {{ $t("message.mostRecentlyHeared") }} + </router-link> + </li> + </ul> + </div> + + <!-- right side of navbar --> + <div class="d-flex navbar-nav"> + <!-- change language --> + <!-- https://vue-i18n.intlify.dev/guide/essentials/scope.html --> + <div class="nav-item dropdown"> + <a + class="nav-link dropdown-toggle" + href="#" + role="button" + data-bs-toggle="dropdown" + > + <i class="fa fa-language" /> {{ $i18n.locale }} + </a> + <ul class="dropdown-menu"> + <li + v-for="locale in $i18n.availableLocales" + :key="locale" + > + <a + class="dropdown-item" + :class="{active: $i18n.locale == locale}" + @click="$i18n.locale = locale" + >{{ locale }}</a> + </li> + </ul> + </div> + + <!-- Help Modal --> + <a + href="#" + class="nav-link" + data-bs-toggle="modal" + data-bs-target="#help" + >{{ $t("message.help") }}</a> + + <!-- User Account --> + <div + v-if="store.isLoggedIn" + class="nav-item dropdown" + > + <a + href="#" + class="nav-link dropdown-toggle" + role="button" + data-bs-toggle="dropdown" + > + {{ store.username || "John Doe" }} <i class="m-1 fa fa-user" /> + </a> + <ul class="dropdown-menu"> + <li> + <router-link + to="/settings" + class="dropdown-item" + > + {{ $t("message.settings") }} + </router-link> + </li> + <li><hr class="dropdown-divider"></li> + <li> + <router-link + to="/login" + class="dropdown-item" + @click="store.logout()" + > + {{ $t("message.logout") }} + </router-link> + </li> + </ul> + </div> + </div> + </div> + </div> + </nav> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/PasswordInput.vue b/pse-dashboard/src/components/PasswordInput.vue new file mode 100644 index 0000000..599f438 --- /dev/null +++ b/pse-dashboard/src/components/PasswordInput.vue @@ -0,0 +1,45 @@ +<script setup> +import { ref } from 'vue'; +import { FloatingLabelInput } from '@/components'; + +defineProps({ + modelValue: { + type: String, + default: "" + }, + label: { + type: String, + default: "Password" + } +}); + +defineEmits(['update:modelValue']) +const isPasswordVisible = ref(false); +</script> +<template> + <div class="input-group form-input"> + <FloatingLabelInput + :type="isPasswordVisible ? 'text' : 'password'" + :label="label" + :model-value="modelValue" + @update:model-value="newValue => + $emit('update:modelValue', newValue)" + /> + + <label class="btn btn-outline-secondary d-flex align-items-center password-visible"> + <input + v-model="isPasswordVisible" + type="checkbox" + class="btn-check" + autocomplete="off" + > + <i + class="fa" + :class="isPasswordVisible ? 'fa-eye-slash' : 'fa-eye'" + /> + </label> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/PasswordValidator.vue b/pse-dashboard/src/components/PasswordValidator.vue new file mode 100644 index 0000000..a269426 --- /dev/null +++ b/pse-dashboard/src/components/PasswordValidator.vue @@ -0,0 +1,112 @@ +<script setup> +import { PasswordInput } from '@/components'; +import { computed, watch, ref } from 'vue'; +import { useI18n } from 'vue-i18n'; + +const props = defineProps({ + modelValue: { + type: Object, + default: undefined + } +}); + +const emit = defineEmits(['update:modelValue']); + +const { t } = useI18n(); + +const password1 = ref(""); +const password2 = ref(""); + +// Überprüft, ob Passwort und Wiederholungspasswort identisch sind +const passwordMatch = computed(() => { + return password1.value === password2.value; +}); + +watch(() => props.modelValue, (newVal) => { + if ( newVal.password == "" ) { + password1.value = ""; + password2.value = ""; + } +}); + +// Überprüft, ob Passwort lang genug ist +const passwordLength = computed(() => { + return password1.value.length >= 8; +}); + +// Überprüft, ob das Passwort ein Sonderzeichen enthält +const passwordSpecialChar = computed(() => { + const specialCharRegex = /[!@#$%^&*()_+\-=\[\]{});':"`\\|,.<>\/?$§€°~`´]/; + return specialCharRegex.test(password1.value); +}); + +// Überprüft, ob das Passwort Zahlen enthält +const passwordNumbers = computed(() => { + const numbersRegex = /[0-9]/; + return numbersRegex.test(password1.value); +}); + +// Überprüft ob das Passwort Groß- und Kleinschreibung enthält +const passwordUpperLower = computed(() => { + const upperLowerRegex = /^(?=.*[A-Z])(?=.*[a-z]).+$/; + return upperLowerRegex.test(password1.value); +}); + +// Wahr, falls das Passwort alle Eigenschaften für ein sicheres Passwort erfüllt +const isPasswordValidArray = computed(() => [ + { rule: passwordLength, text: t("passwordRequirements.passwordLength", 8) }, + { rule: passwordSpecialChar, text: t("passwordRequirements.passwordSpecialChar") }, + { rule: passwordNumbers, text: t("passwordRequirements.passwordNumbers") }, + { rule: passwordUpperLower, text: t("passwordRequirements.passwordUpperLower") }, + { rule: passwordMatch, text: t("passwordRequirements.passwordMatch") }, +]); + +watch(password1, emitPassword); +watch(password2, emitPassword); + +// emit password object with password and valid whenever passwort1 or 2 changes +function emitPassword() { + emit('update:modelValue', { + password: password1.value, + valid: isPasswordValidArray.value.every(({rule}) => rule.value) + }); +}; + +// Nimmt eine Funktion entgegen, +// falls diese wahr auswertet, wird ein Häckchen Emoji zurückgegeben, +// falls diese falsch auswertet, wird ein Kreuz Emoji zurückgegeben +function returnEmoji(fn) { + return fn ? '✅' : '❌'; +}; + +</script> +<template> + <!-- Eingabefeld für Passwort --> + <PasswordInput + v-model="password1" + :label="$t('form.password')" + /> + + <!-- Eingabefeld für Passwortwiederholung --> + <PasswordInput + v-model="password2" + :label="$t('message.repeatPassword')" + /> + + <!-- Liste mit allen Anforderungen für ein sicheres Passwort --> + <!-- Falls Anforderung erfüllt, wird ein Häckchen Emoji vor der Anforderung angezeigt, --> + <!-- falls nicht, ein Kreuz --> + <ul> + <li + v-for="(rule, index) in isPasswordValidArray" + :key="index" + class="d-flex" + > + {{ returnEmoji(rule.rule.value) }} {{ rule.text }} + </li> + </ul> +</template> +<style> + +</style> + diff --git a/pse-dashboard/src/components/ProgressTime.vue b/pse-dashboard/src/components/ProgressTime.vue new file mode 100644 index 0000000..61bd421 --- /dev/null +++ b/pse-dashboard/src/components/ProgressTime.vue @@ -0,0 +1,23 @@ +<script setup> +import { computed } from 'vue' +import dayjs from 'dayjs/esm' +import utc from 'dayjs/esm/plugin/utc' +dayjs.extend(utc) + +const props = defineProps({ + unix: { + type: Number, + default: 0 + } +}); + +// compute hours, minutes and seconds from unix-seconds whenever data changes +const progressTime = computed(() => dayjs.utc(props.unix * 1000).format("HH:mm:ss")); + +</script> +<template> + {{ progressTime }} +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/SubscriptionEntry.vue b/pse-dashboard/src/components/SubscriptionEntry.vue new file mode 100644 index 0000000..db3b45c --- /dev/null +++ b/pse-dashboard/src/components/SubscriptionEntry.vue @@ -0,0 +1,118 @@ +<script setup> +import { useLogger } from '@/logger.js' +import { LastUpdate, ProgressTime } from '@/components' + +const props = defineProps({ + sub: { + type: Object, + default: undefined + } +}); + +defineEmits(['unsubscribe']); + +const { copiedPodcast, copiedPodcastError } = useLogger(); + +// share or copy the url of the podcast to clipboard +async function sharePodcast() { + const shareData = { + title: props.sub.title, + url: props.sub.url + }; + + // share API + try { + await navigator.share(shareData); + return; + } catch (err) { + console.error(err); + } + + // clipboard API + try { + await navigator.clipboard.writeText(shareData.url) + copiedPodcast(); + return; + } catch (err) { + console.error(err); + } + + copiedPodcastError(); +} + +</script> +<template> + <div class="card"> + <!-- title and timestamp --> + <div + class="card-header d-flex gap-3 py-3" + data-bs-toggle="collapse" + :data-bs-target="'#e' + $.uid" + > + <!-- Podcast Icon --> + <i + class="fa fa-podcast rounded-circle flex-shring-0" + style="font-size: 32px" + /> + + <div class="d-flex gap-2 w-100 justify-content-between"> + <!-- Title --> + <div> + <h6 class="mb-0"> + {{ sub.title || sub.url }} + </h6> + </div> + <div class="text-nowrap"> + <!-- Timestamp --> + <small class="opacity-50"> + <LastUpdate :unix="sub.timestamp * 1" /> + </small> + + <!-- Trash Button to unscubscribe from podcast --> + <!-- opens modal and emits unsubscribe event --> + <button + class="btn mx-2 btn-danger" + data-bs-toggle="modal" + data-bs-target="#delete-subs" + @click="$emit('unsubscribe', props.sub)" + > + <i class="fa fa-trash-can" /> + </button> + + <!-- Share Button (@click.stop should stops card to open but doesn't) --> + <button + class="btn ml-2 btn-secondary" + @click="sharePodcast" + > + <i class="fa fa-share" /> + </button> + </div> + </div> + </div> + + <!-- episode list --> + <div + :id="'e' + $.uid" + class="collapse" + data-bs-parent="#episodes-accordion" + > + <div class="card-body"> + <ol> + <li + v-for="(episode, index) in sub.episodes" + :key="index" + > + {{ episode.title }} + <span class="opacity-50 float-end"> + <ProgressTime :unix="episode.position" />/ + <ProgressTime :unix="episode.total" /> + </span> + </li> + </ol> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/components/index.js b/pse-dashboard/src/components/index.js new file mode 100644 index 0000000..d78c3d7 --- /dev/null +++ b/pse-dashboard/src/components/index.js @@ -0,0 +1,30 @@ +import DashboardLayout from './DashboardLayout.vue' +import EpisodeEntry from './EpisodeEntry.vue' +import ErrorLog from './ErrorLog.vue' +import FloatingLabelInput from './FloatingLabelInput.vue' +import FormLayout from './FormLayout.vue' +import HelpModal from './HelpModal.vue' +import LastUpdate from './LastUpdate.vue' +import LoadingConditional from './LoadingConditional.vue' +import NavBar from './NavBar.vue' +import PasswordInput from './PasswordInput.vue' +import PasswordValidator from './PasswordValidator.vue' +import ProgressTime from './ProgressTime.vue' +import SubscriptionEntry from './SubscriptionEntry.vue' + +export { + DashboardLayout, + EpisodeEntry, + ErrorLog, + FloatingLabelInput, + FormLayout, + HelpModal, + LastUpdate, + LoadingConditional, + NavBar, + PasswordInput, + PasswordValidator, + ProgressTime, + SubscriptionEntry, +} + diff --git a/pse-dashboard/src/i18n.js b/pse-dashboard/src/i18n.js new file mode 100644 index 0000000..e722eab --- /dev/null +++ b/pse-dashboard/src/i18n.js @@ -0,0 +1,11 @@ +import { createI18n } from 'vue-i18n' +import * as locales from '@/locales' + +const i18n = createI18n({ + legacy: false, + locale: 'de', + messages: {...locales} +}); + +export default i18n + diff --git a/pse-dashboard/src/locales/de.help.html b/pse-dashboard/src/locales/de.help.html new file mode 100644 index 0000000..f2fca3a --- /dev/null +++ b/pse-dashboard/src/locales/de.help.html @@ -0,0 +1,10 @@ +<p> +Hier stehen Hilfestellungen. +</p> +<br><br><br><br><br><br><br><br><br><br><br> +<p>Bis hier unten. </p> +<br><br><br><br><br><br><br><br><br><br><br> +<br><br><br><br><br><br><br><br><br><br><br> +<br><br><br><br><br><br><br><br><br><br><br> +<p>Und noch viel weiter! </p> + diff --git a/pse-dashboard/src/locales/de.json b/pse-dashboard/src/locales/de.json new file mode 100644 index 0000000..430e844 --- /dev/null +++ b/pse-dashboard/src/locales/de.json @@ -0,0 +1,77 @@ +{ + "message": { + "addSubscription": "Abonnement hinzufügen", + "changePassword": "Passwort ändern", + "close": "Schließen", + "deleteAccount": "Account löschen", + "deleteAccountWarning": "Bist du sicher, dass du dein Konto mit dem Namen '{username}' löschen möchtest? Dabei gehen alle deine Abonnements und gehörten Episoden verloren. Du kannst aber jederzeit ein neues Konto erstellen. ", + "emailAddressRequest": "Bitte E-Mail-Adresse angeben", + "episode": "keine Episoden | eine Episode | {n} Episoden", + "exportData": "Daten exportieren", + "forgotPassword": "Passwort vergessen", + "gpodderInstanceRequest": "Gpodder-Instanz eingeben", + "help": "Hilfe", + "import": "Importieren", + "importData": "Daten importieren", + "instance": "Gpodder-Instanz", + "login": "Anmelden", + "loginRequest": "Bitte anmelden", + "logout": "Abmelden", + "mostRecentlyHeardEpisodes": "Zuletzt gehörte Episoden", + "mostRecentlyHeared": "Zuletzt gehört", + "newPassword": "Neues Passwort", + "newSubscription": "Neues Abonnement", + "noAccountYet": "Noch keinen Account", + "noEpisodes": "Du hast noch keine Episode angehört. ", + "noSubscriptions": "Du hast noch keine Abonnements hinzugefügt. ", + "oldPassword": "Altes Passwort", + "passwordRequest": "Passwort eingeben", + "personalData": "Personenbezogene Daten", + "podcast": "Podcast | Podcasts", + "registration": "Registrierung", + "rememberMe": "Angemeldet bleiben", + "repeat": "Wiederholen", + "repeatPassword": "Passwort wiederholen", + "selectAll": "Alle auswählen", + "send": "Absenden", + "setNewPassword": "Neues Passwort festlegen", + "settings": "Einstellungen", + "signUp": "Registrieren", + "userNameRequest": "Nutzername eingeben", + "unsubscribePodcasts": "Podcasts deabonnieren", + "unsubscribePodcastsWarning": "Bist du sicher, dass du folgende Podcast deabonnieren möchtest? Dabei werden auch alle Hörfortschritte der Abonnements gelöscht. ", + "unsubscribeSelected": "Ausgewählte deabonnieren", + "yourSubscriptions": "Deine abonnierten Podcasts" + }, + "passwordRequirements": { + "passwordLength": "Mindestens {n} Zeichen Lang", + "passwordMatch": "Passwörter sind identisch", + "passwordNumbers": "Zahl", + "passwordSpecialChar": "Sonderzeichen", + "passwordUpperLower": "Klein- und Großbuchstaben" + }, + "form": { + "emailAddress": "E-Mail-Adresse", + "password": "Passwort", + "username": "Nutzername" + }, + "error": { + "accountCreated": "Konto wurde erstellt! Verifiziere deine E-Mail. ", + "accountDeleted": "Konto wurde erfolgreich gelöscht. Auf Wiedersehen. ", + "copiedPodcast": "Podcast wurde in die Zwischenablage kopiert!", + "copiedPodcastError": "Kann nichts in die Zwischenablage legen. ", + "gpodderImport": "Daten erfolgreich von GPodder-Instanz importiert. ", + "passwordChanged": "Passwort wurde erfolgreich geändert! ", + "passwordForgot": "E-Mail wurde gesendet. Schau in dein Postfach!", + "passwordRequirements": "Passwortanforderungen werden nicht erfüllt.", + "passwordReset": "Passwort wurde zurückgesetzt! Teste Dein neues Passwort. ", + "subscriptionAdded": "Abonnement wurde hinzugefügt!", + "400BadRequest": "Eingaben sind falsch.", + "401Unauthorized": "Nutzername oder Kennwort ist falsch. ", + "404NotFound": "Kein Nutzer mit diesen Eingaben gefunden.", + "connectionLost": "Kann keine Verbindung zum Server aufbauen. ", + "axiosError": "Huch, der Programmierer hat einen Fehler gemacht. ", + "pageNotFound": "Seite nicht gefunden. " + } +} + diff --git a/pse-dashboard/src/locales/en.help.html b/pse-dashboard/src/locales/en.help.html new file mode 100644 index 0000000..ac7dedd --- /dev/null +++ b/pse-dashboard/src/locales/en.help.html @@ -0,0 +1,10 @@ +<p> +Help is available here. +</p> +<br><br><br><br><br><br><br><br><br><br><br> +<p>To down here. </p> +<br><br><br><br><br><br><br><br><br><br><br> +<br><br><br><br><br><br><br><br><br><br><br> +<br><br><br><br><br><br><br><br><br><br><br> +<p>And way beyond! </p> + diff --git a/pse-dashboard/src/locales/en.json b/pse-dashboard/src/locales/en.json new file mode 100644 index 0000000..acc7f2d --- /dev/null +++ b/pse-dashboard/src/locales/en.json @@ -0,0 +1,77 @@ +{ + "message": { + "addSubscription": "Add Subscription", + "changePassword": "Change Password", + "close": "Close", + "deleteAccount": "Delete Account", + "deleteAccountWarning": "Are you sure you want to delete your account named '{username}'? You will lose all your subscriptions and listened episodes. However, you can always create a new account. ", + "episode": "no episodes | one episode | {n} episodes", + "emailAddressRequest": "Please enter your Email Address", + "exportData": "Export Data", + "forgotPassword": "Forgot Password", + "gpodderInstanceRequest": "Enter Gpodder-Instance ", + "help": "Help", + "import": "Import", + "importData": "Import Data", + "instance": "GPodder Instance", + "login": "Login", + "loginRequest": "Login Please", + "logout": "Logout", + "mostRecentlyHeardEpisodes": "Recently heard episodes", + "mostRecentlyHeared": "Recently Heard", + "newPassword": "New Password", + "newSubscription": "New Subscription", + "noAccountYet": "No Account Yet", + "noEpisodes": "Looks like you don't have listened to something yet. ", + "noSubscriptions": "Looks like you don't have any subscriptions yet. ", + "oldPassword": "Old Password", + "passwordRequest": "Enter Password", + "personalData": "Personal Data", + "podcast": "Podcast | Podcasts", + "registration": "Registration", + "rememberMe": "Remember Me", + "repeat": "Repeat", + "repeatPassword": "Repeat Password", + "selectAll": "Select All", + "send": "Send", + "setNewPassword": "Set new Password", + "settings": "Settings", + "signUp": "Sign Up", + "userNameRequest": "Enter Username", + "unsubscribePodcasts": "Unsubscribe from Podcasts", + "unsubscribePodcastsWarning": "Are you sure you want to unsubscribe from the following podcast? This will also delete all listening progress of the subscriptions. ", + "unsubscribeSelected": "Unsubscribe from Selected", + "yourSubscriptions": "Your Podcast Subscriptions" + }, + "passwordRequirements": { + "passwordLength": "At least {n} characters long", + "passwordMatch": "Passwords are Identical", + "passwordNumbers": "Digit", + "passwordSpecialChar": "Symbol", + "passwordUpperLower": "Lowercase and Uppercase Letter" + }, + "form": { + "emailAddress": "Email Address", + "password": "Password", + "username": "Username" + }, + "error": { + "accountCreated": "Account created! Validate your mail. ", + "accountDeleted": "Account got deleted. We are sorry you go. ", + "copiedPodcast": "Copied Podcast to Clipboard!", + "copiedPodcastError": "Can't share Podcast. ", + "gpodderImport": "Imported data from GPodder-Instance. ", + "passwordChanged": "Password got changed! ", + "passwordForgot": "E-Mail was send. Look into your invoice!", + "passwordRequirements": "Password requirements are not met. ", + "passwordReset": "Password got reset! Test you new Password. ", + "subscriptionAdded": "Subscription got added to your list!", + "400BadRequest": "Inputs are incorrect. ", + "401Unauthorized": "Wrong Credentials.", + "404NotFound": "No user found with these inputs.", + "connectionLost": "Cannot establish a connection to the server.", + "axiosError": "Oops, the programmer made a mistake. ", + "pageNotFound": "Page not found." + } +} + diff --git a/pse-dashboard/src/locales/index.js b/pse-dashboard/src/locales/index.js new file mode 100644 index 0000000..64176a9 --- /dev/null +++ b/pse-dashboard/src/locales/index.js @@ -0,0 +1,13 @@ +import de from './de.json' +import de_help from './de.help.html?raw' +import en from './en.json' +import en_help from './en.help.html?raw' + +de.message.helpModal = de_help; +en.message.helpModal = en_help; + +export { + de, + en +} + diff --git a/pse-dashboard/src/logger.js b/pse-dashboard/src/logger.js new file mode 100644 index 0000000..8398fd8 --- /dev/null +++ b/pse-dashboard/src/logger.js @@ -0,0 +1,84 @@ +import { reactive } from 'vue' +import i18n from '@/i18n' +// import { useI18n } from 'vue-i18n' + +// const { t } = i18n.global; +export const Logger = reactive({ + items: [], + append(item) { + this.items.push(item); + }, + delete(item) { + this.items = this.items.filter(e => e != item); + } +}); + + +// error {type: "success" | "info" | "warning" | "danger", message: String, lifetime: number} +export function useLogger() { + // const { t } = useI18n(); + const { t } = i18n.global; + + return { + append(item) { + Logger.append(item); + }, + delete(item) { + Logger.delete(item); + }, + passwordRequirementsError() { + Logger.append({type: "warning", message: t('form.password')}) + }, + passwordRequirements() { + Logger.append({type: "warning", message: t("error.passwordRequirements")}); + }, + passwordChanged() { + Logger.append({type: "success", message: t("error.passwordChanged")}); + }, + accountDeleted() { + Logger.append({type: "info", message: t("error.accountDeleted")}); + }, + gpodderImport() { + Logger.append({type: "info", message: t("error.gpodderImport")}); + }, + passwordReset() { + Logger.append({type: "info", message: t("error.passwordReset")}); + }, + passwordForgot() { + Logger.append({type: "info", message: t("error.passwordForgot")}); + }, + subscriptionAdded() { + Logger.append({type: "info", message: t("error.subscriptionAdded")}); + }, + accountCreated() { + Logger.append({type: "success", message: t("error.accountCreated")}); + }, + copiedPodcast() { + Logger.append({type: "info", message: t("error.copiedPodcast")}); + }, + copiedPodcastError() { + Logger.append({type: "warning", message: t("error.copiedPodcastError")}); + }, + + badRequestError() { + Logger.append({type: "danger", message: t("error.400BadRequest")}); + }, + unauthorizedError() { + Logger.append({type: "danger", message: t("error.401Unauthorized")}); + }, + notFoundError() { + Logger.append({type: "danger", message: t("error.404NotFound")}); + }, + connectionLostError() { + Logger.append({type: "danger", message: t("error.connectionLost")}); + }, + axiosError() { + Logger.append({type: "danger", message: t("error.axiosError")}); + }, + pageNotFound() { + Logger.append({type: "warning", message: t("error.pageNotFound")}); + } + } +} + + diff --git a/pse-dashboard/src/main.js b/pse-dashboard/src/main.js new file mode 100644 index 0000000..04dd3c2 --- /dev/null +++ b/pse-dashboard/src/main.js @@ -0,0 +1,20 @@ +import { createApp } from 'vue' +import router from '@/router' +import i18n from '@/i18n' + +import App from '@/App.vue' + +import "bootstrap/dist/css/bootstrap.css" +import '@/style.css' +import "@fortawesome/fontawesome-free/css/all.css" + +try { + navigator.registerProtocolHandler("web+pod", "/subscriptions?add=%s", "Podcast"); +} catch (err) { + console.error(err); +} + +createApp(App).use(router).use(i18n).mount('#app') + +// import "bootstrap/dist/js/bootstrap.js" + diff --git a/pse-dashboard/src/router.js b/pse-dashboard/src/router.js new file mode 100644 index 0000000..573b0d7 --- /dev/null +++ b/pse-dashboard/src/router.js @@ -0,0 +1,116 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { store } from '@/store.js' +import { + LoginView, + SubscriptionsView, + EpisodesView, + ForgotPasswordView, + SettingsView, + RegistrationView, + ResetPasswordView +} from '@/views' +import { useLogger } from '@/logger.js' + +const logger = useLogger(); + +const routes = [ + { + path: '/', + redirect: to => { + return store.isLoggedIn ? '/subscriptions' : '/login'; + }, + meta: { requiresAuth: false }, + }, + { + path: '/login', + name: 'Login', + component: LoginView, + meta: { requiresAuth: false }, + }, + { + path: '/forgotPassword', + name: 'ForgotPassword', + component: ForgotPasswordView, + meta: { requiresAuth: false }, + }, + { + path: '/registration', + name: 'Registration', + component: RegistrationView, + meta: { requiresAuth: false }, + }, + { + path: '/resetPassword', + name: 'ResetPassword', + component: ResetPasswordView, + props: router => ({ + token: router.query.token, + username: router.query.username + }), + meta: { requiresAuth: false }, + }, + { + path: '/subscriptions', + name: 'Subscriptions', + component: SubscriptionsView, + meta: { requiresAuth: true } + }, + { + path: '/episodes', + name: 'Episodes', + component: EpisodesView, + meta: { requiresAuth: true } + }, + { + path: '/settings', + name: 'Settings', + component: SettingsView, + meta: { requiresAuth: true } + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + redirect: to => { + logger.pageNotFound(); + return "/"; + }, + meta: { requiresAuth: false }, + } +] + +const baseURL = import.meta.env.BASE_URL || "/"; +console.log("Base-URL", baseURL); + +const router = createRouter({ + history: createWebHistory(baseURL), + routes +}) + +router.beforeEach((to, from, next) => { + // instead of having to check every route record with + // to.matched.some(record => record.meta.requiresAuth) + if (to.meta.requiresAuth && !store.isLoggedIn) { + // this route requires auth, check if logged in + // if not, redirect to login page. + next({ + path: '/', + // save the location we were at to come back later + query: { redirect: to.fullPath }, + }); + } else if (!to.meta.requiresAuth && store.isLoggedIn) { + next({ + path: '/' + }); + } else if (store.isLoggedIn && from.query.redirect) { + // user is logged in and there's a saved location in the query + // redirect them to that location + const redirect = from.query.redirect; + delete from.query.redirect; + next(redirect); + } else { + next(); + } +}); + +export default router + diff --git a/pse-dashboard/src/store.js b/pse-dashboard/src/store.js new file mode 100644 index 0000000..5f838e6 --- /dev/null +++ b/pse-dashboard/src/store.js @@ -0,0 +1,54 @@ +import { reactive } from 'vue' +import { login, logout } from '@/api/pse-squared.js' + +const username = sessionStorage.getItem("username") || localStorage.getItem("username"); +const password = sessionStorage.getItem("password") || localStorage.getItem("password"); + +export const store = reactive({ + isLoggedIn: username && password, + username, + password, + async login({username, password}, persistant) { + try { + await login({username, password}); + this.username = username; + this.password = password; + this.isLoggedIn = true; + + setStorage(this, persistant); + + return true; + } catch(err) { + console.error(err); + return false; + } + }, + async logout() { + logout(); + this.username = ""; + this.password = ""; + this.isLoggedIn = false; + clearStorage(); + return true; + } +}); + +if (username && password) { + store.login({username, password}); +} + +function setStorage(data, persistant) { + if (persistant) { + localStorage.setItem("username", data.username); + localStorage.setItem("password", data.password); + } + + sessionStorage.setItem("username", data.username); + sessionStorage.setItem("password", data.password); +} + +function clearStorage() { + sessionStorage.clear(); + localStorage.clear(); +} + diff --git a/pse-dashboard/src/style.css b/pse-dashboard/src/style.css new file mode 100644 index 0000000..15fb716 --- /dev/null +++ b/pse-dashboard/src/style.css @@ -0,0 +1,33 @@ +:root { + --bs-body-bg: #f5f5f5 !important; +} + +html, body { + height: 100%; +} + +/* Style der Eingabefelder */ +form .form-control, form .form-input label { + border-radius: 0; + margin-bottom: -1px; +} + + +form > .form-input:first-of-type .form-control { + border-top-right-radius: 0.375rem; + border-top-left-radius: 0.375rem; +} + +form > .form-input:first-of-type label { + border-top-right-radius: 0.375rem; +} + +form > .form-input:last-of-type .form-control { + border-bottom-right-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + +form > .form-input:last-of-type label { + border-bottom-right-radius: 0.375rem; +} + diff --git a/pse-dashboard/src/views/EpisodesView.vue b/pse-dashboard/src/views/EpisodesView.vue new file mode 100644 index 0000000..a7027b1 --- /dev/null +++ b/pse-dashboard/src/views/EpisodesView.vue @@ -0,0 +1,42 @@ +<script setup> +import { DashboardLayout, EpisodeEntry, LoadingConditional } from '@/components' +import { ref, onMounted } from 'vue'; +import { getEpisodeActions } from '@/api/pse-squared.js'; + +const episodes = ref(null); +const received = ref(false); + +onMounted(async () => { + try { + const response = await getEpisodeActions(); + + received.value = true; + episodes.value = response.data; + } catch(err) { + } +}); + +</script> +<template> + <DashboardLayout> + <h1 class="h1 mb-4"> + {{ $t("message.mostRecentlyHeardEpisodes") }} + </h1> + + <LoadingConditional :waiting-for="received"> + <div class="list-group w-auto"> + <EpisodeEntry + v-for="(action, index) in episodes.actions" + :key="index" + :action="action" + /> + </div> + <p v-if="episodes.actions.length == 0"> + {{ $t('message.noEpisodes') }} + </p> + </LoadingConditional> + </DashboardLayout> +</template> +<style> +</style> + diff --git a/pse-dashboard/src/views/ForgotPasswordView.vue b/pse-dashboard/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000..03d0ff1 --- /dev/null +++ b/pse-dashboard/src/views/ForgotPasswordView.vue @@ -0,0 +1,52 @@ +<script setup> +import { FloatingLabelInput, FormLayout } from '@/components' +import { useLogger } from '@/logger.js' +import { ref } from 'vue' +import { forgotPassword } from '@/api/pse-squared.js' + +const email = ref(""); + +const { passwordForgot } = useLogger(); + +async function formForgot() { + try { + await forgotPassword({ email: email.value }); + passwordForgot(); + } catch (err) {} +} +</script> +<template> + <FormLayout> + <!-- Text über Texteingabefeld --> + <h1 class="h3 mb-3 fw-normal"> + {{ $t("message.emailAddressRequest") }} + </h1> + + <form @submit.prevent="formForgot"> + <!-- Eingabefeld für E-Mail-Adresse --> + <FloatingLabelInput + v-model="email" + type="email" + :label="$t('form.emailAddress')" + /> + + <!-- Absende Knopf der Daten --> + <button + type="submit" + class="w-100 btn btn-lg btn-primary mt-2" + > + {{ $t("message.send") }} + </button> + </form> + + <!-- Zurück zur Anmeldung --> + <router-link to="/"> + <button class="w-100 btn btn-lg btn-secondary mt-2"> + {{ $t("message.close") }} + </button> + </router-link> + </FormLayout> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/views/LoginView.vue b/pse-dashboard/src/views/LoginView.vue new file mode 100644 index 0000000..303db3a --- /dev/null +++ b/pse-dashboard/src/views/LoginView.vue @@ -0,0 +1,91 @@ +<script setup> +import { ref } from 'vue'; +import { FloatingLabelInput, FormLayout, PasswordInput } from '@/components' +import { store } from '@/store.js'; +import router from '@/router.js'; + +const username = ref(""); +const password = ref(""); +const stayLoggedIn = ref(false); + +async function login(e) { + const success = await store.login({ + username: username.value, + password: password.value + }, stayLoggedIn.value); + + if (success) { + router.push("/subscriptions"); + } +} + +</script> +<template> + <FormLayout> + <!-- Text über Texteingabe --> + <h1 class="h3 mb-3 fw-normal"> + {{ $t("message.loginRequest") }} + </h1> + + <form @submit.prevent="login"> + <!-- Eingabefeld für Nutzername --> + <FloatingLabelInput + v-model="username" + :label="$t('form.username')" + /> + + <!-- Eingabefeld für Passwort --> + <PasswordInput + v-model="password" + :label="$t('form.password')" + /> + + <div class="row"> + <!-- Angemeldet bleiben Checkbox --> + <div class="col-6"> + <div class="checkbox mb-3"> + <label> + <input + v-model="stayLoggedIn" + type="checkbox" + value="remember-me" + > + {{ $t("message.rememberMe") }} + </label> + </div> + </div> + + <!-- Passwort vergessen Link --> + <div class="col-6"> + <router-link to="/forgotPassword"> + {{ $t("message.forgotPassword") }}? + </router-link> + </div> + </div> + + <!-- Knopf um sich anzumelden --> + <button + type="submit" + class="w-100 btn btn-lg btn-primary" + > + {{ $t("message.login") }} + </button> + + <!-- Registrieren Link --> + <p class="mt-1"> + {{ $t("message.noAccountYet") }}? + <router-link to="/registration"> + {{ $t("message.signUp") }} + </router-link> + </p> + </form> + + <!-- Footer --> + <p class="mt-5 mb-3 text-muted"> + © 2023 + </p> + </FormLayout> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/views/RegistrationView.vue b/pse-dashboard/src/views/RegistrationView.vue new file mode 100644 index 0000000..2d561f3 --- /dev/null +++ b/pse-dashboard/src/views/RegistrationView.vue @@ -0,0 +1,78 @@ +<script setup> +import { FloatingLabelInput, FormLayout, PasswordValidator } from '@/components' +import { ref } from 'vue'; +import router from '@/router.js'; +import { register } from '@/api/pse-squared.js' +import { useLogger } from '@/logger.js' + +const username = ref(""); +const email = ref(""); +const passwordModel = ref(null); + +const { passwordRequirements, accountCreated } = useLogger(); + +async function formRegister() { + if(!passwordModel.value.valid) { + // log.append({type: "info", message: "Password requirements are not met"}) + passwordRequirements(); + return; + } + + try { + await register({ + username: username.value, + email: email.value, + password: passwordModel.value.password + }); + + accountCreated(); + router.push("/login"); + } catch (err) { + } +} + +</script> +<template> + <FormLayout> + <!-- Text über Texteingabefeld --> + <h1 class="h3 mb-3 fw-normal"> + {{ $t("message.registration") }} + </h1> + + <form @submit.prevent="formRegister"> + <!-- Eingabefeld für Nutzernamen --> + <FloatingLabelInput + v-model="username" + type="text" + :label="$t('form.username')" + /> + + <!-- Eingabefeld für E-Mail-Adresse --> + <FloatingLabelInput + v-model="email" + type="email" + :label="$t('form.emailAddress')" + /> + + <!-- Passwort-Validierungs-Komponente --> + <PasswordValidator v-model="passwordModel" /> + + <!-- Absende Knopf für E-Mail-Adresse --> + <button + type="submit" + class="w-100 btn btn-lg btn-primary" + > + {{ $t("message.signUp") }} + </button> + </form> + + <!-- Zurück zur Anmeldung --> + <router-link to="/"> + <button class="w-100 btn btn-lg btn-secondary mt-2"> + {{ $t("message.close") }} + </button> + </router-link> + </FormLayout> +</template> +<style scoped> +</style> diff --git a/pse-dashboard/src/views/ResetPasswordView.vue b/pse-dashboard/src/views/ResetPasswordView.vue new file mode 100644 index 0000000..a617af3 --- /dev/null +++ b/pse-dashboard/src/views/ResetPasswordView.vue @@ -0,0 +1,72 @@ +<script setup> +import { FormLayout, PasswordValidator } from '@/components' +import { ref } from 'vue' +import { useLogger } from '@/logger.js' +import { resetPassword } from '@/api/pse-squared.js' +import router from '@/router.js'; + +const props = defineProps({ + token: { + type: String, + default: "" + }, + username: { + type: String, + default: "" + } +}); + +const { passwordRequirements, passwordReset } = useLogger(); + +const passwordModel = ref(null); + +async function formResetPassword() { + if(!passwordModel.value.valid) { + // log.append({type: "info", message: "Password requirements are not met"}) + passwordRequirements(); + return; + } + + try { + await resetPassword({ + username: props.username, + password: passwordModel.value.password, + token: props.token + }); + + passwordReset(); + router.push("/login"); + } catch (err) {} +} + +</script> +<template> + <FormLayout> + <form @submit.prevent="formResetPassword"> + <!-- Text über Texteingabefeld --> + <h1 class="h3 mb-3 fw-normal"> + {{ $t("message.setNewPassword") }} + </h1> + + <!-- Passwort-Validierungs-Komponente --> + <PasswordValidator v-model="passwordModel" /> + + <!-- Absende Knopf für E-Mail-Adresse --> + <button + type="submit" + class="w-100 btn btn-lg btn-primary" + > + {{ $t("message.send") }} + </button> + + <!-- Zurück zur Anmeldung --> + <router-link to="/"> + <button class="w-100 btn btn-lg btn-secondary mt-2"> + {{ $t("message.close") }} + </button> + </router-link> + </form> + </FormLayout> +</template> +<style scoped> +</style> diff --git a/pse-dashboard/src/views/SettingsView.vue b/pse-dashboard/src/views/SettingsView.vue new file mode 100644 index 0000000..4e5e486 --- /dev/null +++ b/pse-dashboard/src/views/SettingsView.vue @@ -0,0 +1,347 @@ +<script setup> +import { + DashboardLayout, + FloatingLabelInput, + PasswordInput, + PasswordValidator, +} from '@/components' +import { ref } from 'vue'; +import { store } from '@/store.js' +import useGPodder from '@/api/gpodder.js' +import { + changePassword, + deleteAccount, + getEpisodeActions, + getTitles, + postEpisodeActions, + putSubscriptions, +} from '@/api/pse-squared.js' +import JSZip from 'jszip'; +import router from '@/router.js' +import { useLogger } from '@/logger.js' +import { saveAs } from 'file-saver'; + +/******************************************************************************/ +/* change password */ +/******************************************************************************/ + +const changePasswordOld = ref(null); +const changePasswordNew = ref(null); + +const logger = useLogger(); + +async function formChangePassword() { + if(!changePasswordNew.value.valid) { + logger.passwordRequirements(); + return; + } + + try { + await changePassword({ + password: changePasswordOld.value, + new_password: changePasswordNew.value.password + }); + + await store.login({ + username: store.username, + password: changePasswordNew.value.password + }, false); + + changePasswordOld.value = ""; + changePasswordNew.value = {password: "", valid: false}; + + logger.passwordChanged(); + } catch (err) {} +} + +/******************************************************************************/ +/* delete account */ +/******************************************************************************/ + +const deletePassword = ref(""); + +async function formDelete() { + + // FIXME: backend + if (store.password != deletePassword.value) { + logger.badRequestError(); + return; + } + + try { + await deleteAccount({ + password: deletePassword.value + }); + + logger.accountDeleted(); + store.logout(); + router.push("/login"); + } catch (err) {} +} + +/******************************************************************************/ +/* import from another GPodder instance */ +/******************************************************************************/ + +const gPodderInstance = ref(""); +const gPodderUsername = ref(""); +const gPodderPassword = ref(""); + +async function formImportGPodder() { + const instance = useGPodder({ + baseURL: gPodderInstance.value + }); + + await instance.login({ + username: gPodderUsername.value, + password: gPodderPassword.value + }); + + // download titles from instance and upload to pse-squared + try { + const response = await instance.getTitles(); + const subscriptions = response.data.map(sub => sub.url); + + await putSubscriptions(subscriptions); + } catch (err) { + console.error(err); + return; + } + + // download episodes from instance and upload to pse-squared + try { + const response = await instance.getEpisodeActions(); + const episodes = response.data.actions; + + await postEpisodeActions(episodes); + } catch (err) { + console.error(err); + return; + } + + logger.gpodderImport(); +} + + +/******************************************************************************/ +/* import and export data */ +/******************************************************************************/ + +async function importData(e) { + const file = e.target.files[0]; + + const zip = await JSZip.loadAsync(file); + + // read and upload subscriptions + try { + const subscriptionsText = await zip.files["subscriptions.json"].async("string"); + const subscriptions = JSON.parse(subscriptionsText).map(sub => sub.url); + console.log({subscriptions}); + + await putSubscriptions(subscriptions); + } catch(err) { + console.error(err); + } + + // read and upload episodeActions + try { + const episodeActionsText = await zip.files["episodeActions.json"].async("string"); + const episodeActions = JSON.parse(episodeActionsText).actions; + + await postEpisodeActions(episodeActions); + } catch(err) { + console.error(err); + } +} + +async function exportData() { + const zip = new JSZip(); + + // load subscriptions + try { + const subscriptionResponse = await getTitles(); + zip.file("subscriptions.json", JSON.stringify(subscriptionResponse.data)); + } catch(err) { + console.error(err); + } + + // load episodeActions + try { + const episodeActionsResponse = await getEpisodeActions(); + zip.file("episode-actions.json", JSON.stringify(episodeActionsResponse.data)); + } catch(err) { + console.error(err); + } + + // generate and save zip + zip.generateAsync({type:"blob"}).then(function(content) { + saveAs(content, "pse-export.zip"); + }); +} + +</script> +<template> + <DashboardLayout> + <!-- Überschrift --> + <h1 class="h1 mb-4"> + {{ $t("message.settings") }} + </h1> + + <!-- Passwort ändern Sektion --> + <form + class="mb-4" + @submit.prevent="formChangePassword" + > + <h2>{{ $t("message.changePassword") }}</h2> + <!-- Altes Passwort --> + <PasswordInput + v-model="changePasswordOld" + :label="$t('message.oldPassword')" + /> + + <!-- Neues Passwort --> + <PasswordValidator v-model="changePasswordNew" /> + + <button + type="submit" + class="btn btn-primary" + > + {{ $t("message.changePassword") }} + </button> + </form> + + <!-- Gpodder-Import Sektion --> + <form + class="mb-4" + @submit.prevent="formImportGPodder" + > + <h2>Gpodder-{{ $t("message.import") }}</h2> + + <!-- Gpodder Instanz --> + <FloatingLabelInput + v-model="gPodderInstance" + :label="$t('message.instance')" + /> + + <!-- Nutzername --> + <FloatingLabelInput + v-model="gPodderUsername" + :label="$t('form.username')" + /> + + <!-- Passwort --> + <PasswordInput + v-model="gPodderPassword" + :label="$t('form.password')" + /> + + <button + type="submit" + class="btn btn-primary" + > + {{ $t("message.importData") }} + </button> + </form> + + <!-- Personenbezogene Daten im-/exportieren --> + <div class="mb-4"> + <h2>{{ $t("message.personalData") }}</h2> + <label + for="file" + class="btn btn-success" + > + {{ $t("message.importData") }} + </label> + <input + id="file" + type="file" + accept=".zip,application/zip" + hidden + @change="importData" + > + <button + class="btn btn-warning mx-2" + @click="exportData" + > + {{ $t("message.exportData") }} + </button> + </div> + + <!-- Account löschen --> + <div class="mb-4"> + <h2>{{ $t("message.deleteAccount") }}</h2> + + <button + class="btn btn-danger" + data-bs-toggle="modal" + data-bs-target="#delete-user" + > + {{ $t("message.deleteAccount") }} + </button> + </div> + </DashboardLayout> + + <!-- Modal for confirming deletion of user --> + <div + id="delete-user" + class="modal" + tabindex="-1" + > + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <!-- Title of Modal --> + <div class="modal-header"> + <h5 class="modal-title"> + {{ $t('message.deleteAccount') }} + </h5> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close" + /> + </div> + + <!-- list podcasts to unsubscribe from --> + <div class="modal-body"> + <div + class="alert alert-warning d-flex align-items-center" + role="alert" + > + <i class="fs-2 me-3 fa fa-warning" /> + <div> + {{ $t('message.deleteAccountWarning', {username: store.username}) }} + </div> + </div> + + <PasswordInput + v-model="deletePassword" + :label="$t('form.password')" + /> + </div> + + <!-- buttons to dismiss or finaly unsubscribe --> + <div class="modal-footer"> + <button + type="button" + class="btn btn-secondary" + data-bs-dismiss="modal" + > + {{ $t('message.close') }} + </button> + <button + type="button" + data-bs-dismiss="modal" + class="btn btn-danger" + @click="formDelete" + > + {{ $t('message.deleteAccount') }} + </button> + </div> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/views/SubscriptionsView.vue b/pse-dashboard/src/views/SubscriptionsView.vue new file mode 100644 index 0000000..045b5f1 --- /dev/null +++ b/pse-dashboard/src/views/SubscriptionsView.vue @@ -0,0 +1,270 @@ +<script setup> +import { DashboardLayout, FloatingLabelInput, LoadingConditional, SubscriptionEntry } from '@/components'; +import { useLogger } from '@/logger.js' +import { ref, onMounted } from 'vue'; +import { getTitles, putSubscriptions, postSubscriptions } from '@/api/pse-squared.js' +import { Modal } from 'bootstrap'; + +const titles = ref(null); +const received = ref(false); + +const newSubscription = ref(); +const deletedSubscriptions = ref([]); + +const { subscriptionAdded } = useLogger(); + +// if called from pseudo-protocol: show dialog to add subscription up front +onMounted(() => { + loadData(); + + const urlParams = new URLSearchParams(window.location.search); + + const addSubModal = new Modal("#add-sub"); + if (urlParams.has('add')) { + newSubscription.value = decodeURIComponent(urlParams.get('add')) + .replace("web+pod://", ""); + addSubModal.show(); + } +}); + +// fetches all titles of the subscribed podcasts and sets visibility +async function loadData() { + try { + const response = await getTitles(); + + received.value = true; + titles.value = response.data; + } catch(err) { } +} + +// makes a addition request based in the url in the input field/pseudo protocoll +async function addSubscription() { + received.value = false; + await putSubscriptions([newSubscription.value]); + + // log.append({type: "info", message: "Subscription got added to your list!"}); + subscriptionAdded(); + newSubscription.value = ""; + + await loadData(); +} + +// toggles the selection of all subscriptions +function selectAllSubscriptions() { + if ( deletedSubscriptions.value.length > 0 ) { + deletedSubscriptions.value = []; + } else { + deletedSubscriptions.value = titles.value; + } +} + +// makes a deletion request from all selected podcasts +async function unsubscribeFromSelected() { + received.value = false; + + await postSubscriptions({ + add: [], + remove: deletedSubscriptions.value.map(e => e.url) + }); + deletedSubscriptions.value = []; + + await loadData(); +} + +// the modal gets open by the id from the html attributes defined in the Subscription +// component. +function unsubscribeSingle(sub) { + deletedSubscriptions.value = [sub]; +} + +</script> +<template> + <DashboardLayout> + <h1 class="h1 mb-4"> + {{ $t("message.yourSubscriptions") }} + </h1> + + <!-- add a new subscription by url --> + <form + class="input-group mb-3" + @submit.prevent="addSubscription" + > + <FloatingLabelInput + v-model="newSubscription" + :label="$t('message.newSubscription')" + /> + <button + class="btn btn-success" + type="submit" + > + {{ $t("message.addSubscription") }} + </button> + </form> + + <LoadingConditional :waiting-for="received"> + <!-- user does not have subscriptions yet --> + <p v-if="titles.length == 0"> + {{ $t("message.noSubscriptions") }} + </p> + + <!-- display subscriptions --> + <div v-else> + <button + class="btn m-2 btn-success" + @click="selectAllSubscriptions" + > + {{ $t('message.selectAll') }} + </button> + <button + class="btn m-2 btn-primary" + :class="{disabled: deletedSubscriptions.length == 0}" + data-bs-toggle="modal" + data-bs-target="#delete-subs" + > + {{ $t('message.unsubscribeSelected') }} + </button> + + <div id="episodes-accordion"> + <div + v-for="sub in titles" + :key="sub.url" + class="form-check" + > + <input + v-model="deletedSubscriptions" + class="form-check-input mt-4" + type="checkbox" + :value="sub" + > + <SubscriptionEntry + :sub="sub" + @unsubscribe="unsubscribeSingle" + /> + </div> + </div> + </div> + </LoadingConditional> + </DashboardLayout> + + <!-- Modal for adding subscription with pseudo-protocol --> + <div + id="add-sub" + class="modal" + tabindex="-1" + > + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <!-- Title of Modal --> + <div class="modal-header"> + <h5 class="modal-title"> + {{ $t('message.newSubscription') }} + </h5> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close" + /> + </div> + + <!-- display URL of Podcast to subcribe to --> + <div class="modal-body"> + <FloatingLabelInput + v-model="newSubscription" + :label="$t('message.newSubscription')" + /> + </div> + + <!-- buttons to dismiss or accept the new subscription --> + <div class="modal-footer"> + <button + type="button" + class="btn btn-secondary" + data-bs-dismiss="modal" + > + {{ $t('message.close') }} + </button> + <button + type="button" + data-bs-dismiss="modal" + class="btn btn-primary" + @click="addSubscription" + > + {{ $t('message.addSubscription') }} + </button> + </div> + </div> + </div> + </div> + + <!-- Modal for confirming deletion of subscriptions --> + <div + id="delete-subs" + class="modal" + tabindex="-1" + > + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <!-- Title of Modal --> + <div class="modal-header"> + <h5 class="modal-title"> + {{ $t('message.unsubscribePodcasts') }} + </h5> + <button + type="button" + class="btn-close" + data-bs-dismiss="modal" + aria-label="Close" + /> + </div> + + <!-- list podcasts to unsubscribe from --> + <div class="modal-body"> + <div + class="alert alert-warning d-flex align-items-center" + role="alert" + > + <i class="fs-2 me-3 fa fa-warning" /> + <div> + {{ $t('message.unsubscribePodcastsWarning') }} + </div> + </div> + + <ul> + <li + v-for="sub in deletedSubscriptions" + :key="sub.url" + > + {{ sub.title || sub.url }} + <span class="opacity-50"> + ({{ $t('message.episode', sub.episodes.length) }}) + </span> + </li> + </ul> + </div> + + <!-- buttons to dismiss or finaly unsubscribe --> + <div class="modal-footer"> + <button + type="button" + class="btn btn-secondary" + data-bs-dismiss="modal" + > + {{ $t('message.close') }} + </button> + <button + type="button" + data-bs-dismiss="modal" + class="btn btn-primary" + @click="unsubscribeFromSelected" + > + {{ $t('message.unsubscribePodcasts') }} + </button> + </div> + </div> + </div> + </div> +</template> +<style scoped> +</style> + diff --git a/pse-dashboard/src/views/index.js b/pse-dashboard/src/views/index.js new file mode 100644 index 0000000..e4706f8 --- /dev/null +++ b/pse-dashboard/src/views/index.js @@ -0,0 +1,18 @@ +import EpisodesView from './EpisodesView.vue' +import ForgotPasswordView from './ForgotPasswordView.vue' +import LoginView from './LoginView.vue' +import RegistrationView from './RegistrationView.vue' +import ResetPasswordView from './ResetPasswordView.vue' +import SettingsView from './SettingsView.vue' +import SubscriptionsView from './SubscriptionsView.vue' + +export { + EpisodesView, + ForgotPasswordView, + LoginView, + RegistrationView, + ResetPasswordView, + SettingsView, + SubscriptionsView, +} + |