diff options
Diffstat (limited to 'pse-dashboard/src/views')
-rw-r--r-- | pse-dashboard/src/views/EpisodesView.vue | 42 | ||||
-rw-r--r-- | pse-dashboard/src/views/ForgotPasswordView.vue | 52 | ||||
-rw-r--r-- | pse-dashboard/src/views/LoginView.vue | 91 | ||||
-rw-r--r-- | pse-dashboard/src/views/RegistrationView.vue | 78 | ||||
-rw-r--r-- | pse-dashboard/src/views/ResetPasswordView.vue | 72 | ||||
-rw-r--r-- | pse-dashboard/src/views/SettingsView.vue | 347 | ||||
-rw-r--r-- | pse-dashboard/src/views/SubscriptionsView.vue | 270 | ||||
-rw-r--r-- | pse-dashboard/src/views/index.js | 18 |
8 files changed, 970 insertions, 0 deletions
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, +} + |