diff options
| author | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
|---|---|---|
| committer | Pitu <[email protected]> | 2021-01-04 01:04:20 +0900 |
| commit | fcd39dc550dec8dbcb8325e07e938c5024cbc33d (patch) | |
| tree | f41acb4e0d5fd3c3b1236fe4324b3fef9ec6eafe /src/site/pages | |
| parent | Create FUNDING.yml (diff) | |
| parent | chore: update todo (diff) | |
| download | host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.tar.xz host.fuwn.me-fcd39dc550dec8dbcb8325e07e938c5024cbc33d.zip | |
Merge branch 'dev'
Diffstat (limited to 'src/site/pages')
| -rw-r--r-- | src/site/pages/a/_identifier.vue | 153 | ||||
| -rw-r--r-- | src/site/pages/dashboard/account.vue | 174 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/file/_id.vue | 176 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/settings.vue | 162 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/user/_id.vue | 120 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/users.vue | 247 | ||||
| -rw-r--r-- | src/site/pages/dashboard/albums/_id.vue | 128 | ||||
| -rw-r--r-- | src/site/pages/dashboard/albums/index.vue | 110 | ||||
| -rw-r--r-- | src/site/pages/dashboard/index.vue | 144 | ||||
| -rw-r--r-- | src/site/pages/dashboard/tags/index.vue | 270 | ||||
| -rw-r--r-- | src/site/pages/faq.vue | 66 | ||||
| -rw-r--r-- | src/site/pages/index.vue | 102 | ||||
| -rw-r--r-- | src/site/pages/login.vue | 156 | ||||
| -rw-r--r-- | src/site/pages/logout.vue | 8 | ||||
| -rw-r--r-- | src/site/pages/register.vue | 115 |
15 files changed, 2131 insertions, 0 deletions
diff --git a/src/site/pages/a/_identifier.vue b/src/site/pages/a/_identifier.vue new file mode 100644 index 0000000..7ffed35 --- /dev/null +++ b/src/site/pages/a/_identifier.vue @@ -0,0 +1,153 @@ +<template> + <section class="section is-fullheight"> + <template v-if="files && files.length"> + <div class="align-top"> + <div class="container"> + <h1 class="title"> + {{ name }} + </h1> + <h2 class="subtitle"> + Serving {{ files ? files.length : 0 }} files + </h2> + <a + v-if="downloadLink" + :href="downloadLink">Download Album</a> + <hr> + </div> + </div> + <div class="container"> + <template v-if="!isNsfw || (isNsfw && nsfwConsent)"> + <Grid + v-if="files && files.length" + :files="files" + :is-public="true" + :width="200" + :enable-search="false" + :enable-toolbar="false" /> + </template> + <template v-else> + <div class="nsfw"> + <i class="mdi mdi-alert mdi-48px" /> + <h1>NSFW Content</h1> + <p> + This album contains images or videos that are not safe for work or are inappropriate to view in some situations.<br> + Do you wish to proceed? + </p> + <button + class="button is-danger" + @click="nsfwConsent = true"> + Show me the content + </button> + </div> + </template> + </div> + </template> + <template v-else> + <div class="container"> + <h1 class="title"> + :( + </h1> + <h2 class="subtitle"> + This album seems to be empty + </h2> + </div> + </template> + </section> +</template> + +<script> +import axios from 'axios'; +import Grid from '~/components/grid/Grid.vue'; + +export default { + components: { Grid }, + data() { + return { + nsfwConsent: false + }; + }, + computed: { + config() { + return this.$store.state.config; + } + }, + async asyncData({ app, params, error }) { + try { + const { data } = await axios.get(`${app.store.state.config.baseURL}/album/${params.identifier}`); + const downloadLink = data.downloadEnabled ? `${app.store.state.config.baseURL}/album/${params.identifier}/zip` : null; + return { + name: data.name, + downloadEnabled: data.downloadEnabled, + files: data.files, + downloadLink, + isNsfw: data.isNsfw + }; + } catch (err) { + console.log('Error when retrieving album', err); + error({ statusCode: 404, message: 'Album not found' }); + } + }, + metaInfo() { + if (this.files) { + return { + title: `${this.name ? this.name : ''}`, + meta: [ + { vmid: 'theme-color', name: 'theme-color', content: '#30a9ed' }, + { vmid: 'twitter:card', name: 'twitter:card', content: 'summary' }, + { vmid: 'twitter:title', name: 'twitter:title', content: `Album: ${this.name} | Files: ${this.files.length}` }, + { vmid: 'twitter:description', name: 'twitter:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' }, + { vmid: 'twitter:image', name: 'twitter:image', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` }, + { vmid: 'twitter:image:src', name: 'twitter:image:src', value: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` }, + + { vmid: 'og:url', property: 'og:url', content: `${this.config.URL}/a/${this.$route.params.identifier}` }, + { vmid: 'og:title', property: 'og:title', content: `Album: ${this.name} | Files: ${this.files.length}` }, + { vmid: 'og:description', property: 'og:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' }, + { vmid: 'og:image', property: 'og:image', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` }, + { vmid: 'og:image:secure_url', property: 'og:image:secure_url', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` } + ] + }; + } + return { + title: `${this.name ? this.name : ''}`, + meta: [ + { vmid: 'theme-color', name: 'theme-color', content: '#30a9ed' }, + { vmid: 'twitter:card', name: 'twitter:card', content: 'summary' }, + { vmid: 'twitter:title', name: 'twitter:title', content: 'chibisafe' }, + { vmid: 'twitter:description', name: 'twitter:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' }, + { vmid: 'og:url', property: 'og:url', content: `${this.config.URL}/a/${this.$route.params.identifier}` }, + { vmid: 'og:title', property: 'og:title', content: 'chibisafe' }, + { vmid: 'og:description', property: 'og:description', content: 'A modern and self-hosted file upload service that can handle anything you throw at it. Fast uploads, file manager and sharing capabilities all crafted with a beautiful user experience in mind.' } + ] + }; + } +}; +</script> +<style lang="scss" scoped> + section.hero div.hero-body.align-top { + align-items: baseline; + flex-grow: 0; + padding-bottom: 0; + } + + div.loading-container { + justify-content: center; + display: flex; + } + .nsfw { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 50vh; + + h1 { + font-size: 2rem; + margin-bottom: 2rem; + } + p { + font-size: 1.5rem; + margin-bottom: 2rem; + text-align: center; + } + } +</style> diff --git a/src/site/pages/dashboard/account.vue b/src/site/pages/dashboard/account.vue new file mode 100644 index 0000000..3a9d37c --- /dev/null +++ b/src/site/pages/dashboard/account.vue @@ -0,0 +1,174 @@ +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + Account settings + </h2> + <hr> + + <b-field + label="Username" + message="Nothing to do here" + horizontal> + <b-input + class="chibisafe-input" + :value="user.username" + expanded + disabled /> + </b-field> + + <b-field + label="Current password" + message="If you want to change your password input the current one here" + horizontal> + <b-input + v-model="password" + class="chibisafe-input" + type="password" + expanded /> + </b-field> + + <b-field + label="New password" + message="Your new password" + horizontal> + <b-input + v-model="newPassword" + class="chibisafe-input" + type="password" + expanded /> + </b-field> + + <b-field + label="New password again" + message="Your new password once again" + horizontal> + <b-input + v-model="reNewPassword" + class="chibisafe-input" + type="password" + expanded /> + </b-field> + + <div class="mb2 mt2 text-center"> + <b-button + type="is-chibisafe" + @click="changePassword"> + Change password + </b-button> + </div> + + <b-field + label="API key" + message="This API key lets you use the service from other apps" + horizontal> + <b-field expanded> + <b-input + class="chibisafe-input" + :value="apiKey" + expanded + disabled /> + <p class="control"> + <b-button + type="is-chibisafe" + @click="copyKey"> + Copy + </b-button> + </p> + </b-field> + </b-field> + + <div class="mb2 mt2 text-center"> + <b-button + type="is-chibisafe" + @click="promptNewAPIKey"> + Request new API key + </b-button> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; + +export default { + components: { + Sidebar + }, + middleware: ['auth', ({ store }) => { + store.dispatch('auth/fetchCurrentUser'); + }], + data() { + return { + password: '', + newPassword: '', + reNewPassword: '' + }; + }, + computed: { + ...mapGetters({ apiKey: 'auth/getApiKey' }), + ...mapState({ + user: state => state.auth.user + }) + }, + metaInfo() { + return { title: 'Account' }; + }, + methods: { + ...mapActions({ + getUserSetttings: 'auth/fetchCurrentUser' + }), + async changePassword() { + const { password, newPassword, reNewPassword } = this; + + if (!password || !newPassword || !reNewPassword) { + this.$store.dispatch('alert/set', { + text: 'One or more fields are missing', + error: true + }); + return; + } + if (newPassword !== reNewPassword) { + this.$store.dispatch('alert/set', { + text: 'Passwords don\'t match', + error: true + }); + return; + } + + const response = await this.$store.dispatch('auth/changePassword', { + password, + newPassword + }); + + if (response) { + this.$buefy.toast.open(response.message); + } + }, + promptNewAPIKey() { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to regenerate your API key? Previously generated API keys will stop working. Make sure to write the new key down as this is the only time it will be displayed to you.', + onConfirm: () => this.requestNewAPIKey() + }); + }, + copyKey() { + this.$clipboard(this.apiKey); + this.$notifier.success('API key copied to clipboard'); + }, + async requestNewAPIKey() { + const response = await this.$store.dispatch('auth/requestAPIKey'); + this.$buefy.toast.open(response.message); + } + } +}; +</script> diff --git a/src/site/pages/dashboard/admin/file/_id.vue b/src/site/pages/dashboard/admin/file/_id.vue new file mode 100644 index 0000000..135d066 --- /dev/null +++ b/src/site/pages/dashboard/admin/file/_id.vue @@ -0,0 +1,176 @@ +<style lang="scss" scoped> + .underline { text-decoration: underline; } +</style> +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + File details + </h2> + <hr> + + <div class="columns"> + <div class="column is-6"> + <b-field + label="ID" + horizontal> + <span>{{ admin.file.id }}</span> + </b-field> + + <b-field + label="Name" + horizontal> + <span>{{ admin.file.name }}</span> + </b-field> + + <b-field + label="Original Name" + horizontal> + <span>{{ admin.file.original }}</span> + </b-field> + + <b-field + label="IP" + horizontal> + <span class="underline">{{ admin.file.ip }}</span> + </b-field> + + <b-field + label="Link" + horizontal> + <a + :href="admin.file.url" + target="_blank">{{ admin.file.url }}</a> + </b-field> + + <b-field + label="Size" + horizontal> + <span>{{ formatBytes(admin.file.size) }}</span> + </b-field> + + <b-field + label="Hash" + horizontal> + <span>{{ admin.file.hash }}</span> + </b-field> + + <b-field + label="Uploaded" + horizontal> + <span><timeago :since="admin.file.createdAt" /></span> + </b-field> + </div> + <div class="column is-6"> + <b-field + label="User Id" + horizontal> + <span>{{ admin.user.id }}</span> + </b-field> + + <b-field + label="Username" + horizontal> + <span>{{ admin.user.username }}</span> + </b-field> + + <b-field + label="Enabled" + horizontal> + <span>{{ admin.user.enabled }}</span> + </b-field> + + <b-field + label="Registered" + horizontal> + <span><timeago :since="admin.user.createdAt" /></span> + </b-field> + + <b-field + label="Files" + horizontal> + <span> + <nuxt-link :to="`/dashboard/admin/user/${admin.user.id}`">{{ admin.user.fileCount }}</nuxt-link> + </span> + </b-field> + </div> + </div> + + <div class="mb2 mt2 text-center"> + <b-button + v-if="admin.user.id !== auth.user.id" + type="is-danger" + @click="promptBanIP"> + Ban IP + </b-button> + <b-button + v-if="admin.user.id !== auth.user.id" + type="is-danger" + @click="promptDisableUser"> + Disable user + </b-button> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; + +export default { + components: { + Sidebar + }, + middleware: ['auth', 'admin', ({ route, store }) => { + try { + store.dispatch('admin/fetchFile', route.params.id); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }], + computed: mapState(['admin', 'auth']), + methods: { + promptDisableUser() { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to disable the account of the user that uploaded this file?', + onConfirm: () => this.disableUser() + }); + }, + disableUser() { + this.$handler.executeAction('admin/disableUser', this.user.id); + }, + promptBanIP() { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to ban the IP this file was uploaded from?', + onConfirm: () => this.banIP() + }); + }, + banIP() { + this.$handler.executeAction('admin/banIP', this.file.ip); + }, + formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + // eslint-disable-next-line no-mixed-operators + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; + } + } +}; +</script> diff --git a/src/site/pages/dashboard/admin/settings.vue b/src/site/pages/dashboard/admin/settings.vue new file mode 100644 index 0000000..71df2a6 --- /dev/null +++ b/src/site/pages/dashboard/admin/settings.vue @@ -0,0 +1,162 @@ +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + Service settings + </h2> + <hr> + + <b-field + label="Service name" + message="Please enter the name which this service is gonna be identified as" + horizontal> + <b-input + v-model="settings.serviceName" + class="chibisafe-input" + expanded /> + </b-field> + + <b-field + label="Upload folder" + message="Where to store the files relative to the working directory" + horizontal> + <b-input + v-model="settings.uploadFolder" + class="chibisafe-input" + expanded /> + </b-field> + + <b-field + label="Links per album" + message="Maximum links allowed per album" + horizontal> + <b-input + v-model="settings.linksPerAlbum" + class="chibisafe-input" + type="number" + expanded /> + </b-field> + + <b-field + label="Max upload size" + message="Maximum allowed file size in MB" + horizontal> + <b-input + v-model="settings.maxUploadSize" + class="chibisafe-input" + expanded /> + </b-field> + + <b-field + label="Filename length" + message="How many characters long should the generated filenames be" + horizontal> + <b-input + v-model="settings.filenameLength" + class="chibisafe-input" + expanded /> + </b-field> + + <b-field + label="Album link length" + message="How many characters a link for an album should have" + horizontal> + <b-input + v-model="settings.albumLinkLength" + class="chibisafe-input" + expanded /> + </b-field> + + <b-field + label="Generate thumbnails" + message="Generate thumbnails when uploading a file if possible" + horizontal> + <b-switch + v-model="settings.generateThumbnails" + :true-value="true" + :false-value="false" /> + </b-field> + + <b-field + label="Generate zips" + message="Allow generating zips to download entire albums" + horizontal> + <b-switch + v-model="settings.generateZips" + :true-value="true" + :false-value="false" /> + </b-field> + + <b-field + label="Public mode" + message="Enable anonymous uploades" + horizontal> + <b-switch + v-model="settings.publicMode" + :true-value="true" + :false-value="false" /> + </b-field> + + <b-field + label="Enable creating account" + message="Enable creating new accounts in the platform" + horizontal> + <b-switch + v-model="settings.enableAccounts" + :true-value="true" + :false-value="false" /> + </b-field> + + <div class="mb2 mt2 text-center"> + <button + class="button is-primary" + @click="promptRestartService"> + Save and restart service + </button> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; + +export default { + components: { + Sidebar + }, + middleware: ['auth', 'admin', ({ store }) => { + try { + store.dispatch('admin/fetchSettings'); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }], + metaInfo() { + return { title: 'Settings' }; + }, + computed: mapState({ + settings: state => state.admin.settings + }), + methods: { + promptRestartService() { + this.$buefy.dialog.confirm({ + message: 'Keep in mind that restarting only works if you have PM2 or something similar set up. Continue?', + onConfirm: () => this.restartService() + }); + }, + restartService() { + this.$handler.executeAction('admin/restartService'); + } + } +}; +</script> diff --git a/src/site/pages/dashboard/admin/user/_id.vue b/src/site/pages/dashboard/admin/user/_id.vue new file mode 100644 index 0000000..0ed3e86 --- /dev/null +++ b/src/site/pages/dashboard/admin/user/_id.vue @@ -0,0 +1,120 @@ +<style lang="scss" scoped> + .underline { text-decoration: underline; } +</style> +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + User details + </h2> + <hr> + + <b-field + label="User Id" + horizontal> + <span>{{ user.id }}</span> + </b-field> + + <b-field + label="Username" + horizontal> + <span>{{ user.username }}</span> + </b-field> + + <b-field + label="Enabled" + horizontal> + <span>{{ user.enabled }}</span> + </b-field> + + <b-field + label="Registered" + horizontal> + <span><timeago :since="user.createdAt" /></span> + </b-field> + + <b-field + label="Files" + horizontal> + <span>{{ user.files.length }}</span> + </b-field> + + <div class="mb2 mt2 text-center"> + <b-button + v-if="user.enabled" + type="is-danger" + @click="promptDisableUser"> + Disable user + </b-button> + <b-button + v-if="!user.enabled" + type="is-success" + @click="promptEnableUser"> + Enable user + </b-button> + </div> + + <Grid + v-if="user.files.length" + :files="user.files" /> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; +import Grid from '~/components/grid/Grid.vue'; + +export default { + components: { + Sidebar, + Grid + }, + middleware: ['auth', 'admin', ({ route, store }) => { + try { + store.dispatch('admin/fetchUser', route.params.id); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }], + data() { + return { + options: {} + }; + }, + computed: mapState({ + user: state => state.admin.user + }), + methods: { + promptDisableUser() { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to disable the account of this user?', + onConfirm: () => this.disableUser() + }); + }, + promptEnableUser() { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to enable the account of this user?', + onConfirm: () => this.enableUser() + }); + }, + disableUser() { + this.$handler.executeAction('admin/disableUser', this.user.id); + }, + enableUser() { + this.$handler.executeAction('admin/enableUser', this.user.id); + } + } +}; +</script> diff --git a/src/site/pages/dashboard/admin/users.vue b/src/site/pages/dashboard/admin/users.vue new file mode 100644 index 0000000..d86bffd --- /dev/null +++ b/src/site/pages/dashboard/admin/users.vue @@ -0,0 +1,247 @@ +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + Manage your users + </h2> + <hr> + + <div class="view-container"> + <b-table + :data="users" + :mobile-cards="true"> + <b-table-column + v-slot="props" + field="id" + label="Id" + centered> + {{ props.row.id }} + </b-table-column> + + <b-table-column + v-slot="props" + field="username" + label="Username" + centered> + <nuxt-link :to="`/dashboard/admin/user/${props.row.id}`"> + {{ props.row.username }} + </nuxt-link> + </b-table-column> + + <b-table-column + v-slot="props" + field="enabled" + label="Enabled" + centered> + <b-switch + :value="props.row.enabled" + @input="changeEnabledStatus(props.row)" /> + </b-table-column> + + <b-table-column + v-slot="props" + field="isAdmin" + label="Admin" + centered> + <b-switch + :value="props.row.isAdmin" + @input="changeIsAdmin(props.row)" /> + </b-table-column> + + <b-table-column + v-slot="props" + field="purge" + centered> + <b-button + type="is-danger" + @click="promptPurgeFiles(props.row)"> + Purge files + </b-button> + </b-table-column> + + <template slot="empty"> + <div class="has-text-centered"> + <i class="icon-misc-mood-sad" /> + </div> + <div class="has-text-centered"> + Nothing here + </div> + </template> + <template slot="footer"> + <div class="has-text-right"> + {{ users.length }} users + </div> + </template> + </b-table> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; + +export default { + components: { + Sidebar + }, + middleware: ['auth', 'admin', ({ route, store }) => { + try { + store.dispatch('admin/fetchUsers', route.params.id); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + }], + computed: mapState({ + users: state => state.admin.users, + config: state => state.config + }), + metaInfo() { + return { title: 'Uploads' }; + }, + methods: { + async changeEnabledStatus(row) { + if (row.enabled) { + this.$handler.executeAction('admin/disableUser', row.id); + } else { + this.$handler.executeAction('admin/enableUser', row.id); + } + }, + async changeIsAdmin(row) { + if (row.isAdmin) { + this.$handler.executeAction('admin/demoteUser', row.id); + } else { + this.$handler.executeAction('admin/promoteUser', row.id); + } + }, + promptPurgeFiles(row) { + this.$buefy.dialog.confirm({ + message: 'Are you sure you want to delete this user\'s files?', + onConfirm: () => this.purgeFiles(row) + }); + }, + async purgeFiles(row) { + this.$handler.executeAction('admin/purgeUserFiles', row.id); + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + div.view-container { + padding: 2rem; + } + div.album { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; + + div.arrow-container { + width: 2em; + height: 64px; + position: relative; + cursor: pointer; + + i { + border: 2px solid $defaultTextColor; + border-right: 0; + border-top: 0; + display: block; + height: 1em; + position: absolute; + transform: rotate(-135deg); + transform-origin: center; + width: 1em; + z-index: 4; + top: 22px; + transition: transform 0.1s linear; + + &.active { + transform: rotate(-45deg); + } + } + } + div.thumb { + width: 64px; + height: 64px; + box-shadow: $boxShadowLight; + } + + div.info { + margin-left: 15px; + h4 { + font-size: 1.5rem; + a { + color: $defaultTextColor; + font-weight: 400; + &:hover { text-decoration: underline; } + } + } + span { display: block; } + span:nth-child(3) { + font-size: 0.9rem; + } + } + + div.latest { + flex-grow: 1; + justify-content: flex-end; + display: flex; + margin-left: 15px; + + span.no-files { + font-size: 1.5em; + color: #b1b1b1; + padding-top: 17px; + } + + div.more { + width: 64px; + height: 64px; + background: white; + display: flex; + align-items: center; + padding: 10px; + text-align: center; + a { + line-height: 1rem; + color: $defaultTextColor; + &:hover { text-decoration: underline; } + } + } + } + + div.details { + flex: 0 1 100%; + padding-left: 2em; + padding-top: 1em; + min-height: 50px; + + .b-table { + padding: 2em 0em; + + .table-wrapper { + box-shadow: $boxShadowLight; + } + } + } + } + + div.column > h2.subtitle { padding-top: 1px; } + + .b-table { + .table-wrapper { + box-shadow: $boxShadowLight; + } + } +</style> diff --git a/src/site/pages/dashboard/albums/_id.vue b/src/site/pages/dashboard/albums/_id.vue new file mode 100644 index 0000000..cf27a15 --- /dev/null +++ b/src/site/pages/dashboard/albums/_id.vue @@ -0,0 +1,128 @@ +<style lang="scss" scoped> + .albumsModal .columns .column { padding: .25rem; } +</style> + +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <nav class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title is-3"> + {{ images.albumName }} + </h1> + </div> + <div class="level-item"> + <h2 class="subtitle is-5"> + ({{ totalFiles }} files) + </h2> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <Search :hidden-hints="['album']" /> + </div> + </div> + </nav> + + <hr> + + <Grid + v-if=" + totalFiles" + :files="images.files" + :total="totalFiles"> + <template v-slot:pagination> + <b-pagination + v-if="shouldPaginate" + :total="totalFiles" + :per-page="limit" + :current.sync="current" + range-before="2" + range-after="2" + class="pagination-slot" + icon-prev="icon-interface-arrow-left" + icon-next="icon-interface-arrow-right" + icon-pack="icon" + aria-next-label="Next page" + aria-previous-label="Previous page" + aria-page-label="Page" + aria-current-label="Current page" /> + </template> + </Grid> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; + +import Sidebar from '~/components/sidebar/Sidebar.vue'; +import Grid from '~/components/grid/Grid.vue'; +import Search from '~/components/search/Search.vue'; + +export default { + components: { + Sidebar, + Grid, + Search + }, + middleware: ['auth', ({ route, store }) => { + store.commit('images/resetState'); + store.dispatch('images/fetchByAlbumId', { id: route.params.id }); + }], + data() { + return { + current: 1 + }; + }, + computed: { + ...mapGetters({ + totalFiles: 'images/getTotalFiles', + shouldPaginate: 'images/shouldPaginate', + limit: 'images/getLimit' + }), + ...mapState(['images']), + id() { + return this.$route.params.id; + } + }, + metaInfo() { + return { title: 'Album' }; + }, + watch: { + current: 'fetchPaginate' + }, + methods: { + ...mapActions({ + fetch: 'images/fetchByAlbumId' + }), + fetchPaginate() { + this.fetch({ id: this.id, page: this.current }); + } + } +}; +</script> + +<style lang="scss" scoped> + div.grid { + margin-bottom: 1rem; + } + + .pagination-slot { + padding: 1rem 0; + } +</style> + +<style lang="scss"> + .pagination-slot > .pagination-previous, .pagination-slot > .pagination-next { + display: none !important; + } +</style> diff --git a/src/site/pages/dashboard/albums/index.vue b/src/site/pages/dashboard/albums/index.vue new file mode 100644 index 0000000..d2b424b --- /dev/null +++ b/src/site/pages/dashboard/albums/index.vue @@ -0,0 +1,110 @@ +<template> + <section class="hero is-fullheight dashboard"> + <div class="hero-body"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + Manage your albums + </h2> + <hr> + + <div class="search-container"> + <b-field> + <b-input + v-model="newAlbumName" + class="chibisafe-input" + placeholder="Album name..." + type="text" + @keyup.enter.native="createAlbum" /> + <p class="control"> + <button + outlined + class="button is-black" + :disabled="isCreatingAlbum" + @click="createAlbum"> + Create album + </button> + </p> + </b-field> + </div> + + <div class="view-container"> + <AlbumEntry + v-for="album in albums.list" + :key="album.id" + :album="album" /> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState, mapActions } from 'vuex'; +import Sidebar from '~/components/sidebar/Sidebar.vue'; +import AlbumEntry from '~/components/album/AlbumEntry.vue'; + +export default { + components: { + Sidebar, + AlbumEntry + }, + middleware: ['auth', ({ store }) => { + try { + store.dispatch('albums/fetch'); + } catch (e) { + this.alert({ text: e.message, error: true }); + } + }], + data() { + return { + newAlbumName: null, + isCreatingAlbum: false + }; + }, + computed: mapState(['config', 'albums']), + metaInfo() { + return { title: 'Uploads' }; + }, + methods: { + ...mapActions({ + alert: 'alert/set' + }), + async createAlbum() { + if (!this.newAlbumName || this.newAlbumName === '') return; + + this.isCreatingAlbum = true; + try { + const response = await this.$store.dispatch('albums/createAlbum', this.newAlbumName); + + this.alert({ text: response.message, error: false }); + } catch (e) { + this.alert({ text: e.message, error: true }); + } finally { + this.isCreatingAlbum = false; + this.newAlbumName = null; + } + } + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + div.view-container { + padding: 2rem; + } + + div.search-container { + padding: 1rem 2rem; + background-color: $base-2; + } + + div.column > h2.subtitle { padding-top: 1px; } +</style> diff --git a/src/site/pages/dashboard/index.vue b/src/site/pages/dashboard/index.vue new file mode 100644 index 0000000..0b60cdc --- /dev/null +++ b/src/site/pages/dashboard/index.vue @@ -0,0 +1,144 @@ +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns "> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <nav class="level"> + <div class="level-left"> + <div class="level-item"> + <h2 class="subtitle"> + Your uploaded files + </h2> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <Search @search="onSearch" /> + </div> + </div> + </nav> + <hr> + + <!-- <b-loading :active="images.isLoading" /> --> + + <Grid + v-if="totalFiles && !isLoading" + :files="images.files" + :enable-search="false" + class="grid"> + <template v-slot:pagination> + <b-pagination + v-if="shouldPaginate" + :total="totalFiles" + :per-page="limit" + :current.sync="current" + range-before="2" + range-after="2" + class="pagination-slot" + icon-prev="icon-interface-arrow-left" + icon-next="icon-interface-arrow-right" + icon-pack="icon" + aria-next-label="Next page" + aria-previous-label="Previous page" + aria-page-label="Page" + aria-current-label="Current page" /> + </template> + </Grid> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; + +import Sidebar from '~/components/sidebar/Sidebar.vue'; +import Grid from '~/components/grid/Grid.vue'; +import Search from '~/components/search/Search.vue'; + +export default { + components: { + Sidebar, + Grid, + Search + }, + middleware: ['auth', ({ store }) => { + store.commit('images/resetState'); + store.dispatch('images/fetch'); + }], + data() { + return { + current: 1, + isLoading: false, + search: '' + }; + }, + computed: { + ...mapGetters({ + totalFiles: 'images/getTotalFiles', + shouldPaginate: 'images/shouldPaginate', + limit: 'images/getLimit' + }), + ...mapState(['images']) + }, + metaInfo() { + return { title: 'Uploads' }; + }, + watch: { + current: 'fetchPaginate' + }, + created() { + this.filteredHints = this.hints; // fixes the issue where on pageload, suggestions wont load + }, + methods: { + ...mapActions({ + fetch: 'images/fetch' + }), + async fetchPaginate() { + this.isLoading = true; + await this.fetch(this.current); + this.isLoading = false; + }, + sanitizeQuery(qry) { + // remove spaces between a search type selector `album:` + // and the value (ex `tag: 123` -> `tag:123`) + return (qry || '').replace(/(\w+):\s+/gi, '$1:'); + }, + async onSearch(query) { + this.search = query; + + const sanitizedQ = this.sanitizeQuery(query); + // eslint-disable-next-line no-negated-condition + if (!sanitizedQ.length) { + this.current = 1; + await this.fetch(this.current); + } else { + this.$handler.executeAction('images/search', { + q: this.sanitizeQuery(query), + page: this.current + }); + } + } + } +}; +</script> + +<style lang="scss" scoped> + div.grid { + margin-bottom: 1rem; + } + + .pagination-slot { + padding: 1rem 0; + } +</style> + +<style lang="scss"> + .pagination-slot > .pagination-previous, .pagination-slot > .pagination-next { + display: none !important; + } +</style> diff --git a/src/site/pages/dashboard/tags/index.vue b/src/site/pages/dashboard/tags/index.vue new file mode 100644 index 0000000..8790f47 --- /dev/null +++ b/src/site/pages/dashboard/tags/index.vue @@ -0,0 +1,270 @@ +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + div.view-container { + padding: 2rem; + } + div.album { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; + + div.arrow-container { + width: 2em; + height: 64px; + position: relative; + cursor: pointer; + + i { + border: 2px solid $defaultTextColor; + border-right: 0; + border-top: 0; + display: block; + height: 1em; + position: absolute; + transform: rotate(-135deg); + transform-origin: center; + width: 1em; + z-index: 4; + top: 22px; + transition: transform 0.1s linear; + + &.active { + transform: rotate(-45deg); + } + } + } + div.thumb { + width: 64px; + height: 64px; + box-shadow: $boxShadowLight; + } + + div.info { + margin-left: 15px; + h4 { + font-size: 1.5rem; + a { + color: $defaultTextColor; + font-weight: 400; + &:hover { text-decoration: underline; } + } + } + span { display: block; } + span:nth-child(3) { + font-size: 0.9rem; + } + } + + div.latest { + flex-grow: 1; + justify-content: flex-end; + display: flex; + margin-left: 15px; + + span.no-files { + font-size: 1.5em; + color: #b1b1b1; + padding-top: 17px; + } + + div.more { + width: 64px; + height: 64px; + background: white; + display: flex; + align-items: center; + padding: 10px; + text-align: center; + a { + line-height: 1rem; + color: $defaultTextColor; + &:hover { text-decoration: underline; } + } + } + } + + div.details { + flex: 0 1 100%; + padding-left: 2em; + padding-top: 1em; + min-height: 50px; + + .b-table { + padding: 2em 0em; + + .table-wrapper { + box-shadow: $boxShadowLight; + } + } + } + } + + div.column > h2.subtitle { padding-top: 1px; } + + div.no-background { + background: none; + } +</style> +<style lang="scss"> + @import '~/assets/styles/_colors.scss'; + + .b-table { + .table-wrapper { + box-shadow: $boxShadowLight; + } + } +</style> + +<template> + <section class="section is-fullheight dashboard"> + <div class="container"> + <div class="columns"> + <div class="column is-narrow"> + <Sidebar /> + </div> + <div class="column"> + <h2 class="subtitle"> + Manage your tags + </h2> + <hr> + + <div class="search-container"> + <b-field> + <b-input + v-model="newTagName" + class="chibisafe-input" + placeholder="Tag name..." + type="text" + @keyup.enter.native="createTag" /> + <p class="control"> + <b-button + type="is-chibisafe" + @click="createTag"> + Create tags + </b-button> + </p> + </b-field> + </div> + + <div class="view-container"> + <div + v-for="tag in tags" + :key="tag.id" + class="album"> + <div + class="arrow-container" + @click="promptDeleteTag"> + <i class="icon-arrow" /> + </div> + <!-- + <div class="thumb"> + <figure class="image is-64x64 thumb"> + <img src="~/assets/images/blank.png"> + </figure> + </div> + --> + <div class="info"> + <h4> + <router-link :to="`/dashboard/tags/${tag.id}`"> + {{ tag.name }} + </router-link> + </h4> + <span>{{ tag.count || 0 }} files</span> + </div> + <!-- + <div class="latest is-hidden-mobile"> + <template v-if="album.fileCount > 0"> + <div v-for="file of album.files" + :key="file.id" + class="thumb"> + <figure class="image is-64x64"> + <a :href="file.url" + target="_blank"> + <img :src="file.thumbSquare"> + </a> + </figure> + </div> + <div v-if="album.fileCount > 5" + class="thumb more no-background"> + <router-link :to="`/dashboard/albums/${album.uuid}`">{{ album.fileCount - 5 }}+ more</router-link> + </div> + </template> + <template v-else> + <span class="no-files">Nothing to show here</span> + </template> + </div> + --> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import Sidebar from '~/components/sidebar/Sidebar.vue'; + +export default { + components: { + Sidebar + }, + middleware: 'auth', + data() { + return { + tags: [], + newTagName: null + }; + }, + computed: { + config() { + return this.$store.state.config; + } + }, + metaInfo() { + return { title: 'Tags' }; + }, + mounted() { + this.getTags(); + }, + methods: { + promptDeleteTag(id) { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Are you sure you want to delete this tag?', + onConfirm: () => this.promptPurgeTag(id) + }); + }, + promptPurgeTag(id) { + this.$buefy.dialog.confirm({ + type: 'is-danger', + message: 'Would you like to delete every file associated with this tag?', + cancelText: 'No', + confirmText: 'Yes', + onConfirm: () => this.deleteTag(id, true), + onCancel: () => this.deleteTag(id, false) + }); + }, + async deleteTag(id, purge) { + const response = await this.$axios.$delete(`tags/${id}/${purge ? 'purge' : ''}`); + this.getTags(); + return this.$buefy.toast.open(response.message); + }, + async createTag() { + if (!this.newTagName || this.newTagName === '') return; + const response = await this.$axios.$post('tag/new', + { name: this.newTagName }); + this.newTagName = null; + this.$buefy.toast.open(response.message); + this.getTags(); + }, + async getTags() { + const response = await this.$axios.$get('tags'); + for (const tag of response.tags) { + tag.isDetailsOpen = false; + } + this.tags = response.tags; + } + } +}; +</script> diff --git a/src/site/pages/faq.vue b/src/site/pages/faq.vue new file mode 100644 index 0000000..049cad7 --- /dev/null +++ b/src/site/pages/faq.vue @@ -0,0 +1,66 @@ +<template> + <!-- eslint-disable max-len --> + <div class="container has-text-left"> + <h2 class="subtitle"> + What is chibisafe? + </h2> + <article class="message"> + <div class="message-body"> + chibisafe is an easy to use, open source and completely free file upload service. We accept your files, photos, documents, anything, and give you back a shareable link for you to send to others. + </div> + </article> + + <h2 class="subtitle"> + Can I run my own chibisafe? + </h2> + <article class="message"> + <div class="message-body"> + Definitely. Head to <a target="_blank" href="https://github.com/WeebDev/chibisafe">our GitHub repo</a> and follow the instructions to clone, build and deploy it by yourself. It's super easy too! + </div> + </article> + + <h2 class="subtitle"> + How can I keep track of my uploads? + </h2> + <article class="message"> + <div class="message-body"> + Simply create a user on the site and every upload will be associated with your account, granting you access to your uploaded files through our dashboard. + </div> + </article> + + <h2 class="subtitle"> + What are albums? + </h2> + <article class="message"> + <div class="message-body"> + Albums are a simple way of sorting uploads together. Right now you can create albums through the dashboard and use them only with <a target="_blank" href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">our chrome extension</a> which will enable you to <strong>right click -> send to chibisafe</strong> or to a desired album if you have any. + </div> + </article> + + <h2 class="subtitle"> + Why should I use this? + </h2> + <article class="message"> + <div class="message-body"> + There are too many file upload services out there, and a lot of them rely on the foundations of pomf which is ancient. In a desperate and unsuccessful attempt of finding a good file uploader that's easily extendable, chibisafe was born. We give you control over your files, we give you a way to sort your uploads into albums for ease of access and we give you an api to use with ShareX or any other thing that let's you make POST requests. + </div> + </article> + </div> +</template> + +<script> +export default { + name: 'Faq', + data() { + return {}; + }, + metaInfo() { + return { title: 'Faq' }; + } +}; +</script> + +<style lang="scss" scoped> + @import '~/assets/styles/_colors.scss'; + article.message { background-color: #ffffff; } +</style> diff --git a/src/site/pages/index.vue b/src/site/pages/index.vue new file mode 100644 index 0000000..8bdd23d --- /dev/null +++ b/src/site/pages/index.vue @@ -0,0 +1,102 @@ +<template> + <div> + <div class="logoContainer"> + <Logo /> + </div> + <div class="leftSpacer"> + <div class="mainBlock"> + <div> + <h4>Blazing fast file uploader. For real.</h4> + <p> + A <strong>modern</strong> and self-hosted file upload service that can handle anything you throw at it. + </p> + <p> + With a fast API, chunked file uploads out of the box, beautiful masonry-style file manager and both individual and album sharing capabilities, this little tool was crafted with the best user experience in mind.<br> + </p> + <div class="mt4" /> + <Uploader v-if="config.publicMode || (!config.publicMode && loggedIn)" /> + <div + v-else + class="has-text-right is-size-4"> + This site has disabled public uploads. You need an account. + </div> + + <Links /> + </div> + </div> + </div> + </div> +</template> +<script> +import { mapState, mapGetters } from 'vuex'; + +import Logo from '~/components/logo/Logo.vue'; +import Uploader from '~/components/uploader/Uploader.vue'; +import Links from '~/components/home/links/Links.vue'; + +export default { + name: 'Home', + components: { + Logo, + Uploader, + Links + }, + data() { + return { albums: [] }; + }, + computed: { + ...mapGetters({ loggedIn: 'auth/isLoggedIn' }), + ...mapState(['config']) + } +}; +</script> +<style lang="scss" scoped> + .logoContainer { + position: fixed; + top: calc(45% - 188px); + left: calc(22% - 117px); + } + .leftSpacer { + width: 56%; + margin-left: auto; + position: relative; + .mainBlock { + height: calc(100vh - 52px); + position: relative; + margin: 0 5rem; + text-align: right; + > div { + position: absolute; + top: 25%; + } + } + p { + font-size: 1.25em; + margin-top: 1rem; + } + strong { + text-decoration: underline; + } + } + + @media (max-width: 1025px) { + .logoContainer { + position: relative; + top: 0; + left: 0; + text-align: center; + } + .leftSpacer { + width: 100%; + .mainBlock { + height: auto; + padding: 2rem 0; + > div { + top: 0rem; + position: relative; + text-align: center; + } + } + } + } +</style> diff --git a/src/site/pages/login.vue b/src/site/pages/login.vue new file mode 100644 index 0000000..1974263 --- /dev/null +++ b/src/site/pages/login.vue @@ -0,0 +1,156 @@ +<template> + <section class="section is-fullheight is-login"> + <div class="container"> + <h1 class="title"> + Dashboard Access + </h1> + <h2 class="subtitle mb5"> + Login to access your files and folders + </h2> + <div class="columns"> + <div class="column is-4 is-offset-4"> + <b-field> + <b-input + v-model="username" + class="chibisafe-input" + type="text" + placeholder="Username" + @keyup.enter.native="login" /> + </b-field> + <b-field> + <b-input + v-model="password" + class="chibisafe-input" + type="password" + placeholder="Password" + password-reveal + @keyup.enter.native="login" /> + </b-field> + + <p class="control has-addons is-pulled-right" /> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <router-link + v-if="config.userAccounts" + to="/register" + class="is-text"> + Don't have an account? + </router-link> + <span v-else>Registration is closed at the moment</span> + </div> + </div> + + <div class="level-right"> + <p class="level-item"> + <b-button + size="is-medium" + type="is-chibisafe" + @click="login"> + Login + </b-button> + </p> + </div> + </div> + </div> + </div> + </div> + + <!-- + <b-modal :active.sync="isMfaModalActive" + :canCancel="true" + has-modal-card> + <div class="card mfa"> + <div class="card-content"> + <div class="content"> + <p>Enter your Two-Factor code to proceed.</p> + <b-field> + <b-input v-model="mfaCode" + placeholder="Your MFA Code" + type="text" + @keyup.enter.native="mfa"/> + <p class="control"> + <button :class="{ 'is-loading': isLoading }" + class="button is-primary" + @click="mfa">Submit</button> + </p> + </b-field> + </div> + </div> + </div> + </b-modal> + --> + </section> +</template> + +<script> +import { mapState } from 'vuex'; + +export default { + name: 'Login', + data() { + return { + username: null, + password: null, + mfaCode: null, + isMfaModalActive: false, + isLoading: false + }; + }, + computed: mapState(['config', 'auth']), + metaInfo() { + return { title: 'Login' }; + }, + created() { + if (this.auth.loggedIn) { + this.redirect(); + } + }, + methods: { + async login() { + if (this.isLoading) return; + + const { username, password } = this; + if (!username || !password) { + this.$notifier.error('Please fill both fields before attempting to log in.'); + return; + } + + try { + this.isLoading = true; + await this.$store.dispatch('auth/login', { username, password }); + if (this.auth.loggedIn) { + this.redirect(); + } + } catch (e) { + this.$notifier.error(e.message); + } finally { + this.isLoading = false; + } + }, + /* + mfa() { + if (!this.mfaCode) return; + if (this.isLoading) return; + this.isLoading = true; + this.axios.post(`${this.$BASE_URL}/login/mfa`, { token: this.mfaCode }) + .then(res => { + this.$store.commit('token', res.data.token); + this.redirect(); + }) + .catch(err => { + this.isLoading = false; + this.$onPromiseError(err); + }); + }, */ + redirect() { + if (typeof this.$route.query.redirect !== 'undefined') { + this.$router.push(this.$route.query.redirect); + return; + } + this.$router.push('/dashboard'); + } + } +}; +</script> diff --git a/src/site/pages/logout.vue b/src/site/pages/logout.vue new file mode 100644 index 0000000..e6adbea --- /dev/null +++ b/src/site/pages/logout.vue @@ -0,0 +1,8 @@ +<script> +export default { + async created() { + await this.$store.dispatch('auth/logout'); + this.$router.replace('/login'); + } +}; +</script> diff --git a/src/site/pages/register.vue b/src/site/pages/register.vue new file mode 100644 index 0000000..5cffc54 --- /dev/null +++ b/src/site/pages/register.vue @@ -0,0 +1,115 @@ +<template> + <section class="section is-fullheight is-register"> + <div class="container"> + <h1 class="title"> + Dashboard Access + </h1> + <h2 class="subtitle mb5"> + Register for a new account + </h2> + <div class="columns"> + <div class="column is-4 is-offset-4"> + <b-field> + <b-input + v-model="username" + class="chibisafe-input" + type="text" + placeholder="Username" /> + </b-field> + <b-field> + <b-input + v-model="password" + class="chibisafe-input" + type="password" + placeholder="Password" + password-reveal /> + </b-field> + <b-field> + <b-input + v-model="rePassword" + class="chibisafe-input" + type="password" + placeholder="Re-type Password" + password-reveal + @keyup.enter.native="register" /> + </b-field> + + <div class="level"> + <!-- Left side --> + <div class="level-left"> + <div class="level-item"> + <router-link + to="/login" + class="is-text"> + Already have an account? + </router-link> + </div> + </div> + <!-- Right side --> + <div class="level-right"> + <p class="level-item"> + <b-button + size="is-medium" + type="is-chibisafe" + :disabled="isLoading" + @click="register"> + Register + </b-button> + </p> + </div> + </div> + </div> + </div> + </div> + </section> +</template> + +<script> +import { mapState } from 'vuex'; + +export default { + name: 'Register', + data() { + return { + username: null, + password: null, + rePassword: null, + isLoading: false + }; + }, + computed: mapState(['config', 'auth']), + metaInfo() { + return { title: 'Register' }; + }, + methods: { + async register() { + if (this.isLoading) return; + + if (!this.username || !this.password || !this.rePassword) { + this.$notifier.error('Please fill all fields before attempting to register.'); + return; + } + if (this.password !== this.rePassword) { + this.$notifier.error('Passwords don\'t match'); + return; + } + this.isLoading = true; + + try { + const response = await this.$store.dispatch('auth/register', { + username: this.username, + password: this.password + }); + + this.$notifier.success(response.message); + this.$router.push('/login'); + return; + } catch (error) { + this.$notifier.error(error.message); + } finally { + this.isLoading = false; + } + } + } +}; +</script> |