aboutsummaryrefslogtreecommitdiff
path: root/src/site
diff options
context:
space:
mode:
Diffstat (limited to 'src/site')
-rw-r--r--src/site/assets/images/blank.pngbin1892 -> 1479 bytes
-rw-r--r--src/site/assets/images/blank_darker.pngbin0 -> 862 bytes
-rw-r--r--src/site/assets/styles/_bulma_colors_extender.scss16
-rw-r--r--src/site/assets/styles/_colors.scss58
-rw-r--r--src/site/assets/styles/bulma-divider.scss164
-rw-r--r--src/site/assets/styles/style.scss237
-rw-r--r--src/site/components/album/AlbumDetails.vue211
-rw-r--r--src/site/components/album/AlbumEntry.vue187
-rw-r--r--src/site/components/footer/Footer.vue72
-rw-r--r--src/site/components/grid/Grid.vue604
-rw-r--r--src/site/components/grid/waterfall/Waterfall.vue261
-rw-r--r--src/site/components/grid/waterfall/WaterfallItem.vue52
-rw-r--r--src/site/components/image-modal/ImageInfo.vue179
-rw-r--r--src/site/components/loading/BulmaLoading.vue33
-rw-r--r--src/site/components/loading/CubeShadow.vue17
-rw-r--r--src/site/components/loading/Origami.vue18
-rw-r--r--src/site/components/loading/PingPong.vue26
-rw-r--r--src/site/components/loading/RotateSquare.vue13
-rw-r--r--src/site/components/navbar/Navbar.vue72
-rw-r--r--src/site/components/sidebar/Sidebar.vue116
-rw-r--r--src/site/components/uploader/Uploader.vue75
-rw-r--r--src/site/constants/alertTypes.js10
-rw-r--r--src/site/layouts/default.vue121
-rw-r--r--src/site/layouts/error.vue2
-rw-r--r--src/site/middleware/admin.js7
-rw-r--r--src/site/middleware/auth.js5
-rw-r--r--src/site/pages/a/_identifier.vue60
-rw-r--r--src/site/pages/dashboard/account.vue199
-rw-r--r--src/site/pages/dashboard/admin/file/_id.vue255
-rw-r--r--src/site/pages/dashboard/admin/settings.vue225
-rw-r--r--src/site/pages/dashboard/admin/user/_id.vue146
-rw-r--r--src/site/pages/dashboard/admin/users.vue266
-rw-r--r--src/site/pages/dashboard/albums/_id.vue136
-rw-r--r--src/site/pages/dashboard/albums/index.vue350
-rw-r--r--src/site/pages/dashboard/index.vue127
-rw-r--r--src/site/pages/dashboard/tags/index.vue166
-rw-r--r--src/site/pages/faq.vue24
-rw-r--r--src/site/pages/index.vue27
-rw-r--r--src/site/pages/login.vue134
-rw-r--r--src/site/pages/logout.vue8
-rw-r--r--src/site/pages/register.vue129
-rw-r--r--src/site/plugins/axios.js34
-rw-r--r--src/site/plugins/buefy.js1
-rw-r--r--src/site/plugins/flexsearch.js5
-rw-r--r--src/site/plugins/handler.js25
-rw-r--r--src/site/plugins/notifier.js25
-rw-r--r--src/site/plugins/nuxt-client-init.js2
-rw-r--r--src/site/plugins/vue-isyourpasswordsafe.js2
-rw-r--r--src/site/plugins/vue-timeago.js2
-rw-r--r--src/site/store/.eslintrc.json5
-rw-r--r--src/site/store/admin.js122
-rw-r--r--src/site/store/albums.js129
-rw-r--r--src/site/store/alert.js33
-rw-r--r--src/site/store/auth.js106
-rw-r--r--src/site/store/config.js18
-rw-r--r--src/site/store/images.js144
-rw-r--r--src/site/store/index.js61
57 files changed, 3602 insertions, 1920 deletions
diff --git a/src/site/assets/images/blank.png b/src/site/assets/images/blank.png
index 224a81c..f1ea2eb 100644
--- a/src/site/assets/images/blank.png
+++ b/src/site/assets/images/blank.png
Binary files differ
diff --git a/src/site/assets/images/blank_darker.png b/src/site/assets/images/blank_darker.png
new file mode 100644
index 0000000..61f2944
--- /dev/null
+++ b/src/site/assets/images/blank_darker.png
Binary files differ
diff --git a/src/site/assets/styles/_bulma_colors_extender.scss b/src/site/assets/styles/_bulma_colors_extender.scss
new file mode 100644
index 0000000..69dbd1e
--- /dev/null
+++ b/src/site/assets/styles/_bulma_colors_extender.scss
@@ -0,0 +1,16 @@
+// Import the initial variables
+@import "../../../node_modules/bulma/sass/utilities/initial-variables";
+@import "../../../node_modules/bulma/sass/utilities/functions";
+
+// Setup our custom colors
+$lolisafe: #323846;
+$lolisafe-invert: findColorInvert($lolisafe);
+
+// XXX: EXPERIMENTAL, CHECK IF WE NEED ORIGINAL PRIMARY ANYWHERE
+// $primary: $lolisafe;
+// $primary-invert: $lolisafe-invert;
+
+// declare custom colors
+$custom-colors: (
+ "lolisafe":($lolisafe, $lolisafe-invert)
+);
diff --git a/src/site/assets/styles/_colors.scss b/src/site/assets/styles/_colors.scss
index 6e40102..0bc7c5e 100644
--- a/src/site/assets/styles/_colors.scss
+++ b/src/site/assets/styles/_colors.scss
@@ -1,25 +1,71 @@
// $basePink: #EC1A55;
$base-1: #292e39;
-$base-2: #2E3440;
-$base-3: #3B4252;
-$base-4: #434C5E;
-$base-5: #4C566A;
+$base-2: #2e3440;
+$base-3: #3b4252;
+$base-4: #434c5e;
+$base-5: #4c566a;
$background: #1e2430;
$backgroundAccent: #20222b;
+$backgroundAccentLighter: #53555e;
$backgroundLight1: #f5f6f8;
+$scheme-main: $background;
+$scheme-main-bis: $backgroundAccent;
+$scheme-main-ter: $backgroundAccentLighter;
+
+// customize navbar
+$navbar-background-color: $backgroundAccent;
+$navbar-item-color: #f5f6f8;
+
// $defaultTextColor: #4a4a4a;
$defaultTextColor: rgb(236, 239, 244);
$textColor: #c7ccd8;
$textColorHighlight: white;
+$input-hover-color: $textColor;
+
$basePink: #ff015b;
$basePinkHover: rgb(196, 4, 71);
-$baseBlue: #30A9ED;
+$baseBlue: #30a9ed;
$baseBlueHover: rgb(21, 135, 201);
$uploaderDropdownColor: #797979;
-$boxShadow: 0 10px 15px rgba(4,39,107,0.2);
+$boxShadow: 0 10px 15px rgba(4, 39, 107, 0.2);
$boxShadowLight: rgba(15, 17, 21, 0.35) 0px 6px 9px 0px;
+
+// pagination
+$pagination-color: $defaultTextColor;
+
+$pagination-focus-color: $textColorHighlight;
+$pagination-focus-border-color: $textColorHighlight;
+
+$pagination-active-color: $textColorHighlight;
+$pagination-active-border-color: $textColorHighlight;
+
+$pagination-hover-color: $textColorHighlight;
+$pagination-hover-border-color: $textColorHighlight;
+
+$pagination-current-background-color: $base-3;
+$pagination-current-border-color: $base-2;
+
+// loading
+$loading-background: rgba(0, 0, 0, 0.8);
+$loading-background: rgba(40, 40, 40, 0.66);
+
+// dialogs
+$modal-card-title-color: $textColor;
+$modal-card-body-background-color: $background;
+$modal-card-head-background-color: $backgroundAccent;
+$modal-card-head-border-bottom: 1px solid rgba(255, 255, 255, 0.1098);
+$modal-card-foot-border-top: 1px solid rgba(255, 255, 255, 0.1098);
+
+// sidebar
+$sidebar-background: $base-1;
+$sidebar-box-shadow: none;
+
+//
+$menu-item-color: $textColor;
+$menu-item-hover-color: $textColorHighlight;
+$menu-item-active-background-color: $backgroundAccent;
diff --git a/src/site/assets/styles/bulma-divider.scss b/src/site/assets/styles/bulma-divider.scss
new file mode 100644
index 0000000..713c7e4
--- /dev/null
+++ b/src/site/assets/styles/bulma-divider.scss
@@ -0,0 +1,164 @@
+/*! @creativebulma/bulma-divider v1.1.0 | (c) 2020 Gaetan | MIT License | https://github.com/CreativeBulma/bulma-divider */
+@-webkit-keyframes spinAround {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(359deg);
+ }
+}
+
+@keyframes spinAround {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(359deg);
+ }
+}
+
+/* line 17, src/sass/app.sass */
+.divider {
+ position: relative;
+ display: flex;
+ align-items: center;
+ text-transform: uppercase;
+ color: #7a7a7a;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: .5px;
+ margin: 25px 0;
+}
+
+/* line 28, src/sass/app.sass */
+.divider::after, .divider::before {
+ content: '';
+ display: block;
+ flex: 1;
+ height: 1px;
+ background-color: #dbdbdb;
+}
+
+/* line 37, src/sass/app.sass */
+.divider:not(.is-right)::after {
+ margin-left: 10px;
+}
+
+/* line 41, src/sass/app.sass */
+.divider:not(.is-left)::before {
+ margin-right: 10px;
+}
+
+/* line 45, src/sass/app.sass */
+.divider.is-left::before {
+ display: none;
+}
+
+/* line 49, src/sass/app.sass */
+.divider.is-right::after {
+ display: none;
+}
+
+/* line 52, src/sass/app.sass */
+.divider.is-vertical {
+ flex-direction: column;
+ margin: 0 25px;
+}
+
+/* line 56, src/sass/app.sass */
+.divider.is-vertical::after, .divider.is-vertical::before {
+ height: auto;
+ width: 1px;
+}
+
+/* line 61, src/sass/app.sass */
+.divider.is-vertical::after {
+ margin-left: 0;
+ margin-top: 10px;
+}
+
+/* line 65, src/sass/app.sass */
+.divider.is-vertical::before {
+ margin-right: 0;
+ margin-bottom: 10px;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-white::after, .divider.is-white::before {
+ background-color: white;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-black::after, .divider.is-black::before {
+ background-color: #0a0a0a;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-light::after, .divider.is-light::before {
+ background-color: whitesmoke;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-dark::after, .divider.is-dark::before {
+ background-color: #363636;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-primary::after, .divider.is-primary::before {
+ background-color: #00d1b2;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-primary.is-light::after, .divider.is-primary.is-light::before {
+ background-color: #ebfffc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-link::after, .divider.is-link::before {
+ background-color: #3273dc;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-link.is-light::after, .divider.is-link.is-light::before {
+ background-color: #eef3fc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-info::after, .divider.is-info::before {
+ background-color: #3298dc;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-info.is-light::after, .divider.is-info.is-light::before {
+ background-color: #eef6fc;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-success::after, .divider.is-success::before {
+ background-color: #48c774;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-success.is-light::after, .divider.is-success.is-light::before {
+ background-color: #effaf3;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-warning::after, .divider.is-warning::before {
+ background-color: #ffdd57;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-warning.is-light::after, .divider.is-warning.is-light::before {
+ background-color: #fffbeb;
+}
+
+/* line 72, src/sass/app.sass */
+.divider.is-danger::after, .divider.is-danger::before {
+ background-color: #f14668;
+}
+
+/* line 80, src/sass/app.sass */
+.divider.is-danger.is-light::after, .divider.is-danger.is-light::before {
+ background-color: #feecf0;
+}
diff --git a/src/site/assets/styles/style.scss b/src/site/assets/styles/style.scss
index c067ef4..026f277 100644
--- a/src/site/assets/styles/style.scss
+++ b/src/site/assets/styles/style.scss
@@ -1,15 +1,24 @@
// Let's first take care of having the customized colors ready.
-@import "./_colors.scss";
+@import './_bulma_colors_extender.scss';
+@import './_colors.scss';
// Bulma/Buefy customization
-@import "../../../node_modules/bulma/sass/utilities/_all.sass";
+@import '../../../node_modules/bulma/sass/utilities/_all.sass';
$body-size: 14px !default;
-$family-primary: 'Nunito', BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
+$family-primary: 'Nunito', BlinkMacSystemFont, -apple-system, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell',
+ 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
$size-normal: 1rem;
-@import "../../../node_modules/bulma/bulma.sass";
-@import "../../../node_modules/buefy/src/scss/buefy.scss";
+/* @import '../../../node_modules/bulma/bulma.sass';
+@import '../../../node_modules/buefy/src/scss/buefy.scss'; */
+
+@import "~bulma";
+@import "~buefy/src/scss/buefy";
+
+@import '@mdi/font/css/materialdesignicons.css';
+
+@import './bulma-divider.scss';
html {
// font-size: 100%;
@@ -18,9 +27,9 @@ html {
}
a {
- color: #5E81AC;
+ color: #5e81ac;
&:hover {
- color: #81A1C1;
+ color: #81a1c1;
text-decoration: underline;
}
}
@@ -43,10 +52,18 @@ h4 {
}
@for $i from 1 through 10 {
- .mt#{$i} { margin-top: $i + em !important; }
- .mb#{$i} { margin-bottom: $i + em !important; }
- .ml#{$i} { margin-left: $i + em !important; }
- .mr#{$i} { margin-right: $i + em !important; }
+ .mt#{$i} {
+ margin-top: $i + em !important;
+ }
+ .mb#{$i} {
+ margin-bottom: $i + em !important;
+ }
+ .ml#{$i} {
+ margin-left: $i + em !important;
+ }
+ .mr#{$i} {
+ margin-right: $i + em !important;
+ }
}
.text-center {
@@ -58,8 +75,12 @@ hr {
height: 1px;
}
// Bulma color changes.
-.tooltip.is-top.is-primary:before { border-top: 5px solid #20222b; }
-.tooltip.is-primary:after { background: #20222b; }
+.tooltip.is-top.is-primary:before {
+ border-top: 5px solid #20222b;
+}
+.tooltip.is-primary:after {
+ background: #20222b;
+}
div#drag-overlay {
position: fixed;
@@ -93,7 +114,6 @@ div#drag-overlay {
}
}
-
section.hero {
&.dashboard {
// background-color: $backgroundLight1 !important;
@@ -103,10 +123,12 @@ section.hero {
}
}
-section input, section a.button {
+section input,
+section a.button {
font-size: 14px !important;
}
-section input, section p.control a.button {
+section input,
+section p.control a.button {
border-left: 0px !important;
border-top: 0px !important;
border-right: 0px !important;
@@ -114,13 +136,15 @@ section input, section p.control a.button {
box-shadow: 0 0 0 !important;
}
-section p.control a.button { margin-left: 10px !important; }
+section p.control a.button {
+ margin-left: 10px !important;
+}
section p.control button {
height: 100%;
font-size: 12px;
}
-.switch input[type=checkbox] + .check:before {
+.switch input[type='checkbox'] + .check:before {
background: #fbfbfb;
}
@@ -128,7 +152,8 @@ section p.control button {
Register and Login forms
*/
-section.hero.is-login, section.hero.is-register {
+section.hero.is-login,
+section.hero.is-register {
a {
font-size: 1.25em;
line-height: 2.5em;
@@ -174,45 +199,31 @@ section#register a.is-text {
font-size: 1.25em;
line-height: 2.5em;
}
-*/
+
.modal-card-head, .modal-card-foot {
background: $backgroundLight1;
}
+*/
.switch {
margin-top: 5px;
}
-.input, .taginput .taginput-container.is-focusable, .textarea, .select select {
- border: 2px solid #21252d;
- border-radius: .3em !important;
- background: rgba(0, 0, 0, 0.15);
- padding: 1rem;
- color: $textColor;
- height: 3rem;
- &:focus,
- &:hover {
- border: 2px solid #21252d;
- }
- &::placeholder {
- color: $textColor;
- }
-}
-button.button.is-primary {
+/* button.button.is-primary {
background-color: #323846;
border: 2px solid #21252d;
color: $textColor;
font-size: 1rem;
- border-top: 0;
- border-left: 0;
- border-right: 0;
+ border-top: 0;
+ border-left: 0;
+ border-right: 0;
&:hover {
background-color: $base-2;
}
&.big {
font-size: 1.25rem;
}
-}
+} */
svg.waves {
display: block;
bottom: -1px;
@@ -224,13 +235,16 @@ svg.waves {
user-select: none;
overflow: hidden;
}
-div.field-body > div.field { text-align: left; }
+div.field-body > div.field {
+ text-align: left;
+}
table.table {
background: $base-2;
color: $textColor;
border: 0;
thead {
- th, td {
+ th,
+ td {
color: $textColor;
}
}
@@ -244,7 +258,144 @@ table.table {
}
}
}
- th, td {
+ th,
+ td {
border-color: #ffffff1c;
}
}
+
+.lolisafe-input input,
+.lolisafe-select select,
+.lolisafe-textarea textarea {
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+ background: rgba(0, 0, 0, 0.15);
+ padding: 1rem;
+ color: $textColor;
+ height: 3rem;
+ &:focus,
+ &:hover {
+ border: 2px solid #21252d;
+ }
+ &::placeholder {
+ color: $textColor;
+ }
+}
+
+.lolisafe-input .icon {
+ color: #323846 !important;
+}
+
+
+// vue-bar
+.vb > .vb-dragger {
+ z-index: 5;
+ width: 12px;
+ right: 0;
+}
+
+.vb > .vb-dragger > .vb-dragger-styler {
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+ -webkit-transform: rotate3d(0, 0, 0, 0);
+ transform: rotate3d(0, 0, 0, 0);
+ -webkit-transition: background-color 100ms ease-out, margin 100ms ease-out, height 100ms ease-out;
+ transition: background-color 100ms ease-out, margin 100ms ease-out, height 100ms ease-out;
+ background-color: $backgroundAccent;
+ margin: 5px 5px 5px 0;
+ border-radius: 20px;
+ height: calc(100% - 10px);
+ display: block;
+}
+
+.vb.vb-scrolling-phantom > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+}
+
+.vb > .vb-dragger:hover > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+ margin: 0px;
+ height: 100%;
+}
+
+.vb.vb-dragging > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+ margin: 0px;
+ height: 100%;
+}
+
+.vb.vb-dragging-phantom > .vb-dragger > .vb-dragger-styler {
+ background-color: $backgroundAccentLighter;
+}
+
+.vb-content {
+ overflow: auto !important;
+}
+
+// helpers
+.has-text-default {
+ color: $textColor;
+}
+
+.has-text-default-highlight {
+ color: $textColorHighlight;
+}
+
+.is-height-max-content {
+ height: max-content;
+}
+
+.pagination a,
+.pagination a:hover {
+ text-decoration: none;
+}
+
+// fix control icons
+.control.has-icons-left .icon, .control.has-icons-right .icon {
+ height: 3rem;
+ padding-right: 1rem;
+}
+
+.is-marginless {
+ margin: 0 !important;
+}
+
+.fucking-opl-shut-up {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.lolisafe-on-border.field.is-floating-label .label:before {
+ background-color: $lolisafe;
+}
+
+.is-lolisafe.divider::after, .is-lolisafe.divider::before {
+ background-color: #21252d;
+}
+
+.lolisafe.taginput {
+ .taginput-container {
+ background-color: #21252d;
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+
+ input {
+ background-color: #21252d;
+ color: $textColor;
+ &::placeholder {
+ color: $textColor;
+ }
+ }
+
+ .icon {
+ padding-left: 15px;
+ }
+
+ &:focus,
+ &:hover,
+ &:active {
+ border: 2px solid #21252d;
+ }
+ }
+}
diff --git a/src/site/components/album/AlbumDetails.vue b/src/site/components/album/AlbumDetails.vue
new file mode 100644
index 0000000..b411f13
--- /dev/null
+++ b/src/site/components/album/AlbumDetails.vue
@@ -0,0 +1,211 @@
+<template>
+ <div class="details">
+ <h2>Public links for this album:</h2>
+
+ <b-table
+ :data="details.links || []"
+ :mobile-cards="true">
+ <template slot-scope="props">
+ <b-table-column
+ field="identifier"
+ label="Link"
+ centered>
+ <a
+ :href="`${config.URL}/a/${props.row.identifier}`"
+ target="_blank">
+ {{ props.row.identifier }}
+ </a>
+ </b-table-column>
+
+ <b-table-column
+ field="views"
+ label="Views"
+ centered>
+ {{ props.row.views }}
+ </b-table-column>
+
+ <b-table-column
+ field="enableDownload"
+ label="Allow download"
+ centered>
+ <b-switch
+ v-model="props.row.enableDownload"
+ @input="updateLinkOptions(albumId, props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ field="enabled"
+ numeric>
+ <button
+ :class="{ 'is-loading': isDeleting(props.row.identifier) }"
+ class="button is-danger"
+ :disabled="isDeleting(props.row.identifier)"
+ @click="promptDeleteAlbumLink(albumId, props.row.identifier)">
+ Delete link
+ </button>
+ </b-table-column>
+ </template>
+ <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="level is-paddingless">
+ <div class="level-left">
+ <div class="level-item">
+ <button
+ :class="{ 'is-loading': isCreatingLink }"
+ class="button is-primary"
+ style="float: left"
+ @click="createLink(albumId)">
+ Create new link
+ </button>
+ </div>
+ <div class="level-item">
+ <span class="has-text-default">{{ details.links.length }} / {{ config.maxLinksPerAlbum }} links created</span>
+ </div>
+ </div>
+
+ <div class="level-right">
+ <div class="level-item">
+ <button
+ class="button is-danger"
+ style="float: right"
+ @click="promptDeleteAlbum(albumId)">
+ Delete album
+ </button>
+ </div>
+ </div>
+ </div>
+ </template>
+ </b-table>
+ </div>
+</template>
+
+<script>
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ props: {
+ albumId: {
+ type: Number,
+ default: 0,
+ },
+ details: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ isCreatingLink: false,
+ isDeletingLinks: [],
+ };
+ },
+ computed: mapState(['config']),
+ methods: {
+ ...mapActions({
+ deleteAlbumAction: 'albums/deleteAlbum',
+ deleteAlbumLinkAction: 'albums/deleteLink',
+ updateLinkOptionsAction: 'albums/updateLinkOptions',
+ createLinkAction: 'albums/createLink',
+ alert: 'alert/set',
+ }),
+ promptDeleteAlbum(id) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to delete this album?',
+ onConfirm: () => this.deleteAlbum(id),
+ });
+ },
+ promptDeleteAlbumLink(albumId, identifier) {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to delete this album link?',
+ onConfirm: () => this.deleteAlbumLink(albumId, identifier),
+ });
+ },
+ async deleteAlbum(id) {
+ try {
+ const response = await this.deleteAlbumAction(id);
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ },
+ async deleteAlbumLink(albumId, identifier) {
+ this.isDeletingLinks.push(identifier);
+ try {
+ const response = await this.deleteAlbumLinkAction({ albumId, identifier });
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ } finally {
+ this.isDeletingLinks = this.isDeletingLinks.filter((e) => e !== identifier);
+ }
+ },
+ async createLink(albumId) {
+ this.isCreatingLink = true;
+ try {
+ const response = await this.createLinkAction(albumId);
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ } finally {
+ this.isCreatingLink = false;
+ }
+ },
+ async updateLinkOptions(albumId, linkOpts) {
+ try {
+ const response = await this.updateLinkOptionsAction({ albumId, linkOpts });
+
+ this.alert({ text: response.message, error: false });
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ },
+ isDeleting(identifier) {
+ return this.isDeletingLinks.indexOf(identifier) > -1;
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ div.details {
+ flex: 0 1 100%;
+ padding-left: 2em;
+ padding-top: 1em;
+ min-height: 50px;
+
+ .b-table {
+ padding: 2em 0em;
+
+ .table-wrapper {
+ -webkit-box-shadow: $boxShadowLight;
+ box-shadow: $boxShadowLight;
+ }
+ }
+ }
+</style>
+
+<style lang="scss">
+ @import '~/assets/styles/_colors.scss';
+
+ .b-table {
+ .table-wrapper {
+ -webkit-box-shadow: $boxShadowLight;
+ box-shadow: $boxShadowLight;
+ }
+ }
+</style>
diff --git a/src/site/components/album/AlbumEntry.vue b/src/site/components/album/AlbumEntry.vue
new file mode 100644
index 0000000..2723b49
--- /dev/null
+++ b/src/site/components/album/AlbumEntry.vue
@@ -0,0 +1,187 @@
+<template>
+ <div class="album">
+ <div
+ class="arrow-container"
+ @click="toggleDetails(album)">
+ <i
+ :class="{ active: isExpanded }"
+ class="icon-arrow" />
+ </div>
+ <div class="thumb">
+ <figure class="image is-64x64 thumb">
+ <img src="~/assets/images/blank_darker.png">
+ </figure>
+ </div>
+ <div class="info">
+ <h4>
+ <router-link :to="`/dashboard/albums/${album.id}`">
+ {{ album.name }}
+ </router-link>
+ </h4>
+ <span>
+ Created <span class="is-inline has-text-weight-semibold"><timeago :since="album.createdAt" /></span>
+ </span>
+ <span>{{ album.fileCount || 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.id}`">
+ {{ album.fileCount - 5 }}+ more
+ </router-link>
+ </div>
+ </template>
+ <template v-else>
+ <span class="no-files">Nothing to show here</span>
+ </template>
+ </div>
+
+ <AlbumDetails
+ v-if="isExpanded"
+ :details="getDetails(album.id)"
+ :albumId="album.id" />
+ </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex';
+import AlbumDetails from '~/components/album/AlbumDetails.vue';
+
+export default {
+ components: {
+ AlbumDetails,
+ },
+ props: {
+ album: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ ...mapGetters({
+ isExpandedGetter: 'albums/isExpanded',
+ getDetails: 'albums/getDetails',
+ }),
+ isExpanded() {
+ return this.isExpandedGetter(this.album.id);
+ },
+ },
+ methods: {
+ async toggleDetails(album) {
+ if (!this.isExpanded) {
+ await this.$store.dispatch('albums/fetchDetails', album.id);
+ }
+ this.$store.commit('albums/toggleExpandedState', album.id);
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ 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;
+
+ -webkit-transition: transform 0.1s linear;
+ -moz-transition: transform 0.1s linear;
+ -ms-transition: transform 0.1s linear;
+ -o-transition: transform 0.1s linear;
+ transition: transform 0.1s linear;
+
+ &.active {
+ transform: rotate(-45deg);
+ }
+ }
+ }
+
+ div.thumb {
+ width: 64px;
+ height: 64px;
+ -webkit-box-shadow: $boxShadowLight;
+ box-shadow: $boxShadowLight;
+ }
+
+ div.info {
+ margin-left: 15px;
+ text-align: left;
+ 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.no-background { background: none !important; }
+</style>
diff --git a/src/site/components/footer/Footer.vue b/src/site/components/footer/Footer.vue
index 9f51fee..19d18f2 100644
--- a/src/site/components/footer/Footer.vue
+++ b/src/site/components/footer/Footer.vue
@@ -1,12 +1,18 @@
<template>
+ <!-- eslint-disable max-len -->
<footer>
- <svg viewBox="0 0 1920 250"
+ <svg
+ viewBox="0 0 1920 250"
class="waves">
- <path d="M1920 250H0V0s126.707 78.536 349.975 80.05c177.852 1.203 362.805-63.874 553.803-63.874 290.517 0 383.458 57.712 603.992 61.408 220.527 3.696 278.059-61.408 412.23-17.239"
+
+ <path
+ d="M1920 250H0V0s126.707 78.536 349.975 80.05c177.852 1.203 362.805-63.874 553.803-63.874 290.517 0 383.458 57.712 603.992 61.408 220.527 3.696 278.059-61.408 412.23-17.239"
class="wave-1" />
- <path d="M1920 144s-467.917 116.857-1027.243-17.294C369.986 1.322 0 45.578 0 45.578V250h1920V144z"
+ <path
+ d="M1920 144s-467.917 116.857-1027.243-17.294C369.986 1.322 0 45.578 0 45.578V250h1920V144z"
class="wave-2" />
- <path d="M0 195.553s208.547-75.581 701.325-20.768c376.707 41.908 520.834-67.962 722.545-67.962 222.926 0 311.553 83.523 496.129 86.394V250H0v-54.447z"
+ <path
+ d="M0 195.553s208.547-75.581 701.325-20.768c376.707 41.908 520.834-67.962 722.545-67.962 222.926 0 311.553 83.523 496.129 86.394V250H0v-54.447z"
class="wave-3" />
</svg>
<div>
@@ -15,7 +21,8 @@
<div class="column is-narrow">
<h4>lolisafe</h4>
<span>© 2017-2020
- <a href="https://github.com/pitu"
+ <a
+ href="https://github.com/pitu"
class="no-block">Pitu</a>
</span><br>
<span>v{{ version }}</span>
@@ -24,22 +31,33 @@
<div class="columns is-gapless">
<div class="column" />
<div class="column">
- <nuxt-link to="/">Home</nuxt-link>
- <nuxt-link to="/faq">FAQ</nuxt-link>
+ <nuxt-link to="/">
+ Home
+ </nuxt-link>
+ <nuxt-link to="/faq">
+ FAQ
+ </nuxt-link>
</div>
<div class="column">
- <nuxt-link to="/dashboard">Dashboard</nuxt-link>
- <nuxt-link to="/dashboard">Files</nuxt-link>
- <nuxt-link to="/dashboard/albums">Albums</nuxt-link>
- <nuxt-link to="/dashboard/account">Account</nuxt-link>
+ <nuxt-link to="/dashboard">
+ Dashboard
+ </nuxt-link>
+ <nuxt-link to="/dashboard">
+ Files
+ </nuxt-link>
+ <nuxt-link to="/dashboard/albums">
+ Albums
+ </nuxt-link>
+ <nuxt-link to="/dashboard/account">
+ Account
+ </nuxt-link>
</div>
<div class="column">
<a href="https://github.com/weebdev/lolisafe">GitHub</a>
- <a href="https://patreon.com/pitu">Patreon</a>
- <a href="https://discord.gg/5g6vgwn">Discord</a>
</div>
<div class="column">
- <a v-if="loggedIn"
+ <a
+ v-if="loggedIn"
@click="createShareXThing">ShareX Config</a>
<a href="https://chrome.google.com/webstore/detail/lolisafe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">Chrome Extension</a>
</div>
@@ -50,27 +68,32 @@
</div>
</footer>
</template>
+
<script>
+/* eslint-disable no-restricted-globals */
+
+import { mapState, mapGetters } from 'vuex';
import { saveAs } from 'file-saver';
+
export default {
computed: {
- loggedIn() {
- return this.$store.state.loggedIn;
- },
- version() {
- return this.$store.state.config.version;
- }
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState({
+ version: (state) => state.config.version,
+ serviceName: (state) => state.config.serviceName,
+ token: (state) => state.auth.token,
+ }),
},
methods: {
createShareXThing() {
const sharexFile = `{
- "Name": "${this.$store.state.config.serviceName}",
+ "Name": "${this.serviceName}",
"DestinationType": "ImageUploader, FileUploader",
"RequestType": "POST",
"RequestURL": "${location.origin}/api/upload",
"FileFormName": "files[]",
"Headers": {
- "authorization": "Bearer ${this.$store.state.token}",
+ "authorization": "Bearer ${this.token}",
"accept": "application/vnd.lolisafe.json"
},
"ResponseType": "Text",
@@ -79,10 +102,11 @@ export default {
}`;
const sharexBlob = new Blob([sharexFile], { type: 'application/octet-binary' });
saveAs(sharexBlob, `${location.hostname}.sxcu`);
- }
- }
+ },
+ },
};
</script>
+
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
footer {
diff --git a/src/site/components/grid/Grid.vue b/src/site/components/grid/Grid.vue
index d0a5ea3..1e427fc 100644
--- a/src/site/components/grid/Grid.vue
+++ b/src/site/components/grid/Grid.vue
@@ -1,156 +1,136 @@
<template>
<div>
- <div v-if="enableToolbar"
- class="toolbar">
- <div class="block">
- <b-radio v-model="showList"
- name="name"
- :native-value="true">
- List
- </b-radio>
- <b-radio v-model="showList"
- name="name"
- :native-value="false">
- Grid
- </b-radio>
+ <nav class="level">
+ <div class="level-left">
+ <div class="level-item">
+ <slot name="pagination" />
+ </div>
</div>
- </div>
+ <div v-if="enableToolbar" class="level-right toolbar">
+ <div class="level-item">
+ <div class="block">
+ <b-radio v-model="showList" name="name" :native-value="true">
+ List
+ </b-radio>
+ <b-radio v-model="showList" name="name" :native-value="false">
+ Grid
+ </b-radio>
+ </div>
+ </div>
+ </div>
+ </nav>
<template v-if="!showList">
- <Waterfall :gutterWidth="10"
- :gutterHeight="4">
- <!--
- TODO: Implement search based on originalName, albumName and tags
- <input v-if="enableSearch"
- v-model="searchTerm"
- type="text"
- placeholder="Search..."
- @input="search()"
- @keyup.enter="search()">
- -->
-
- <!-- TODO: Implement pagination -->
-
- <WaterfallItem v-for="(item, index) in gridFiles"
- v-if="showWaterfall"
- :key="index"
- :width="width"
- move-class="item-move">
+ <Waterfall
+ v-if="showWaterfall"
+ :gutterWidth="10"
+ :gutterHeight="4"
+ :options="{fitWidth: true}"
+ :itemWidth="width"
+ :items="gridFiles">
+ <template v-slot="{item}">
<template v-if="isPublic">
- <a :href="`${item.url}`"
- target="_blank">
+ <a
+ :href="`${item.url}`"
+ class="preview-container"
+ target="_blank"
+ @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)"
+ @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)">
+
<img :src="item.thumb ? item.thumb : blank">
- <span v-if="!item.thumb && item.name"
- class="extension">{{ item.name.split('.').pop() }}</span>
+ <div v-if="item.preview && isHovered(item.id)" class="preview">
+ <video ref="video" class="preview" autoplay loop muted>
+ <source :src="item.preview" type="video/mp4">
+ </video>
+ </div>
+
+ <span v-if="!item.thumb && item.name" class="extension">{{
+ item.name.split('.').pop()
+ }}</span>
</a>
</template>
<template v-else>
<img :src="item.thumb ? item.thumb : blank">
- <span v-if="!item.thumb && item.name"
- class="extension">{{ item.name.split('.').pop() }}</span>
- <div v-if="!isPublic"
+ <div v-if="item.preview && isHovered(item.id)" class="preview">
+ <video ref="video" class="preview" autoplay loop muted>
+ <source :src="item.preview" type="video/mp4">
+ </video>
+ </div>
+
+ <span v-if="!item.thumb && item.name" class="extension">{{ item.name.split('.').pop() }}</span>
+ <div
+ v-if="!isPublic"
:class="{ fixed }"
- class="actions">
- <b-tooltip label="Link"
- position="is-top">
- <a :href="`${item.url}`"
- target="_blank"
- class="btn">
+ class="actions"
+ @mouseenter.self.stop.prevent="item.preview && mouseOver(item.id)"
+ @mouseleave.self.stop.prevent="item.preview && mouseOut(item.id)">
+ <b-tooltip label="Link" position="is-top">
+ <a :href="`${item.url}`" target="_blank" class="btn">
<i class="icon-web-code" />
</a>
</b-tooltip>
- <b-tooltip label="Albums"
- position="is-top">
- <a class="btn"
- @click="openAlbumModal(item)">
- <i class="icon-interface-window" />
+ <b-tooltip label="Tags" position="is-top">
+ <a class="btn" @click="false && manageTags(item)">
+ <i class="icon-ecommerce-tag-c" />
</a>
</b-tooltip>
- <!--
- <b-tooltip label="Tags"
- position="is-top">
- <a @click="manageTags(item)">
- <i class="icon-ecommerce-tag-c" />
+ <b-tooltip label="Albums" position="is-top">
+ <a class="btn" @click="handleFileModal(item)">
+ <i class="icon-interface-window" />
</a>
</b-tooltip>
- -->
- <b-tooltip label="Delete"
- position="is-top">
- <a class="btn"
- @click="deleteFile(item, index)">
+ <b-tooltip label="Delete" position="is-top">
+ <a class="btn" @click="deleteFile(item)">
<i class="icon-editorial-trash-a-l" />
</a>
</b-tooltip>
- <b-tooltip v-if="user && user.isAdmin"
- label="More info"
- position="is-top"
- class="more">
+ <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more">
<nuxt-link :to="`/dashboard/admin/file/${item.id}`">
<i class="icon-interface-more" />
</nuxt-link>
</b-tooltip>
</div>
</template>
- </WaterfallItem>
+ </template>
</Waterfall>
- <button
- v-if="moreFiles"
- class="button is-primary"
- @click="loadMoreFiles">Load more</button>
</template>
<div v-else>
- <b-table
- :data="gridFiles || []"
- :mobile-cards="true">
+ <b-table :data="gridFiles || []" :mobile-cards="true">
<template slot-scope="props">
<template v-if="!props.row.hideFromList">
- <b-table-column field="url"
- label="URL">
- <a :href="props.row.url"
- target="_blank">{{ props.row.url }}</a>
+ <b-table-column field="url" label="URL">
+ <a :href="props.row.url" target="_blank">{{ props.row.url }}</a>
</b-table-column>
- <b-table-column field="albums"
- label="Albums"
- centered>
+ <b-table-column field="albums" label="Albums" centered>
<template v-for="(album, index) in props.row.albums">
- <nuxt-link :key="index"
- :to="`/dashboard/albums/${album.id}`">
+ <nuxt-link :key="index" :to="`/dashboard/albums/${album.id}`">
{{ album.name }}
</nuxt-link>
- <template v-if="index < props.row.albums.length - 1">, </template>
+ <template v-if="index < props.row.albums.length - 1">
+ ,
+ </template>
</template>
{{ props.row.username }}
</b-table-column>
- <b-table-column field="uploaded"
- label="Uploaded"
- centered>
+ <b-table-column field="uploaded" label="Uploaded" centered>
<span><timeago :since="props.row.createdAt" /></span>
</b-table-column>
- <b-table-column field="purge"
- centered>
- <b-tooltip label="Albums"
- position="is-top">
- <a class="btn"
- @click="openAlbumModal(props.row)">
+ <b-table-column field="purge" centered>
+ <b-tooltip label="Albums" position="is-top">
+ <a class="btn" @click="handleFileModal(props.row)">
<i class="icon-interface-window" />
</a>
</b-tooltip>
- <b-tooltip label="Delete"
- position="is-top"
- class="is-danger">
- <a class="is-danger"
- @click="deleteFile(props.row)">
+ <b-tooltip label="Delete" position="is-top" class="is-danger">
+ <a class="is-danger" @click="deleteFile(props.row)">
<i class="icon-editorial-trash-a-l" />
</a>
</b-tooltip>
- <b-tooltip v-if="user && user.isAdmin"
- label="More info"
- position="is-top"
- class="more">
+ <b-tooltip v-if="user && user.isAdmin" label="More info" position="is-top" class="more">
<nuxt-link :to="`/dashboard/admin/file/${props.row.id}`">
<i class="icon-interface-more" />
</nuxt-link>
@@ -167,283 +147,361 @@
</div>
</template>
<template slot="footer">
- <div class="has-text-right">
- {{ files.length }} files
+ <div class="has-text-right has-text-default">
+ Showing {{ files.length }} files ({{ total }} total)
</div>
</template>
</b-table>
<button
v-if="moreFiles"
class="button is-primary mt2"
- @click="loadMoreFiles">Load more</button>
+ @click="loadMoreFiles">
+ Load more
+ </button>
</div>
- <b-modal :active.sync="isAlbumsModalActive"
- :width="640"
- scroll="keep">
+ <b-modal :active.sync="isAlbumsModalActive" scroll="keep">
+ <ImageInfo :file="modalData.file" />
+ </b-modal>
+ <!-- <b-modal :active.sync="isAlbumsModalActive" :width="640" scroll="keep">
<div class="card albumsModal">
<div class="card-content">
<div class="content">
- <h3 class="subtitle">Select the albums this file should be a part of</h3>
+ <h3 class="subtitle">
+ Select the albums this file should be a part of
+ </h3>
<hr>
+
<div class="albums-container">
- <div v-for="(album, index) in albums"
- :key="index"
- class="album">
+ <div v-for="album in albums" :key="album.id" class="album">
<div class="field">
- <b-checkbox :value="isAlbumSelected(album.id)"
- @input="albumCheckboxClicked($event, album.id)">{{ album.name }}</b-checkbox>
+ <b-checkbox
+ :value="isAlbumSelected(album.id)"
+ @input="albumCheckboxClicked($event, album.id)">
+ {{ album.name }}
+ </b-checkbox>
</div>
</div>
</div>
</div>
</div>
</div>
- </b-modal>
+ </b-modal> -->
</div>
</template>
+
<script>
+import { mapState } from 'vuex';
+
import Waterfall from './waterfall/Waterfall.vue';
-import WaterfallItem from './waterfall/WaterfallItem.vue';
+import ImageInfo from '~/components/image-modal/ImageInfo.vue';
export default {
components: {
Waterfall,
- WaterfallItem
+ ImageInfo,
},
props: {
files: {
type: Array,
- default: () => []
+ default: () => [],
+ },
+ total: {
+ type: Number,
+ default: 0,
},
fixed: {
type: Boolean,
- default: false
+ default: false,
},
isPublic: {
type: Boolean,
- default: false
+ default: false,
},
width: {
type: Number,
- default: 150
+ default: 150,
},
enableSearch: {
type: Boolean,
- default: true
+ default: true,
},
enableToolbar: {
type: Boolean,
- default: true
- }
+ default: true,
+ },
},
data() {
return {
showWaterfall: true,
searchTerm: null,
showList: false,
- albums: [],
+ hoveredItems: [],
isAlbumsModalActive: false,
showingModalForFile: null,
- filesOffset: 0,
- filesOffsetEnd: 50,
- filesPerPage: 50
+ filesOffsetWaterfall: 0,
+ filesOffsetEndWaterfall: 50,
+ filesPerPageWaterfall: 50,
+ modalData: {
+ file: null,
+ tags: null,
+ albums: null,
+ },
};
},
computed: {
- user() {
- return this.$store.state.user;
- },
+ ...mapState({
+ user: (state) => state.auth.user,
+ albums: (state) => state.albums.tinyDetails,
+ images: (state) => state.images,
+ }),
blank() {
- return require('@/assets/images/blank2.jpg');
+ // eslint-disable-next-line global-require, import/no-unresolved
+ return require('@/assets/images/blank.png');
},
gridFiles() {
- return this.files.slice(this.filesOffset, this.filesOffsetEnd);
+ return this.files;
},
- moreFiles() {
- return this.files.length > this.filesOffsetEnd;
- }
+ },
+ created() {
+ this.getAlbums();
},
methods: {
- loadMoreFiles() {
- this.filesOffsetEnd = this.filesOffsetEnd + this.filesPerPage;
- },
async search() {
- const data = await this.$search.do(this.searchTerm, [
- 'name',
- 'original',
- 'type',
- 'albums:name'
- ]);
- console.log('> Search result data', data);
+ const data = await this.$search.do(this.searchTerm, ['name', 'original', 'type', 'albums:name']);
+ console.log('> Search result data', data); // eslint-disable-line no-console
},
- deleteFile(file, index) {
+ deleteFile(file) {
+ // this.$emit('delete', file);
this.$buefy.dialog.confirm({
title: 'Deleting file',
message: 'Are you sure you want to <b>delete</b> this file?',
confirmText: 'Delete File',
type: 'is-danger',
- hasIcon: true,
onConfirm: async () => {
- const response = await this.$axios.$delete(`file/${file.id}`);
- if (this.showList) {
- file.hideFromList = true;
- this.$forceUpdate();
- } else {
- this.showWaterfall = false;
- this.files.splice(index, 1);
- this.$nextTick(() => {
- this.showWaterfall = true;
- });
+ try {
+ const response = await this.$store.dispatch('images/deleteFile', file.id);
+
+ this.$buefy.toast.open(response.message);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
}
- return this.$buefy.toast.open(response.message);
- }
+ },
});
},
isAlbumSelected(id) {
- if (!this.showingModalForFile) return;
- const found = this.showingModalForFile.albums.find(el => el.id === id);
- return found ? found.id ? true : false : false;
+ if (!this.showingModalForFile) return false;
+ const found = this.showingModalForFile.albums.find((el) => el.id === id);
+ return !!(found && found.id);
},
async openAlbumModal(file) {
+ const { id } = file;
this.showingModalForFile = file;
this.showingModalForFile.albums = [];
- this.isAlbumsModalActive = true;
- const response = await this.$axios.$get(`file/${file.id}/albums`);
- this.showingModalForFile.albums = response.albums;
+ try {
+ await this.$store.dispatch('images/getFileAlbums', id);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ this.showingModalForFile.albums = this.images.fileAlbumsMap[id];
- this.getAlbums();
+ this.isAlbumsModalActive = true;
},
- async albumCheckboxClicked(value, id) {
- const response = await this.$axios.$post(`file/album/${value ? 'add' : 'del'}`, {
- albumId: id,
- fileId: this.showingModalForFile.id
- });
- this.$buefy.toast.open(response.message);
+ async albumCheckboxClicked(add, id) {
+ try {
+ let response;
+ if (add) {
+ response = await this.$store.dispatch('images/addToAlbum', {
+ albumId: id,
+ fileId: this.showingModalForFile.id,
+ });
+ } else {
+ response = await this.$store.dispatch('images/removeFromAlbum', {
+ albumId: id,
+ fileId: this.showingModalForFile.id,
+ });
+ }
- // Not the prettiest solution to refetch on each click but it'll do for now
- this.$parent.getFiles();
+ this.$buefy.toast.open(response.message);
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
},
async getAlbums() {
- const response = await this.$axios.$get(`albums/dropdown`);
- this.albums = response.albums;
- this.$forceUpdate();
- }
- }
+ try {
+ await this.$store.dispatch('albums/getTinyDetails');
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async handleFileModal(file) {
+ const { id } = file;
+
+ try {
+ await this.$store.dispatch('images/fetchFileMeta', id);
+ this.modalData.file = this.images.fileExtraInfoMap[id];
+ this.modalData.albums = this.images.fileAlbumsMap[id];
+ this.modalData.tags = this.images.fileTagsMap[id];
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ this.isAlbumsModalActive = true;
+ },
+ mouseOver(id) {
+ const foundIndex = this.hoveredItems.indexOf(id);
+ if (foundIndex > -1) return;
+ this.hoveredItems.push(id);
+ },
+ mouseOut(id) {
+ const foundIndex = this.hoveredItems.indexOf(id);
+ if (foundIndex > -1) this.hoveredItems.splice(foundIndex, 1);
+ },
+ isHovered(id) {
+ return this.hoveredItems.includes(id);
+ },
+ },
};
</script>
+
<style lang="scss" scoped>
- @import '~/assets/styles/_colors.scss';
- .item-move {
- transition: all .25s cubic-bezier(.55,0,.1,1);
- }
+@import '~/assets/styles/_colors.scss';
+.item-move {
+ transition: all 0.25s cubic-bezier(0.55, 0, 0.1, 1);
+}
- div.toolbar {
- padding: 1rem;
+div.toolbar {
+ padding: 1rem;
- .block {
- text-align: right;
- }
+ .block {
+ text-align: right;
}
+}
- span.extension {
- position: absolute;
- width: 100%;
- height: 100%;
- z-index: 0;
- top: 0;
- left: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 2rem;
- pointer-events: none;
- opacity: .75;
- max-width: 150px;
- }
+span.extension {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ top: 0;
+ left: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2rem;
+ pointer-events: none;
+ opacity: 0.75;
+ max-width: 150px;
+}
- div.actions {
- opacity: 0;
- -webkit-transition: opacity 0.1s linear;
- -moz-transition: opacity 0.1s linear;
- -ms-transition: opacity 0.1s linear;
- -o-transition: opacity 0.1s linear;
- transition: opacity 0.1s linear;
- position: absolute;
- top: 0px;
- left: 0px;
- width: 100%;
- height: calc(100% - 6px);
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- justify-content: center;
- align-items: center;
-
- span {
- padding: 3px;
- &.more {
- position: absolute;
- top: 0;
- right: 0;
- }
+div.preview {
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: calc(100% - 6px);
+ overflow: hidden;
+}
- &:nth-child(1), &:nth-child(2) {
- align-items: flex-end;
- }
+.preview-container {
+ display: inline-block;
+}
- &:nth-child(1), &:nth-child(3) {
- justify-content: flex-end;
- }
- a {
+div.actions {
+ opacity: 0;
+ -webkit-transition: opacity 0.1s linear;
+ -moz-transition: opacity 0.1s linear;
+ -ms-transition: opacity 0.1s linear;
+ -o-transition: opacity 0.1s linear;
+ transition: opacity 0.1s linear;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: calc(100% - 6px);
+ // background: rgba(0, 0, 0, 0.5);
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 60px),
+ linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0px, rgba(0, 0, 0, 0) 45px);
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+
+ span {
+ padding: 3px;
+
+ &.more {
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+
+ &:nth-child(1),
+ &:nth-child(2) {
+ align-items: flex-end;
+ padding-bottom: 10px;
+ }
+
+ &:nth-child(3),
+ &:nth-child(4) {
+ justify-content: flex-end;
+ padding-bottom: 10px;
+ }
+
+ a {
+ width: 30px;
+ height: 30px;
+ color: white;
+ justify-content: center;
+ align-items: center;
+ display: flex;
+ &.btn:before {
+ content: '';
width: 30px;
height: 30px;
- color: white;
- justify-content: center;
- align-items: center;
- display: flex;
- &.btn:before {
- content: '';
- width: 30px;
- height: 30px;
- border: 1px solid white;
- border-radius: 50%;
- position: absolute;
- }
+ border: 1px solid white;
+ border-radius: 50%;
+ position: absolute;
}
}
+ }
- &.fixed {
- position: relative;
- opacity: 1;
- background: none;
-
- a {
- width: auto;
- height: auto;
- color: $defaultTextColor;
- &:before {
- display: none;
- }
- }
+ &.fixed {
+ position: relative;
+ opacity: 1;
+ background: none;
+ a {
+ width: auto;
+ height: auto;
+ color: $defaultTextColor;
+ &:before {
+ display: none;
+ }
}
}
+}
- .albums-container {
- display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- .album {
- flex-basis: 33%;
- text-align: left;
- }
+.albums-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ .album {
+ flex-basis: 33%;
+ text-align: left;
}
-</style>
+}
-<style lang="scss">
- .waterfall-item:hover {
- div.actions {
- opacity: 1
- }
+.hidden {
+ display: none;
+}
+
+.waterfall {
+ margin: 0 auto;
+}
+
+.waterfall-item:hover {
+ div.actions {
+ opacity: 1;
}
+}
</style>
diff --git a/src/site/components/grid/waterfall/Waterfall.vue b/src/site/components/grid/waterfall/Waterfall.vue
index 8631ea5..79a330a 100644
--- a/src/site/components/grid/waterfall/Waterfall.vue
+++ b/src/site/components/grid/waterfall/Waterfall.vue
@@ -1,180 +1,133 @@
-<style>
- .waterfall {
- position: relative;
- }
-</style>
<template>
- <div class="waterfall">
- <slot />
+ <div ref="waterfall" class="waterfall">
+ <WaterfallItem
+ v-for="(item, index) in items"
+ :key="item.id"
+ :style="{ width: `${itemWidth}px`, marginBottom: `${gutterHeight}px` }"
+ :width="itemWidth">
+ <slot :item="item" />
+ </WaterfallItem>
</div>
</template>
<script>
-// import {quickSort, getMinIndex, _, sum} from './util'
-
-const quickSort = (arr, type) => {
- const left = [];
- const right = [];
- if (arr.length <= 1) {
- return arr;
- }
- const povis = arr[0];
- for (let i = 1; i < arr.length; i++) {
- if (arr[i][type] < povis[type]) {
- left.push(arr[i]);
- } else {
- right.push(arr[i]);
- }
- }
- return quickSort(left, type).concat(povis, quickSort(right, type))
-};
-
-const getMinIndex = arr => {
- let pos = 0;
- for (let i = 0; i < arr.length; i++) {
- if (arr[pos] > arr[i]) {
- pos = i;
- }
- }
- return pos;
-};
+import WaterfallItem from './WaterfallItem.vue';
-const _ = {
- on(el, type, func, capture = false) {
- el.addEventListener(type, func, capture);
- },
- off(el, type, func, capture = false) {
- el.removeEventListener(type, func, capture);
- }
-};
+const isBrowser = typeof window !== 'undefined';
+const Masonry = isBrowser ? window.Masonry || require('masonry-layout') : null;
+const imagesloaded = isBrowser ? require('imagesloaded') : null;
-const sum = arr => arr.reduce((sum, val) => sum + val);
export default {
name: 'Waterfall',
+ components: {
+ WaterfallItem,
+ },
props: {
- gutterWidth: {
- type: Number,
- default: 0
- },
- gutterHeight: {
- type: Number,
- default: 0
- },
- resizable: {
- type: Boolean,
- default: true
+ options: {
+ type: Object,
+ default: () => {},
},
- align: {
- type: String,
- default: 'center'
+ items: {
+ type: Array,
+ default: () => [],
},
- fixWidth: {
- type: Number
+ itemWidth: {
+ type: Number,
+ default: 150,
},
- minCol: {
+ gutterWidth: {
type: Number,
- default: 1
+ default: 10,
},
- maxCol: {
- type: Number
+ gutterHeight: {
+ type: Number,
+ default: 4,
},
- percent: {
- type: Array
- }
},
- data() {
- return {
- timer: null,
- colNum: 0,
- lastWidth: 0,
- percentWidthArr: []
- };
+ mounted() {
+ this.initializeMasonry();
+ this.imagesLoaded();
},
- created() {
- this.$on('itemRender', () => {
- if (this.timer) {
- clearTimeout(this.timer);
- }
- this.timer = setTimeout(() => {
- this.render();
- }, 0);
- });
+ updated() {
+ this.performLayout();
+ this.imagesLoaded();
},
- mounted() {
- this.resizeHandle();
- this.$watch('resizable', this.resizeHandle);
+ unmounted() {
+ this.masonry.destroy();
},
methods: {
- calulate(arr) {
- let pageWidth = this.fixWidth ? this.fixWidth : this.$el.offsetWidth;
- // 百分比布局计算
- if (this.percent) {
- this.colNum = this.percent.length;
- const total = sum(this.percent);
- this.percentWidthArr = this.percent.map(value => (value / total) * pageWidth);
- this.lastWidth = 0;
- // 正常布局计算
- } else {
- this.colNum = parseInt(pageWidth / (arr.width + this.gutterWidth));
- if (this.minCol && this.colNum < this.minCol) {
- this.colNum = this.minCol;
- this.lastWidth = 0;
- } else if (this.maxCol && this.colNum > this.maxCol) {
- this.colNum = this.maxCol;
- this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth;
- } else {
- this.lastWidth = pageWidth - (arr.width + this.gutterWidth) * this.colNum + this.gutterWidth;
- }
+ imagesLoaded() {
+ const node = this.$refs.waterfall;
+ imagesloaded(
+ node,
+ () => {
+ this.masonry.layout();
+ },
+ );
+ },
+ performLayout() {
+ const diff = this.diffDomChildren();
+ if (diff.removed.length > 0) {
+ this.masonry.remove(diff.removed);
+ this.masonry.reloadItems();
+ }
+ if (diff.appended.length > 0) {
+ this.masonry.appended(diff.appended);
+ this.masonry.reloadItems();
+ }
+ if (diff.prepended.length > 0) {
+ this.masonry.prepended(diff.prepended);
}
+ if (diff.moved.length > 0) {
+ this.masonry.reloadItems();
+ }
+ this.masonry.layout();
+ },
+ diffDomChildren() {
+ const oldChildren = this.domChildren.filter((element) => !!element.parentNode);
+ const newChildren = this.getNewDomChildren();
+ const removed = oldChildren.filter((oldChild) => !newChildren.includes(oldChild));
+ const domDiff = newChildren.filter((newChild) => !oldChildren.includes(newChild));
+ const prepended = domDiff.filter((newChild, index) => newChildren[index] === newChild);
+ const appended = domDiff.filter((el) => !prepended.includes(el));
+ let moved = [];
+ if (removed.length === 0) {
+ moved = oldChildren.filter((child, index) => index !== newChildren.indexOf(child));
+ }
+ this.domChildren = newChildren;
+ return {
+ old: oldChildren,
+ new: newChildren,
+ removed,
+ appended,
+ prepended,
+ moved,
+ };
},
- resizeHandle() {
- if (this.resizable) {
- _.on(window, 'resize', this.render, false);
- } else {
- _.off(window, 'resize', this.render, false);
+ initializeMasonry() {
+ if (!this.masonry) {
+ this.masonry = new Masonry(
+ this.$refs.waterfall,
+ {
+ columnWidth: this.itemWidth,
+ gutter: this.gutterWidth,
+ ...this.options,
+ },
+ );
+ this.domChildren = this.getNewDomChildren();
}
},
- render() {
- // 重新排序
- let childArr = [];
- childArr = this.$children.map(child => child.getMeta());
- childArr = quickSort(childArr, 'order');
- // 计算列数
- this.calulate(childArr[0])
- let offsetArr = Array(this.colNum).fill(0);
- // 渲染
- childArr.forEach(child => {
- let position = getMinIndex(offsetArr);
- // 百分比布局渲染
- if (this.percent) {
- let left = 0;
- child.el.style.width = `${this.percentWidthArr[position]}px`;
- if (position === 0) {
- left = 0;
- } else {
- for (let i = 0; i < position; i++) {
- left += this.percentWidthArr[i];
- }
- }
- child.el.style.left = `${left}px`;
- // 正常布局渲染
- } else {
- if (this.align === 'left') { // eslint-disable-line no-lonely-if
- child.el.style.left = `${position * (child.width + this.gutterWidth)}px`;
- } else if (this.align === 'right') {
- child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth}px`;
- } else {
- child.el.style.left = `${position * (child.width + this.gutterWidth) + this.lastWidth / 2}px`;
- }
- }
- if (child.height === 0) {
- return;
- }
- child.el.style.top = `${offsetArr[position]}px`;
- offsetArr[position] += (child.height + this.gutterHeight);
- this.$el.style.height = `${Math.max.apply(Math, offsetArr)}px`;
- });
- this.$emit('rendered', this);
- }
- }
+ getNewDomChildren() {
+ const node = this.$refs.waterfall;
+ const children = this.options && this.options.itemSelector
+ ? node.querySelectorAll(this.options.itemSelector) : node.children;
+ return Array.prototype.slice.call(children);
+ },
+ },
};
</script>
+
+<style lang="scss" scoped>
+.wfi {
+
+}
+</style>
diff --git a/src/site/components/grid/waterfall/WaterfallItem.vue b/src/site/components/grid/waterfall/WaterfallItem.vue
index a02ea1f..c5cade1 100644
--- a/src/site/components/grid/waterfall/WaterfallItem.vue
+++ b/src/site/components/grid/waterfall/WaterfallItem.vue
@@ -1,60 +1,10 @@
-<style>
- .waterfall-item {
- position: absolute;
- }
-</style>
<template>
<div class="waterfall-item">
<slot />
</div>
</template>
<script>
-import imagesLoaded from 'imagesloaded';
export default {
name: 'WaterfallItem',
- props: {
- order: {
- type: Number,
- default: 0
- },
- width: {
- type: Number,
- default: 150
- }
- },
- data() {
- return {
- itemWidth: 0,
- height: 0
- };
- },
- created() {
- this.$watch(() => this.height, this.emit);
- },
- mounted() {
- this.$el.style.display = 'none';
- this.$el.style.width = `${this.width}px`;
- this.emit();
- imagesLoaded(this.$el, () => {
- this.$el.style.left = '-9999px';
- this.$el.style.top = '-9999px';
- this.$el.style.display = 'block';
- this.height = this.$el.offsetHeight;
- this.itemWidth = this.$el.offsetWidth;
- });
- },
- methods: {
- emit() {
- this.$parent.$emit('itemRender');
- },
- getMeta() {
- return {
- el: this.$el,
- height: this.height,
- width: this.itemWidth,
- order: this.order
- };
- }
- }
-}
+};
</script>
diff --git a/src/site/components/image-modal/ImageInfo.vue b/src/site/components/image-modal/ImageInfo.vue
new file mode 100644
index 0000000..c9dba1a
--- /dev/null
+++ b/src/site/components/image-modal/ImageInfo.vue
@@ -0,0 +1,179 @@
+<template>
+ <div class="container has-background-lolisafe">
+ <div class="columns is-marginless">
+ <div class="column fucking-opl-shut-up">
+ <img src="https://placehold.it/1024x10024">
+ </div>
+ <div class="column is-one-third">
+ <div class="sticky">
+ <div class="divider is-lolisafe has-text-light">
+ File information
+ </div>
+ <b-field
+ label="ID"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.id }}</span>
+ </div>
+ </b-field>
+ <b-field
+ label="Name"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.name }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Original Name"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.original }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="IP"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.ip }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Link"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <a
+ class="fake-input"
+ :href="file.url"
+ target="_blank">{{ file.url }}</a>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Size"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ formatBytes(file.size) }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Hash"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input">{{ file.hash }}</span>
+ </div>
+ </b-field>
+
+ <b-field
+ label="Uploaded"
+ label-position="on-border"
+ type="is-lolisafe"
+ class="lolisafe-on-border">
+ <div class="control">
+ <span class="fake-input"><timeago :since="file.createdAt" /></span>
+ </div>
+ </b-field>
+ <div class="divider is-lolisafe has-text-light">
+ Albums
+ </div>
+
+ <div class="divider is-lolisafe has-text-light">
+ Tags
+ </div>
+ <b-field label="Add some tags">
+ <b-taginput
+ v-model="tags"
+ class="lolisafe"
+ ellipsis
+ icon="label"
+ placeholder="Add a tag" />
+ </b-field>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+import { mapState } from 'vuex';
+
+export default {
+ props: {
+ file: {
+ type: Object,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ tags: [],
+ };
+ },
+ computed: mapState(['images']),
+ methods: {
+ 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));
+
+ return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+ },
+ },
+};
+</script>
+
+<style lang="scss" scoped>
+@import '~/assets/styles/_colors.scss';
+.modal-content, .modal-card {
+ max-height: 100%;
+}
+
+.fake-input {
+ font-size: 1rem !important;
+ height: 2.5rem;
+ border-color: #323846; /* $lolisafe */
+ max-width: 100%;
+ width: 100%;
+ border-radius: 4px;
+ display: inline-block;
+ font-size: 1rem;
+ justify-content: flex-start;
+ line-height: 1.5;
+ padding-bottom: calc(0.375em - 1px);
+ padding-left: calc(0.625em - 1px);
+ padding-right: calc(0.625em - 1px);
+ padding-top: calc(0.375em - 1px);
+ background-color: #21252d;
+ border: 2px solid #21252d;
+ border-radius: 0.3em !important;
+
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.divider:first-child {
+ margin: 10px 0 25px;
+}
+</style>
diff --git a/src/site/components/loading/BulmaLoading.vue b/src/site/components/loading/BulmaLoading.vue
new file mode 100644
index 0000000..37cc5a5
--- /dev/null
+++ b/src/site/components/loading/BulmaLoading.vue
@@ -0,0 +1,33 @@
+<template>
+ <div class="loader-wrapper">
+ <div class="loader is-loading" />
+ </div>
+</template>
+
+<style lang="scss" scoped>
+ .loader-wrapper {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background: #fff;
+ opacity: 0;
+ z-index: -1;
+ transition: opacity .3s;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 6px;
+
+ .loader {
+ height: 80px;
+ width: 80px;
+ }
+
+ &.is-active {
+ opacity: 1;
+ z-index: 1;
+ }
+ }
+</style>
diff --git a/src/site/components/loading/CubeShadow.vue b/src/site/components/loading/CubeShadow.vue
index af31dac..bbfdb52 100644
--- a/src/site/components/loading/CubeShadow.vue
+++ b/src/site/components/loading/CubeShadow.vue
@@ -1,5 +1,6 @@
<template>
- <div :style="styles"
+ <div
+ :style="styles"
class="spinner spinner--cube-shadow" />
</template>
@@ -8,16 +9,16 @@ export default {
props: {
size: {
type: String,
- default: '60px'
+ default: '60px',
},
background: {
type: String,
- default: '#9C27B0'
+ default: '#9C27B0',
},
duration: {
type: String,
- default: '1.8s'
- }
+ default: '1.8s',
+ },
},
computed: {
styles() {
@@ -25,10 +26,10 @@ export default {
width: this.size,
height: this.size,
backgroundColor: this.background,
- animationDuration: this.duration
+ animationDuration: this.duration,
};
- }
- }
+ },
+ },
};
</script>
diff --git a/src/site/components/loading/Origami.vue b/src/site/components/loading/Origami.vue
index d1b523d..cd1c087 100644
--- a/src/site/components/loading/Origami.vue
+++ b/src/site/components/loading/Origami.vue
@@ -1,7 +1,9 @@
<template>
- <div :style="styles"
+ <div
+ :style="styles"
class="spinner spinner-origami">
- <div :style="innerStyles"
+ <div
+ :style="innerStyles"
class="spinner-inner loading">
<span class="slice" />
<span class="slice" />
@@ -18,21 +20,21 @@ export default {
props: {
size: {
type: String,
- default: '40px'
- }
+ default: '40px',
+ },
},
computed: {
innerStyles() {
- let size = parseInt(this.size);
+ const size = parseInt(this.size, 10);
return { transform: `scale(${(size / 60)})` };
},
styles() {
return {
width: this.size,
- height: this.size
+ height: this.size,
};
- }
- }
+ },
+ },
};
</script>
diff --git a/src/site/components/loading/PingPong.vue b/src/site/components/loading/PingPong.vue
index ac33e28..d562e9f 100644
--- a/src/site/components/loading/PingPong.vue
+++ b/src/site/components/loading/PingPong.vue
@@ -1,12 +1,14 @@
<template>
- <div :style="styles"
+ <div
+ :style="styles"
class="spinner spinner--ping-pong">
- <div :style="innerStyles"
+ <div
+ :style="innerStyles"
class="spinner-inner">
<div class="board">
- <div class="left"/>
- <div class="right"/>
- <div class="ball"/>
+ <div class="left" />
+ <div class="right" />
+ <div class="ball" />
</div>
</div>
</div>
@@ -17,22 +19,22 @@ export default {
props: {
size: {
type: String,
- default: '60px'
- }
+ default: '60px',
+ },
},
computed: {
innerStyles() {
- let size = parseInt(this.size);
+ const size = parseInt(this.size, 10);
return { transform: `scale(${size / 250})` };
},
styles() {
return {
width: this.size,
- height: this.size
+ height: this.size,
};
- }
- }
-}
+ },
+ },
+};
</script>
<style lang="scss" scoped>
diff --git a/src/site/components/loading/RotateSquare.vue b/src/site/components/loading/RotateSquare.vue
index 4da8300..089e01a 100644
--- a/src/site/components/loading/RotateSquare.vue
+++ b/src/site/components/loading/RotateSquare.vue
@@ -1,5 +1,6 @@
<template>
- <div :style="styles"
+ <div
+ :style="styles"
class="spinner spinner--rotate-square-2" />
</template>
@@ -8,18 +9,18 @@ export default {
props: {
size: {
type: String,
- default: '40px'
- }
+ default: '40px',
+ },
},
computed: {
styles() {
return {
width: this.size,
height: this.size,
- display: 'inline-block'
+ display: 'inline-block',
};
- }
- }
+ },
+ },
};
</script>
diff --git a/src/site/components/navbar/Navbar.vue b/src/site/components/navbar/Navbar.vue
index 48dfeb1..0f82200 100644
--- a/src/site/components/navbar/Navbar.vue
+++ b/src/site/components/navbar/Navbar.vue
@@ -1,95 +1,97 @@
<template>
- <nav :class="{ isWhite }"
- class="navbar is-transparent">
- <div class="navbar-brand">
- <a role="button"
- class="navbar-burger burger"
- aria-label="menu"
- aria-expanded="false"
- data-target="navbarBasicExample">
- <span aria-hidden="true" />
- <span aria-hidden="true" />
- <span aria-hidden="true" />
- </a>
- </div>
- <div class="navbar-menu">
- <div class="navbar-end">
+ <b-navbar
+ :class="{ isWhite }"
+ transparent>
+ <template slot="end">
+ <b-navbar-item tag="div">
<router-link
to="/"
class="navbar-item no-active"
exact>
Home
</router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
<router-link
- to="/"
+ to="/faq"
class="navbar-item no-active"
exact>
Docs
</router-link>
- <template v-if="loggedIn">
+ </b-navbar-item>
+ <template v-if="loggedIn">
+ <b-navbar-item tag="div">
<router-link
to="/dashboard"
class="navbar-item no-active"
exact>
Uploads
</router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
<router-link
to="/dashboard/albums"
class="navbar-item no-active"
exact>
Albums
</router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
<router-link
to="/dashboard/account"
class="navbar-item no-active"
exact>
Account
</router-link>
+ </b-navbar-item>
+ <b-navbar-item tag="div">
<router-link
to="/"
class="navbar-item no-active"
@click.native="logOut">
Logout
</router-link>
- </template>
- <template v-else>
+ </b-navbar-item>
+ </template>
+ <template v-else>
+ <b-navbar-item tag="div">
<router-link
class="navbar-item"
to="/login">
Login
</router-link>
- </template>
- </div>
- </div>
- </nav>
+ </b-navbar-item>
+ </template>
+ </template>
+ </b-navbar>
</template>
<script>
+import { mapState, mapGetters } from 'vuex';
+
export default {
props: {
isWhite: {
type: Boolean,
- default: false
- }
+ default: false,
+ },
},
data() {
return { hamburger: false };
},
computed: {
- loggedIn() {
- return this.$store.state.loggedIn;
- },
- config() {
- return this.$store.state.config;
- }
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState(['config']),
},
methods: {
- logOut() {
- this.$store.dispatch('logout');
- }
- }
+ async logOut() {
+ await this.$store.dispatch('auth/logout');
+ this.$router.replace('/login');
+ },
+ },
};
</script>
+
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
nav.navbar {
diff --git a/src/site/components/sidebar/Sidebar.vue b/src/site/components/sidebar/Sidebar.vue
index a2ad3f4..d586122 100644
--- a/src/site/components/sidebar/Sidebar.vue
+++ b/src/site/components/sidebar/Sidebar.vue
@@ -1,64 +1,80 @@
<template>
- <div class="dashboard-menu">
- <router-link to="/dashboard">
- <i class="icon-com-pictures" />Files
- </router-link>
- <router-link to="/dashboard/albums">
- <i class="icon-interface-window" />Albums
- </router-link>
- <!--
- <router-link to="/dashboard/tags">
- <i class="icon-ecommerce-tag-c" />Tags
- </router-link>
- -->
- <router-link to="/dashboard/account">
- <i class="icon-ecommerce-tag-c" />Account
- </router-link>
- <template v-if="user && user.isAdmin">
- <router-link to="/dashboard/admin/users">
- <i class="icon-setting-gear-a" />Users
- </router-link>
- <!--
- TODO: Dont wanna deal with this now
- <router-link to="/dashboard/admin/settings">
- <i class="icon-setting-gear-a" />Settings
- </router-link>
- -->
- </template>
- </div>
+ <b-menu class="dashboard-menu">
+ <b-menu-list label="Menu">
+ <b-menu-item
+ class="item"
+ icon="information-outline"
+ label="Dashboard"
+ tag="nuxt-link"
+ to="/dashboard"
+ exact />
+ <b-menu-item
+ class="item"
+ icon="image-multiple-outline"
+ label="Albums"
+ tag="nuxt-link"
+ to="/dashboard/albums"
+ exact />
+ <b-menu-item
+ class="item"
+ icon="tag-outline"
+ label="Tags"
+ tag="nuxt-link"
+ to="/dashboard/tags"
+ exact />
+ <b-menu-item icon="settings" expanded>
+ <template slot="label" slot-scope="props">
+ Administration
+ <b-icon class="is-pulled-right" :icon="props.expanded ? 'menu-down' : 'menu-up'" />
+ </template>
+ <b-menu-item icon="account" label="Users" tag="nuxt-link" to="/dashboard/admin/users" exact />
+ <b-menu-item icon="cog-outline" label="Settings" tag="nuxt-link" to="/dashboard/admin/settings" exact />
+ </b-menu-item>
+ <b-menu-item
+ class="item"
+ icon="account-cog-outline"
+ label="My account"
+ tag="nuxt-link"
+ to="/dashboard/account"
+ exact />
+ </b-menu-list>
+ <b-menu-list label="Actions">
+ <b-menu-item icon="exit-to-app" label="Logout" tag="nuxt-link" to="/logout" exact />
+ </b-menu-list>
+ </b-menu>
</template>
<script>
+import { mapState } from 'vuex';
+
export default {
- computed: {
- user() {
- return this.$store.state.user;
- }
- }
+ computed: mapState({
+ user: (state) => state.auth.user,
+ }),
+ methods: {
+ isRouteActive(id) {
+ if (this.$route.path.includes(id)) {
+ return true;
+ }
+ return false;
+ },
+ },
};
+
</script>
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
.dashboard-menu {
- padding: 2rem;
- border-radius: 8px;
+ ::v-deep a:hover {
+ cursor: pointer;
+ text-decoration: none;
+ }
- a {
- display: block;
- font-weight: 700;
- color: $textColor;
- position: relative;
- padding-left: 40px;
- height: 35px;
- &:hover{
- color: white;
- }
+ ::v-deep .icon {
+ margin-right: 0.5rem;
+ }
- i {
- position: absolute;
- font-size: 1.5em;
- top: -4px;
- left: 5px;
- }
+ ::v-deep .icon.is-pulled-right {
+ margin-right: 0;
}
hr { margin-top: 0.6em; }
diff --git a/src/site/components/uploader/Uploader.vue b/src/site/components/uploader/Uploader.vue
index 1b03ff8..7e2d446 100644
--- a/src/site/components/uploader/Uploader.vue
+++ b/src/site/components/uploader/Uploader.vue
@@ -1,7 +1,9 @@
<template>
- <div :class="{ 'has-files': alreadyAddedFiles }"
+ <div
+ :class="{ 'has-files': alreadyAddedFiles }"
class="uploader-wrapper">
- <b-select v-if="loggedIn"
+ <b-select
+ v-if="loggedIn"
v-model="selectedAlbum"
placeholder="Upload to album"
size="is-medium"
@@ -13,7 +15,8 @@
{{ album.name }}
</option>
</b-select>
- <dropzone v-if="showDropzone"
+ <dropzone
+ v-if="showDropzone"
id="dropzone"
ref="el"
:options="dropzoneOptions"
@@ -25,16 +28,22 @@
Add or drop more files
</label>
- <div id="template"
+ <div
+ id="template"
ref="template">
<div class="dz-preview dz-file-preview">
<div class="dz-details">
- <div class="dz-filename"><span data-dz-name /></div>
- <div class="dz-size"><span data-dz-size /></div>
+ <div class="dz-filename">
+ <span data-dz-name />
+ </div>
+ <div class="dz-size">
+ <span data-dz-size />
+ </div>
</div>
<div class="result">
<div class="openLink">
- <a class="link"
+ <a
+ class="link"
target="_blank">
Link
</a>
@@ -43,14 +52,16 @@
<div class="error">
<div>
<span>
- <span class="error-message"
+ <span
+ class="error-message"
data-dz-errormessage />
<i class="icon-web-warning" />
</span>
</div>
</div>
<div class="dz-progress">
- <span class="dz-upload"
+ <span
+ class="dz-upload"
data-dz-uploadprogress />
</div>
<!--
@@ -64,6 +75,8 @@
</template>
<script>
+import { mapState, mapGetters } from 'vuex';
+
import Dropzone from 'nuxt-dropzone';
import '~/assets/styles/dropzone.scss';
@@ -75,20 +88,15 @@ export default {
files: [],
dropzoneOptions: {},
showDropzone: false,
- albums: [],
- selectedAlbum: null
+ selectedAlbum: null,
};
},
computed: {
- config() {
- return this.$store.state.config;
- },
- token() {
- return this.$store.state.token;
- },
- loggedIn() {
- return this.$store.state.loggedIn;
- }
+ ...mapState({
+ config: (state) => state.config,
+ albums: (state) => state.albums.tinyDetails,
+ }),
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn', token: 'auth/getToken' }),
},
watch: {
loggedIn() {
@@ -96,7 +104,7 @@ export default {
},
selectedAlbum() {
this.updateDropzoneConfig();
- }
+ },
},
mounted() {
this.dropzoneOptions = {
@@ -119,7 +127,7 @@ export default {
maxFilesize: this.config.maxFileSize,
previewTemplate: this.$refs.template.innerHTML,
dictDefaultMessage: 'Drag & Drop your files or click to browse',
- headers: { Accept: 'application/vnd.lolisafe.json' }
+ headers: { Accept: 'application/vnd.lolisafe.json' },
};
this.showDropzone = true;
if (this.loggedIn) this.getAlbums();
@@ -129,8 +137,11 @@ export default {
Get all available albums so the user can upload directly to one (or several soon™) of them.
*/
async getAlbums() {
- const response = await this.$axios.$get(`albums/dropdown`);
- this.albums = response.albums;
+ try {
+ await this.$store.dispatch('albums/getTinyDetails');
+ } catch (e) {
+ this.$store.dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
this.updateDropzoneConfig();
},
@@ -143,14 +154,14 @@ export default {
this.$refs.el.setOption('headers', {
Accept: 'application/vnd.lolisafe.json',
Authorization: this.token ? `Bearer ${this.token}` : '',
- albumId: this.selectedAlbum ? this.selectedAlbum : null
+ albumId: this.selectedAlbum ? this.selectedAlbum : null,
});
},
/*
Dropzone stuff
*/
- dropzoneFilesAdded(files) {
+ dropzoneFilesAdded() {
this.alreadyAddedFiles = true;
},
dropzoneSuccess(file, response) {
@@ -159,8 +170,9 @@ export default {
dropzoneError(file, message, xhr) {
this.$store.dispatch('alert', {
text: 'There was an error uploading this file. Check the console.',
- error: true
+ error: true,
});
+ // eslint-disable-next-line no-console
console.error(file, message, xhr);
},
async dropzoneChunksUploaded(file, done) {
@@ -170,12 +182,11 @@ export default {
original: file.name,
size: file.size,
type: file.type,
- count: file.upload.totalChunkCount
- }]
+ count: file.upload.totalChunkCount,
+ }],
});
this.processResult(file, data);
- this.$forceUpdate();
return done();
},
@@ -194,8 +205,8 @@ export default {
this.$clipboard(response.url);
});
*/
- }
- }
+ },
+ },
};
</script>
<style lang="scss" scoped>
diff --git a/src/site/constants/alertTypes.js b/src/site/constants/alertTypes.js
new file mode 100644
index 0000000..1b830bc
--- /dev/null
+++ b/src/site/constants/alertTypes.js
@@ -0,0 +1,10 @@
+export default {
+ PRIMARY: 'is-primary',
+ INFO: 'is-info',
+ SUCCESS: 'is-success',
+ WARNING: 'is-warning',
+ ERROR: 'is-danger',
+ DARK: 'is-dark',
+ LIGHT: 'is-light',
+ WHITE: 'is-white',
+};
diff --git a/src/site/layouts/default.vue b/src/site/layouts/default.vue
index 1642980..0049b88 100644
--- a/src/site/layouts/default.vue
+++ b/src/site/layouts/default.vue
@@ -1,73 +1,84 @@
<template>
- <div v-bar>
- <div>
- <section class="hero is-fullheight has-text-centered">
- <Navbar :isWhite="true" />
- <div class="hero-body">
- <nuxt-child id="app" />
- </div>
- <div class="hero-foot">
- <Footer />
- </div>
- </section>
+ <div
+ v-bar
+ class="scroll-area">
+ <div class="default-body">
+ <Navbar :isWhite="true" />
+ <nuxt-child
+ id="app"
+ class="nuxt-app is-height-max-content" />
+ <Footer />
</div>
</div>
</template>
<script>
+import { mapState } from 'vuex';
import Navbar from '~/components/navbar/Navbar.vue';
-import Footer from '~/components/footer/Footer';
+import Footer from '~/components/footer/Footer.vue';
+
export default {
- components: { Navbar, Footer },
- computed: {
- config() {
- return this.$store.state.config;
- },
- alert() {
- return this.$store.state.alert;
- }
+ components: {
+ Navbar,
+ Footer,
},
- watch: {
- alert() {
- if (!this.alert) return;
+ computed: mapState(['config', 'alert']),
+ created() {
+ this.$store.watch((state) => state.alert.message, () => {
+ const { message, type, snackbar } = this.alert;
+
+ if (!message) return;
- this.$buefy.toast.open({
- duration: 3500,
- message: this.alert.text,
- position: 'is-bottom',
- type: this.alert.error ? 'is-danger' : 'is-success'
- });
+ if (snackbar) {
+ this.$buefy.snackbar.open({
+ duration: 3500,
+ position: 'is-bottom',
+ message,
+ type,
+ });
+ } else {
+ this.$buefy.toast.open({
+ duration: 3500,
+ position: 'is-bottom',
+ message,
+ type,
+ });
+ }
- setTimeout(() => {
- this.$store.dispatch('alert', null);
- }, 3500);
- }
+ this.$store.dispatch('alert/clear', null);
+ });
},
mounted() {
- console.log(`%c lolisafe %c v${this.config.version} %c`, 'background:#35495e ; padding: 1px; border-radius: 3px 0 0 3px; color: #fff', 'background:#ff015b; padding: 1px; border-radius: 0 3px 3px 0; color: #fff', 'background:transparent');
- }
+ // eslint-disable-next-line no-console
+ console.log(
+ `%c lolisafe %c v${this.config.version} %c`,
+ 'background:#35495e ; padding: 1px; border-radius: 3px 0 0 3px; color: #fff',
+ 'background:#ff015b; padding: 1px; border-radius: 0 3px 3px 0; color: #fff',
+ 'background:transparent',
+ );
+ },
};
</script>
+
<style lang="scss">
- html { overflow: hidden !important; }
- .is-fullheight { height: 100vh; }
- .hero-body {
- padding: 3rem 0 !important;
- #app {
- width: 100%;
- & > .container {
- margin-top: 5rem;
- }
- }
- > .hero {
- min-height: auto !important;
- height: auto !important;
- }
- }
- @import "~/assets/styles/style.scss";
- @import "~/assets/styles/icons.min.css";
+html {
+ overflow: hidden !important;
+}
+.is-fullheight {
+ min-height: 100vh !important;
+ height: max-content;
+}
+.nuxt-app > .section {
+ min-height: auto !important;
+ height: auto !important;
+}
+@import '~/assets/styles/style.scss';
+@import '~/assets/styles/icons.min.css';
</style>
<style lang="scss" scoped>
- .hero-body {
- align-items: baseline !important;
- }
+.default-body {
+ align-items: baseline !important;
+}
+.scroll-area {
+ height: 100vh;
+}
</style>
diff --git a/src/site/layouts/error.vue b/src/site/layouts/error.vue
index 77d188f..3f12c46 100644
--- a/src/site/layouts/error.vue
+++ b/src/site/layouts/error.vue
@@ -23,6 +23,6 @@
import Navbar from '~/components/navbar/Navbar.vue';
export default {
- components: { Navbar }
+ components: { Navbar },
};
</script>
diff --git a/src/site/middleware/admin.js b/src/site/middleware/admin.js
index fcac9c6..1a94b75 100644
--- a/src/site/middleware/admin.js
+++ b/src/site/middleware/admin.js
@@ -1,5 +1,6 @@
-export default function({ store, redirect }) {
+export default function ({ store, redirect }) {
// If the user is not authenticated
- if (!store.state.user) return redirect('/login');
- if (!store.state.user.isAdmin) return redirect('/dashboard');
+ if (!store.state.auth.user) return redirect('/login');
+ if (!store.state.auth.user.isAdmin) return redirect('/dashboard');
+ return true;
}
diff --git a/src/site/middleware/auth.js b/src/site/middleware/auth.js
index 58a372e..b2ecc68 100644
--- a/src/site/middleware/auth.js
+++ b/src/site/middleware/auth.js
@@ -1,6 +1,7 @@
-export default function({ store, redirect }) {
+export default function ({ store, redirect }) {
// If the user is not authenticated
- if (!store.state.loggedIn) {
+ if (!store.state.auth.loggedIn) {
return redirect('/login');
}
+ return true;
}
diff --git a/src/site/pages/a/_identifier.vue b/src/site/pages/a/_identifier.vue
index ea36852..3746cc6 100644
--- a/src/site/pages/a/_identifier.vue
+++ b/src/site/pages/a/_identifier.vue
@@ -16,42 +16,48 @@
</style>
<template>
- <section class="hero is-fullheight">
+ <section class="section is-fullheight">
<template v-if="files && files.length">
- <div class="hero-body align-top">
+ <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"
+ <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="hero-body">
- <div class="container">
- <Grid v-if="files && files.length"
- :files="files"
- :isPublic="true"
- :width="200"
- :enableSearch="false"
- :enableToolbar="false" />
- </div>
+ <div class="container">
+ <Grid
+ v-if="files && files.length"
+ :files="files"
+ :isPublic="true"
+ :width="200"
+ :enableSearch="false"
+ :enableToolbar="false" />
</div>
</template>
<template v-else>
- <div class="hero-body">
- <div class="container">
- <h1 class="title">:(</h1>
- <h2 class="subtitle">This album seems to be empty</h2>
- </div>
+ <div class="container">
+ <h1 class="title">
+ :(
+ </h1>
+ <h2 class="subtitle">
+ This album seems to be empty
+ </h2>
</div>
</template>
</section>
</template>
<script>
-import Grid from '~/components/grid/Grid.vue';
import axios from 'axios';
+import Grid from '~/components/grid/Grid.vue';
export default {
components: { Grid },
@@ -61,7 +67,7 @@ export default {
computed: {
config() {
return this.$store.state.config;
- }
+ },
},
async asyncData({ app, params, error }) {
try {
@@ -71,7 +77,7 @@ export default {
name: data.name,
downloadEnabled: data.downloadEnabled,
files: data.files,
- downloadLink
+ downloadLink,
};
} catch (err) {
console.log('Error when retrieving album', err);
@@ -94,8 +100,8 @@ export default {
{ 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'}` }
- ]
+ { vmid: 'og:image:secure_url', property: 'og:image:secure_url', content: `${this.files.length > 0 ? this.files[0].thumbSquare : '/public/images/share.jpg'}` },
+ ],
};
}
return {
@@ -107,9 +113,9 @@ export default {
{ 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: 'lolisafe' },
- { 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: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>
diff --git a/src/site/pages/dashboard/account.vue b/src/site/pages/dashboard/account.vue
index 6ecc885..5d2f064 100644
--- a/src/site/pages/dashboard/account.vue
+++ b/src/site/pages/dashboard/account.vue
@@ -1,64 +1,94 @@
<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">Account settings</h2>
- <hr>
+ <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 v-model="user.username"
- expanded
- disabled />
- </b-field>
+ <b-field
+ label="Username"
+ message="Nothing to do here"
+ horizontal>
+ <b-input
+ class="lolisafe-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="user.password"
- type="password"
- expanded />
- </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="lolisafe-input"
+ type="password"
+ expanded />
+ </b-field>
- <b-field label="New password"
- message="Your new password"
- horizontal>
- <b-input v-model="user.newPassword"
- type="password"
- expanded />
- </b-field>
+ <b-field
+ label="New password"
+ message="Your new password"
+ horizontal>
+ <b-input
+ v-model="newPassword"
+ class="lolisafe-input"
+ type="password"
+ expanded />
+ </b-field>
- <b-field label="New password again"
- message="Your new password once again"
- horizontal>
- <b-input v-model="user.reNewPassword"
- type="password"
- expanded />
- </b-field>
+ <b-field
+ label="New password again"
+ message="Your new password once again"
+ horizontal>
+ <b-input
+ v-model="reNewPassword"
+ class="lolisafe-input"
+ type="password"
+ expanded />
+ </b-field>
- <div class="mb2 mt2 text-center">
- <button class="button is-primary"
- @click="changePassword">Change password</button>
- </div>
+ <div class="mb2 mt2 text-center">
+ <b-button
+ type="is-lolisafe"
+ @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-input v-model="user.apiKey"
+ <b-field
+ label="API key"
+ message="This API key lets you use the service from other apps"
+ horizontal>
+ <b-field expanded>
+ <b-input
+ class="lolisafe-input"
+ :value="apiKey"
expanded
disabled />
+ <p class="control">
+ <b-button
+ type="is-lolisafe"
+ @click="copyKey">
+ Copy
+ </b-button>
+ </p>
</b-field>
+ </b-field>
- <div class="mb2 mt2 text-center">
- <button class="button is-primary"
- @click="promptNewAPIKey">Request new API key</button>
- </div>
+ <div class="mb2 mt2 text-center">
+ <b-button
+ type="is-lolisafe"
+ @click="promptNewAPIKey">
+ Request new API key
+ </b-button>
</div>
</div>
</div>
@@ -67,65 +97,78 @@
</template>
<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
- Sidebar
+ Sidebar,
},
- middleware: 'auth',
+ middleware: ['auth', ({ store }) => {
+ store.dispatch('auth/fetchCurrentUser');
+ }],
data() {
return {
- user: {}
+ password: '',
+ newPassword: '',
+ reNewPassword: '',
};
},
+ computed: {
+ ...mapGetters({ 'apiKey': 'auth/getApiKey' }),
+ ...mapState({
+ user: (state) => state.auth.user,
+ }),
+ },
metaInfo() {
return { title: 'Account' };
},
- mounted() {
- this.getUserSetttings();
- },
methods: {
- async getUserSetttings() {
- const response = await this.$axios.$get(`users/me`);
- this.user = response.user;
- },
+ ...mapActions({
+ getUserSetttings: 'auth/fetchCurrentUser',
+ }),
async changePassword() {
- if (!this.user.password || !this.user.newPassword || !this.user.reNewPassword) {
- this.$store.dispatch('alert', {
+ const { password, newPassword, reNewPassword } = this;
+
+ if (!password || !newPassword || !reNewPassword) {
+ this.$store.dispatch('alert/set', {
text: 'One or more fields are missing',
- error: true
+ error: true,
});
return;
}
- if (this.user.newPassword !== this.user.reNewPassword) {
- this.$store.dispatch('alert', {
+ if (newPassword !== reNewPassword) {
+ this.$store.dispatch('alert/set', {
text: 'Passwords don\'t match',
- error: true
+ error: true,
});
return;
}
- const response = await this.$axios.$post(`user/password/change`,
- {
- password: this.user.password,
- newPassword: this.user.newPassword
- });
- this.$buefy.toast.open(response.message);
+ 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()
+ onConfirm: () => this.requestNewAPIKey(),
});
},
+ copyKey() {
+ this.$clipboard(this.apiKey);
+ this.$notifier.success('API key copied to clipboard');
+ },
async requestNewAPIKey() {
- const response = await this.$axios.$post(`user/apikey/change`);
- this.user.apiKey = response.apiKey;
- this.$forceUpdate();
+ 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
index 6718b32..89afa47 100644
--- a/src/site/pages/dashboard/admin/file/_id.vue
+++ b/src/site/pages/dashboard/admin/file/_id.vue
@@ -2,97 +2,119 @@
.underline { text-decoration: underline; }
</style>
<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">File details</h2>
- <hr>
-
- <div class="columns">
- <div class="column is-6">
- <b-field label="ID"
- horizontal>
- <span>{{ file.id }}</span>
- </b-field>
-
- <b-field label="Name"
- horizontal>
- <span>{{ file.name }}</span>
- </b-field>
-
- <b-field label="Original Name"
- horizontal>
- <span>{{ file.original }}</span>
- </b-field>
-
- <b-field label="IP"
- horizontal>
- <span class="underline">{{ file.ip }}</span>
- </b-field>
-
- <b-field label="Link"
- horizontal>
- <a :href="file.url"
- target="_blank">{{ file.url }}</a>
- </b-field>
-
- <b-field label="Size"
- horizontal>
- <span>{{ formatBytes(file.size) }}</span>
- </b-field>
-
- <b-field label="Hash"
- horizontal>
- <span>{{ file.hash }}</span>
- </b-field>
-
- <b-field label="Uploaded"
- horizontal>
- <span><timeago :since="file.createdAt" /></span>
- </b-field>
- </div>
- <div class="column is-6">
- <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>
- <nuxt-link :to="`/dashboard/admin/user/${user.id}`">{{ user.fileCount }}</nuxt-link>
- </span>
- </b-field>
- </div>
+ <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="mb2 mt2 text-center">
- <button class="button is-danger"
- @click="promptBanIP">Ban IP</button>
- <button class="button is-danger"
- @click="promptDisableUser">Disable user</button>
+ <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>
@@ -100,59 +122,42 @@
</template>
<script>
+import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
- Sidebar
- },
- middleware: ['auth', 'admin'],
- data() {
- return {
- options: {},
- file: null,
- user: null
- };
+ Sidebar,
},
- async asyncData({ $axios, route }) {
+ middleware: ['auth', 'admin', ({ route, store }) => {
try {
- const response = await $axios.$get(`file/${route.params.id}`);
- return {
- file: response.file ? response.file : null,
- user: response.user ? response.user : null
- };
- } catch (error) {
- console.error(error);
- return {
- file: null,
- user: null
- };
+ 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()
+ onConfirm: () => this.disableUser(),
});
},
- async disableUser() {
- const response = await this.$axios.$post('admin/users/disable', {
- id: this.user.id
- });
- this.$buefy.toast.open(response.message);
+ 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()
+ onConfirm: () => this.banIP(),
});
},
- async banIP() {
- const response = await this.$axios.$post('admin/ban/ip', {
- ip: this.file.ip
- });
- this.$buefy.toast.open(response.message);
+ banIP() {
+ this.$handler.executeAction('admin/banIP', this.file.ip);
},
formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
@@ -163,8 +168,8 @@ export default {
const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
- }
- }
+ 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
index 052a641..56331b5 100644
--- a/src/site/pages/dashboard/admin/settings.vue
+++ b/src/site/pages/dashboard/admin/settings.vue
@@ -1,94 +1,123 @@
<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">Service settings</h2>
- <hr>
+ <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="options.serviceName"
- expanded />
- </b-field>
+ <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="lolisafe-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="options.uploadFolder"
- 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="lolisafe-input"
+ expanded />
+ </b-field>
- <b-field label="Links per album"
- message="Maximum links allowed per album"
- horizontal>
- <b-input v-model="options.linksPerAlbum"
- type="number"
- expanded />
- </b-field>
+ <b-field
+ label="Links per album"
+ message="Maximum links allowed per album"
+ horizontal>
+ <b-input
+ v-model="settings.linksPerAlbum"
+ class="lolisafe-input"
+ type="number"
+ expanded />
+ </b-field>
- <b-field label="Max upload size"
- message="Maximum allowed file size in MB"
- horizontal>
- <b-input v-model="options.maxUploadSize"
- expanded />
- </b-field>
+ <b-field
+ label="Max upload size"
+ message="Maximum allowed file size in MB"
+ horizontal>
+ <b-input
+ v-model="settings.maxUploadSize"
+ class="lolisafe-input"
+ expanded />
+ </b-field>
- <b-field label="Filename length"
- message="How many characters long should the generated filenames be"
- horizontal>
- <b-input v-model="options.filenameLength"
- 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="lolisafe-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="options.albumLinkLength"
- 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="lolisafe-input"
+ expanded />
+ </b-field>
- <b-field label="Generate thumbnails"
- message="Generate thumbnails when uploading a file if possible"
- horizontal>
- <b-switch v-model="options.generateThumbnails"
- :true-value="true"
- :false-value="false" />
- </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="options.generateZips"
- :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="options.publicMode"
- :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="options.enableAccounts"
- :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 class="mb2 mt2 text-center">
+ <button
+ class="button is-primary"
+ @click="promptRestartService">
+ Save and restart service
+ </button>
</div>
</div>
</div>
@@ -97,39 +126,37 @@
</template>
<script>
+import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
- Sidebar
- },
- middleware: ['auth', 'admin'],
- data() {
- return {
- options: {}
- };
+ 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' };
},
- mounted() {
- this.getSettings();
- },
+ computed: mapState({
+ settings: (state) => state.admin.settings,
+ }),
methods: {
- async getSettings() {
- const response = await this.$axios.$get(`service/config`);
- this.options = response.config;
- },
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()
+ onConfirm: () => this.restartService(),
});
},
- async restartService() {
- const response = await this.$axios.$post(`service/restart`);
- this.$buefy.toast.open(response.message);
- }
- }
+ 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
index 7703b1c..7814468 100644
--- a/src/site/pages/dashboard/admin/user/_id.vue
+++ b/src/site/pages/dashboard/admin/user/_id.vue
@@ -2,50 +2,66 @@
.underline { text-decoration: underline; }
</style>
<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">User details</h2>
- <hr>
-
- <b-field label="User Id"
- horizontal>
- <span>{{ user.id }}</span>
- </b-field>
+ <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="Username"
- horizontal>
- <span>{{ user.username }}</span>
- </b-field>
+ <b-field
+ label="User Id"
+ horizontal>
+ <span>{{ user.id }}</span>
+ </b-field>
- <b-field label="Enabled"
- horizontal>
- <span>{{ user.enabled }}</span>
- </b-field>
+ <b-field
+ label="Username"
+ horizontal>
+ <span>{{ user.username }}</span>
+ </b-field>
- <b-field label="Registered"
- horizontal>
- <span><timeago :since="user.createdAt" /></span>
- </b-field>
+ <b-field
+ label="Enabled"
+ horizontal>
+ <span>{{ user.enabled }}</span>
+ </b-field>
- <b-field label="Files"
- horizontal>
- <span>{{ files.length }}</span>
- </b-field>
+ <b-field
+ label="Registered"
+ horizontal>
+ <span><timeago :since="user.createdAt" /></span>
+ </b-field>
- <div class="mb2 mt2 text-center">
- <button class="button is-danger"
- @click="promptDisableUser">Disable user</button>
- </div>
+ <b-field
+ label="Files"
+ horizontal>
+ <span>{{ user.files.length }}</span>
+ </b-field>
- <Grid v-if="files.length"
- :files="files" />
+ <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>
@@ -53,50 +69,52 @@
</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
+ Grid,
},
- middleware: ['auth', 'admin'],
+ 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: {},
- files: null,
- user: null
};
},
- async asyncData({ $axios, route }) {
- try {
- const response = await $axios.$get(`/admin/users/${route.params.id}`);
- return {
- files: response.files ? response.files : null,
- user: response.user ? response.user : null
- };
- } catch (error) {
- console.error(error);
- return {
- files: null,
- user: null
- };
- }
- },
+ computed: mapState({
+ user: (state) => state.admin.user,
+ }),
methods: {
promptDisableUser() {
this.$buefy.dialog.confirm({
- message: 'Are you sure you want to disable the account of the user that uploaded this file?',
- onConfirm: () => this.disableUser()
+ type: 'is-danger',
+ message: 'Are you sure you want to disable the account of this user?',
+ onConfirm: () => this.disableUser(),
});
},
- async disableUser() {
- const response = await this.$axios.$post('admin/users/disable', {
- id: this.user.id
+ promptEnableUser() {
+ this.$buefy.dialog.confirm({
+ type: 'is-danger',
+ message: 'Are you sure you want to enable the account of this user?',
+ onConfirm: () => this.enableUser(),
});
- this.$buefy.toast.open(response.message);
- }
- }
+ },
+ 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
index 1fefa1e..bed4c2b 100644
--- a/src/site/pages/dashboard/admin/users.vue
+++ b/src/site/pages/dashboard/admin/users.vue
@@ -1,3 +1,137 @@
+<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">
+ <template slot-scope="props">
+ <b-table-column
+ field="id"
+ label="Id"
+ centered>
+ {{ props.row.id }}
+ </b-table-column>
+
+ <b-table-column
+ 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
+ field="enabled"
+ label="Enabled"
+ centered>
+ <b-switch
+ :value="props.row.enabled"
+ @input="changeEnabledStatus(props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ field="isAdmin"
+ label="Admin"
+ centered>
+ <b-switch
+ :value="props.row.isAdmin"
+ @input="changeIsAdmin(props.row)" />
+ </b-table-column>
+
+ <b-table-column
+ field="purge"
+ centered>
+ <b-button
+ type="is-danger"
+ @click="promptPurgeFiles(props.row)">
+ Purge files
+ </b-button>
+ </b-table-column>
+ </template>
+ <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 {
@@ -107,9 +241,6 @@
}
div.column > h2.subtitle { padding-top: 1px; }
-</style>
-<style lang="scss">
- @import '~/assets/styles/_colors.scss';
.b-table {
.table-wrapper {
@@ -118,132 +249,3 @@
}
}
</style>
-
-
-<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 users</h2>
- <hr>
-
- <div class="view-container">
- <b-table
- :data="users || []"
- :mobile-cards="true">
- <template slot-scope="props">
- <b-table-column field="id"
- label="Id"
- centered>
- {{ props.row.id }}
- </b-table-column>
-
- <b-table-column 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 field="enabled"
- label="Enabled"
- centered>
- <b-switch v-model="props.row.enabled"
- @input="changeEnabledStatus(props.row)" />
- </b-table-column>
-
- <b-table-column field="isAdmin"
- label="Admin"
- centered>
- <b-switch v-model="props.row.isAdmin"
- @input="changeIsAdmin(props.row)" />
- </b-table-column>
-
- <b-table-column field="purge"
- centered>
- <button class="button is-primary"
- @click="promptPurgeFiles(props.row)">Purge files</button>
- </b-table-column>
- </template>
- <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>
- </div>
- </section>
-</template>
-
-<script>
-import Sidebar from '~/components/sidebar/Sidebar.vue';
-
-export default {
- components: {
- Sidebar
- },
- middleware: ['auth', 'admin'],
- data() {
- return {
- users: []
- };
- },
- computed: {
- config() {
- return this.$store.state.config;
- }
- },
- metaInfo() {
- return { title: 'Uploads' };
- },
- mounted() {
- this.getUsers();
- },
- methods: {
- async getUsers() {
- const response = await this.$axios.$get(`admin/users`);
- this.users = response.users;
- },
- async changeEnabledStatus(row) {
- const response = await this.$axios.$post(`admin/users/${row.enabled ? 'enable' : 'disable'}`, {
- id: row.id
- });
- this.$buefy.toast.open(response.message);
- },
- async changeIsAdmin(row) {
- const response = await this.$axios.$post(`admin/users/${row.isAdmin ? 'promote' : 'demote'}`, {
- id: row.id
- });
- this.$buefy.toast.open(response.message);
- },
- 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) {
- const response = await this.$axios.$post(`admin/users/purge`, {
- id: row.id
- });
- this.$buefy.toast.open(response.message);
- }
- }
-};
-</script>
diff --git a/src/site/pages/dashboard/albums/_id.vue b/src/site/pages/dashboard/albums/_id.vue
index 1b7c442..a4aa440 100644
--- a/src/site/pages/dashboard/albums/_id.vue
+++ b/src/site/pages/dashboard/albums/_id.vue
@@ -3,20 +3,67 @@
</style>
<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">Files</h2>
- <hr>
- <!-- TODO: Add a list view so the user can see the files that don't have thumbnails, like text documents -->
- <Grid v-if="files.length"
- :files="files" />
- </div>
+ <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">
+ <b-field>
+ <b-input
+ class="lolisafe-input"
+ placeholder="Search"
+ type="search" />
+ <p class="control">
+ <b-button type="is-lolisafe">
+ Search
+ </b-button>
+ </p>
+ </b-field>
+ </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>
@@ -24,34 +71,65 @@
</template>
<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+
import Sidebar from '~/components/sidebar/Sidebar.vue';
import Grid from '~/components/grid/Grid.vue';
export default {
components: {
Sidebar,
- Grid
+ Grid,
},
- middleware: 'auth',
+ middleware: ['auth', ({ route, store }) => {
+ store.commit('images/resetState');
+ store.dispatch('images/fetchByAlbumId', { id: route.params.id });
+ }],
data() {
return {
- name: null,
- files: []
+ 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' };
},
- async asyncData({ $axios, route }) {
- try {
- const response = await $axios.$get(`album/${route.params.id}/full`);
- return {
- files: response.files ? response.files : []
- };
- } catch (error) {
- console.error(error);
- return { files: [] };
- }
- }
+ 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
index 60607ac..c5acb2e 100644
--- a/src/site/pages/dashboard/albums/index.vue
+++ b/src/site/pages/dashboard/albums/index.vue
@@ -7,130 +7,36 @@
<Sidebar />
</div>
<div class="column">
- <h2 class="subtitle">Manage your albums</h2>
+ <h2 class="subtitle">
+ Manage your albums
+ </h2>
<hr>
<div class="search-container">
<b-field>
- <b-input v-model="newAlbumName"
+ <b-input
+ v-model="newAlbumName"
+ class="lolisafe-input"
placeholder="Album name..."
type="text"
@keyup.enter.native="createAlbum" />
<p class="control">
- <button class="button is-primary"
- @click="createAlbum">Create album</button>
+ <button
+ outlined
+ class="button is-black"
+ :disabled="isCreatingAlbum"
+ @click="createAlbum">
+ Create album
+ </button>
</p>
</b-field>
</div>
<div class="view-container">
- <div v-for="album in albums"
+ <AlbumEntry
+ v-for="album in albums.list"
:key="album.id"
- class="album">
- <div class="arrow-container"
- @click="fetchAlbumDetails(album)">
- <i :class="{ active: album.isDetailsOpen }"
- 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/albums/${album.id}`">{{ album.name }}</router-link>
- </h4>
- <span>Updated <timeago :since="album.editedAt" /></span>
- <span>{{ album.fileCount || 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.id}`">{{ album.fileCount - 5 }}+ more</router-link>
- </div>
- </template>
- <template v-else>
- <span class="no-files">Nothing to show here</span>
- </template>
- </div>
-
- <div v-if="album.isDetailsOpen"
- class="details">
- <h2>Public links for this album:</h2>
-
- <b-table
- :data="album.links.length ? album.links : []"
- :mobile-cards="true">
- <template slot-scope="props">
- <b-table-column field="identifier"
- label="Link"
- centered>
- <a :href="`${config.URL}/a/${props.row.identifier}`"
- target="_blank">
- {{ props.row.identifier }}
- </a>
- </b-table-column>
-
- <b-table-column field="views"
- label="Views"
- centered>
- {{ props.row.views }}
- </b-table-column>
-
- <b-table-column field="enableDownload"
- label="Allow download"
- centered>
- <b-switch v-model="props.row.enableDownload"
- @input="linkOptionsChanged(props.row)" />
- </b-table-column>
-
- <b-table-column field="enabled"
- label="Actions"
- centered>
- <button class="button is-danger"
- @click="promptDeleteAlbumLink(props.row.identifier)">Delete link</button>
- </b-table-column>
- </template>
- <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="wrapper">
- <div class="has-text-right">
- <button :class="{ 'is-loading': album.isCreatingLink }"
- class="button is-primary"
- style="float: left"
- @click="createLink(album)">Create new link</button>
- {{ album.links.length }} / {{ config.maxLinksPerAlbum }} links created
- </div>
-
- <div class="has-text-left">
- <button class="button is-danger"
- style="float: right"
- @click="promptDeleteAlbum(album.id)">Delete album</button>
- </div>
- </div>
- </template>
- </b-table>
- </div>
- </div>
+ :album="album" />
</div>
</div>
</div>
@@ -140,223 +46,65 @@
</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
+ Sidebar,
+ AlbumEntry,
},
- middleware: 'auth',
+ middleware: ['auth', ({ store }) => {
+ try {
+ store.dispatch('albums/fetch');
+ } catch (e) {
+ this.alert({ text: e.message, error: true });
+ }
+ }],
data() {
return {
- albums: [],
- newAlbumName: null
+ newAlbumName: null,
+ isCreatingAlbum: false,
};
},
- computed: {
- config() {
- return this.$store.state.config;
- }
- },
+ computed: mapState(['config', 'albums']),
metaInfo() {
return { title: 'Uploads' };
},
- mounted() {
- this.getAlbums();
- },
methods: {
- async fetchAlbumDetails(album) {
- const response = await this.$axios.$get(`album/${album.id}/links`);
- album.links = response.links;
- album.isDetailsOpen = !album.isDetailsOpen;
- this.$forceUpdate();
- },
- promptDeleteAlbum(id) {
- this.$buefy.dialog.confirm({
- message: 'Are you sure you want to delete this album?',
- onConfirm: () => this.deleteAlbum(id)
- });
- },
- async deleteAlbum(id) {
- const response = await this.$axios.$delete(`album/${id}`);
- this.getAlbums();
- return this.$buefy.toast.open(response.message);
- },
- promptDeleteAlbumLink(identifier) {
- this.$buefy.dialog.confirm({
- message: 'Are you sure you want to delete this album link?',
- onConfirm: () => this.deleteAlbumLink(identifier)
- });
- },
- async deleteAlbumLink(identifier) {
- const response = await this.$axios.$delete(`album/link/delete/${identifier}`);
- return this.$buefy.toast.open(response.message);
- },
- async linkOptionsChanged(link) {
- const response = await this.$axios.$post(`album/link/edit`,
- {
- identifier: link.identifier,
- enableDownload: link.enableDownload,
- enabled: link.enabled
- });
- this.$buefy.toast.open(response.message);
- },
- async createLink(album) {
- album.isCreatingLink = true;
- // Since we actually want to change the state even if the call fails, use a try catch
+ ...mapActions({
+ 'alert': 'alert/set',
+ }),
+ async createAlbum() {
+ if (!this.newAlbumName || this.newAlbumName === '') return;
+
+ this.isCreatingAlbum = true;
try {
- const response = await this.$axios.$post(`album/link/new`,
- { albumId: album.id });
- this.$buefy.toast.open(response.message);
- album.links.push({
- identifier: response.identifier,
- views: 0,
- enabled: true,
- enableDownload: true,
- expiresAt: null
- });
- } catch (error) {
- //
+ 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 {
- album.isCreatingLink = false;
+ this.isCreatingAlbum = false;
+ this.newAlbumName = null;
}
},
- async createAlbum() {
- if (!this.newAlbumName || this.newAlbumName === '') return;
- const response = await this.$axios.$post(`album/new`,
- { name: this.newAlbumName });
- this.newAlbumName = null;
- this.$buefy.toast.open(response.message);
- this.getAlbums();
- },
- async getAlbums() {
- const response = await this.$axios.$get(`albums/mini`);
- for (const album of response.albums) {
- album.isDetailsOpen = false;
- }
- this.albums = response.albums;
- }
- }
+ },
};
</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;
-
- -webkit-transition: transform 0.1s linear;
- -moz-transition: transform 0.1s linear;
- -ms-transition: transform 0.1s linear;
- -o-transition: transform 0.1s linear;
- transition: transform 0.1s linear;
-
- &.active {
- transform: rotate(-45deg);
- }
- }
- }
- div.thumb {
- width: 64px;
- height: 64px;
- -webkit-box-shadow: $boxShadowLight;
- box-shadow: $boxShadowLight;
- }
- div.info {
- margin-left: 15px;
- text-align: left;
- 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 {
- -webkit-box-shadow: $boxShadowLight;
- box-shadow: $boxShadowLight;
- }
- }
- }
+ div.search-container {
+ padding: 1rem 2rem;
+ background-color: $base-2;
}
div.column > h2.subtitle { padding-top: 1px; }
</style>
-<style lang="scss">
- @import '~/assets/styles/_colors.scss';
-
- .b-table {
- .table-wrapper {
- -webkit-box-shadow: $boxShadowLight;
- box-shadow: $boxShadowLight;
- }
- }
-</style>
diff --git a/src/site/pages/dashboard/index.vue b/src/site/pages/dashboard/index.vue
index b58e567..d623b5c 100644
--- a/src/site/pages/dashboard/index.vue
+++ b/src/site/pages/dashboard/index.vue
@@ -1,19 +1,62 @@
<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">Your uploaded files</h2>
- <hr>
- <!-- TODO: Add a list view so the user can see the files that don't have thumbnails, like text documents -->
- <Grid v-if="files.length"
- :files="files"
- :enableSearch="false" />
- </div>
+ <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">
+ <b-field>
+ <b-input
+ class="lolisafe-input"
+ placeholder="Search"
+ type="search" />
+ <p class="control">
+ <b-button type="is-lolisafe">
+ Search
+ </b-button>
+ </p>
+ </b-field>
+ </div>
+ </div>
+ </nav>
+ <hr>
+
+ <!-- <b-loading :active="images.isLoading" /> -->
+
+ <Grid
+ v-if="totalFiles && !isLoading"
+ :files="images.files"
+ :enableSearch="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>
@@ -21,31 +64,65 @@
</template>
<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+
import Sidebar from '~/components/sidebar/Sidebar.vue';
import Grid from '~/components/grid/Grid.vue';
export default {
components: {
Sidebar,
- Grid
+ Grid,
},
- middleware: 'auth',
+ middleware: ['auth', ({ store }) => {
+ store.commit('images/resetState');
+ store.dispatch('images/fetch');
+ }],
data() {
return {
- files: []
+ current: 1,
+ isLoading: false,
};
},
+ computed: {
+ ...mapGetters({
+ totalFiles: 'images/getTotalFiles',
+ shouldPaginate: 'images/shouldPaginate',
+ limit: 'images/getLimit',
+ }),
+ ...mapState(['images']),
+ },
metaInfo() {
return { title: 'Uploads' };
},
- mounted() {
- this.getFiles();
+ watch: {
+ current: 'fetchPaginate',
},
methods: {
- async getFiles() {
- const response = await this.$axios.$get(`files`);
- this.files = response.files;
- }
- }
+ ...mapActions({
+ fetch: 'images/fetch',
+ }),
+ async fetchPaginate() {
+ this.isLoading = true;
+ await this.fetch(this.current);
+ this.isLoading = false;
+ },
+ },
};
</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
index bc9ae57..fe92087 100644
--- a/src/site/pages/dashboard/tags/index.vue
+++ b/src/site/pages/dashboard/tags/index.vue
@@ -107,6 +107,10 @@
}
div.column > h2.subtitle { padding-top: 1px; }
+
+ div.no-background {
+ background: none;
+ }
</style>
<style lang="scss">
@import '~/assets/styles/_colors.scss';
@@ -119,77 +123,85 @@
}
</style>
-
<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 tags</h2>
- <hr>
+ <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"
- placeholder="Tag name..."
- type="text"
- @keyup.enter.native="createTag" />
- <p class="control">
- <button class="button is-primary"
- @click="createTag">Create tags</button>
- </p>
- </b-field>
- </div>
+ <div class="search-container">
+ <b-field>
+ <b-input
+ v-model="newTagName"
+ class="lolisafe-input"
+ placeholder="Tag name..."
+ type="text"
+ @keyup.enter.native="createTag" />
+ <p class="control">
+ <b-button
+ type="is-lolisafe"
+ @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 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>
@@ -203,19 +215,19 @@ import Sidebar from '~/components/sidebar/Sidebar.vue';
export default {
components: {
- Sidebar
+ Sidebar,
},
middleware: 'auth',
data() {
return {
tags: [],
- newTagName: null
+ newTagName: null,
};
},
computed: {
config() {
return this.$store.state.config;
- }
+ },
},
metaInfo() {
return { title: 'Tags' };
@@ -226,17 +238,19 @@ export default {
methods: {
promptDeleteTag(id) {
this.$buefy.dialog.confirm({
+ type: 'is-danger',
message: 'Are you sure you want to delete this tag?',
- onConfirm: () => this.promptPurgeTag(id)
+ 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)
+ onCancel: () => this.deleteTag(id, false),
});
},
async deleteTag(id, purge) {
@@ -246,19 +260,19 @@ export default {
},
async createTag() {
if (!this.newTagName || this.newTagName === '') return;
- const response = await this.$axios.$post(`tag/new`,
+ 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`);
+ 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
index bd93086..85e8744 100644
--- a/src/site/pages/faq.vue
+++ b/src/site/pages/faq.vue
@@ -1,34 +1,45 @@
<template>
+ <!-- eslint-disable max-len -->
<div class="container has-text-left">
- <h2 class="subtitle">What is lolisafe?</h2>
+ <h2 class="subtitle">
+ What is lolisafe?
+ </h2>
<article class="message">
<div class="message-body">
lolisafe 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 lolisafe?</h2>
+ <h2 class="subtitle">
+ Can I run my own lolisafe?
+ </h2>
<article class="message">
<div class="message-body">
Definitely. Head to <a target="_blank" href="https://github.com/WeebDev/lolisafe">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>
+ <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>
+ <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/loli-safe-uploader/enkkmplljfjppcdaancckgilmgoiofnj">our chrome extension</a> which will enable you to <strong>right click -&gt; send to lolisafe</strong> or to a desired album if you have any.
</div>
</article>
- <h2 class="subtitle">Why should I use this?</h2>
+ <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, lolisafe 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.
@@ -45,9 +56,10 @@ export default {
},
metaInfo() {
return { title: 'Faq' };
- }
+ },
};
</script>
+
<style lang="scss" scoped>
@import '~/assets/styles/_colors.scss';
article.message { background-color: #ffffff; }
diff --git a/src/site/pages/index.vue b/src/site/pages/index.vue
index 0617098..a28eea4 100644
--- a/src/site/pages/index.vue
+++ b/src/site/pages/index.vue
@@ -1,5 +1,5 @@
<template>
- <div>
+ <div class="section">
<div class="container">
<div class="columns">
<div class="column is-3 is-offset-2">
@@ -11,15 +11,18 @@
<div class="content-wrapper">
<h4>Blazing fast file uploader. <br>For real.</h4>
<p>
+ <!-- eslint-disable-next-line max-len -->
A <strong>modern</strong> and <strong>self-hosted</strong> 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.
</p>
</div>
</div>
</div>
</div>
- <div class="container">
+ <div class="container uploader">
<Uploader v-if="config.publicMode || (!config.publicMode && loggedIn)" />
- <div v-else>
+ <div
+ v-else
+ class="has-text-centered is-size-4 has-text-danger">
This site has disabled public uploads. You need an account.
</div>
<Links />
@@ -27,6 +30,8 @@
</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';
@@ -36,19 +41,15 @@ export default {
components: {
Logo,
Uploader,
- Links
+ Links,
},
data() {
return { albums: [] };
},
computed: {
- loggedIn() {
- return this.$store.state.loggedIn;
- },
- config() {
- return this.$store.state.config;
- }
- }
+ ...mapGetters({ loggedIn: 'auth/isLoggedIn' }),
+ ...mapState(['config']),
+ },
};
</script>
<style lang="scss" scoped>
@@ -79,4 +80,8 @@ export default {
}
}
}
+
+ .uploader {
+ margin-top: 2rem;
+ }
</style>
diff --git a/src/site/pages/login.vue b/src/site/pages/login.vue
index 7a98aa4..1f313e3 100644
--- a/src/site/pages/login.vue
+++ b/src/site/pages/login.vue
@@ -1,37 +1,57 @@
<template>
- <section class="hero is-fullheight is-login">
- <div class="hero-body">
- <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"
- type="text"
- placeholder="Username"
- @keyup.enter.native="login" />
- </b-field>
- <b-field>
- <b-input v-model="password"
- type="password"
- placeholder="Password"
- password-reveal
- @keyup.enter.native="login" />
- </b-field>
+ <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="lolisafe-input"
+ type="text"
+ placeholder="Username"
+ @keyup.enter.native="login" />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="password"
+ class="lolisafe-input"
+ type="password"
+ placeholder="Password"
+ password-reveal
+ @keyup.enter.native="login" />
+ </b-field>
+
+ <p class="control has-addons is-pulled-right" />
- <p class="control has-addons is-pulled-right">
- <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>
- <button class="button is-primary big ml1"
- @click="login">login</button>
- </p>
+ <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-lolisafe"
+ @click="login">
+ Login
+ </b-button>
+ </p>
+ </div>
</div>
</div>
</div>
@@ -65,6 +85,8 @@
</template>
<script>
+import { mapState } from 'vuex';
+
export default {
name: 'Login',
data() {
@@ -73,41 +95,36 @@ export default {
password: null,
mfaCode: null,
isMfaModalActive: false,
- isLoading: false
+ isLoading: false,
};
},
- computed: {
- config() {
- return this.$store.state.config;
- }
- },
+ computed: mapState(['config', 'auth']),
metaInfo() {
return { title: 'Login' };
},
+ created() {
+ if (this.auth.loggedIn) {
+ this.redirect();
+ }
+ },
methods: {
async login() {
if (this.isLoading) return;
- if (!this.username || !this.password) {
- this.$store.dispatch('alert', {
- text: 'Please fill both fields before attempting to log in.',
- error: true
- });
+
+ const { username, password } = this;
+ if (!username || !password) {
+ this.$notifier.error('Please fill both fields before attempting to log in.');
return;
}
- this.isLoading = true;
try {
- const data = await this.$axios.$post(`auth/login`, {
- username: this.username,
- password: this.password
- });
- this.$axios.setToken(data.token, 'Bearer');
- document.cookie = `token=${encodeURIComponent(data.token)}`;
- this.$store.dispatch('login', { token: data.token, user: data.user });
-
- this.redirect();
- } catch (error) {
- //
+ 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;
}
@@ -126,15 +143,14 @@ export default {
this.isLoading = false;
this.$onPromiseError(err);
});
- },*/
+ }, */
redirect() {
- this.$store.commit('loggedIn', true);
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..7124ea5
--- /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
index 2f155c0..5a7a158 100644
--- a/src/site/pages/register.vue
+++ b/src/site/pages/register.vue
@@ -1,41 +1,62 @@
<template>
- <section class="hero is-fullheight is-register">
- <div class="hero-body">
- <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"
- type="text"
- placeholder="Username" />
- </b-field>
- <b-field>
- <b-input v-model="password"
- type="password"
- placeholder="Password"
- password-reveal />
- </b-field>
- <b-field>
- <b-input v-model="rePassword"
- type="password"
- placeholder="Re-type Password"
- password-reveal
- @keyup.enter.native="register" />
- </b-field>
+ <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="lolisafe-input"
+ type="text"
+ placeholder="Username" />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="password"
+ class="lolisafe-input"
+ type="password"
+ placeholder="Password"
+ password-reveal />
+ </b-field>
+ <b-field>
+ <b-input
+ v-model="rePassword"
+ class="lolisafe-input"
+ type="password"
+ placeholder="Re-type Password"
+ password-reveal
+ @keyup.enter.native="register" />
+ </b-field>
- <p class="control has-addons is-pulled-right">
- <router-link to="/login"
- class="is-text">Already have an account?</router-link>
- <button class="button is-primary big ml1"
- :disabled="isLoading"
- @click="register">Register</button>
- </p>
+ <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-lolisafe"
+ :disabled="isLoading"
+ @click="register">
+ Register
+ </b-button>
+ </p>
+ </div>
</div>
</div>
</div>
@@ -44,6 +65,8 @@
</template>
<script>
+import { mapState } from 'vuex';
+
export default {
name: 'Register',
data() {
@@ -51,50 +74,42 @@ export default {
username: null,
password: null,
rePassword: null,
- isLoading: false
+ isLoading: false,
};
},
- computed: {
- config() {
- return this.$store.state.config;
- }
- },
+ computed: mapState(['config', 'auth']),
metaInfo() {
return { title: 'Register' };
},
methods: {
async register() {
if (this.isLoading) return;
+
if (!this.username || !this.password || !this.rePassword) {
- this.$store.dispatch('alert', {
- text: 'Please fill all fields before attempting to register.',
- error: true
- });
+ this.$notifier.error('Please fill all fields before attempting to register.');
return;
}
if (this.password !== this.rePassword) {
- this.$store.dispatch('alert', {
- text: 'Passwords don\'t match',
- error: true
- });
+ this.$notifier.error('Passwords don\'t match');
return;
}
this.isLoading = true;
try {
- const response = await this.$axios.$post(`auth/register`, {
+ const response = await this.$store.dispatch('auth/register', {
username: this.username,
- password: this.password
+ password: this.password,
});
- this.$store.dispatch('alert', { text: response.message });
- return this.$router.push('/login');
+ this.$notifier.success(response.message);
+ this.$router.push('/login');
+ return;
} catch (error) {
- //
+ this.$notifier.error(error.message);
} finally {
this.isLoading = false;
}
- }
- }
+ },
+ },
};
</script>
diff --git a/src/site/plugins/axios.js b/src/site/plugins/axios.js
index cc6d98e..0308d39 100644
--- a/src/site/plugins/axios.js
+++ b/src/site/plugins/axios.js
@@ -1,18 +1,32 @@
-export default function({ $axios, store }) {
+export default function ({ $axios, store }) {
$axios.setHeader('accept', 'application/vnd.lolisafe.json');
- $axios.onRequest(config => {
- if (store.state.token) {
- config.headers.common['Authorization'] = `bearer ${store.state.token}`;
+
+ $axios.onRequest((config) => {
+ if (store.state.auth.token) {
+ config.headers.common.Authorization = `bearer ${store.state.auth.token}`;
}
});
- $axios.onError(error => {
- if (process.env.development) console.error('[AXIOS Error]', error);
+ $axios.onError((error) => {
+ if (process.env.NODE_ENV !== 'production') console.error('[AXIOS Error]', error);
if (process.browser) {
- store.dispatch('alert', {
- text: error.response.data.message,
- error: true
- });
+ if (process.env.NODE_ENV !== 'production') {
+ if (error.response?.data?.message) {
+ store.dispatch('alert/set', {
+ text: error.response.data.message,
+ error: true,
+ });
+ } else {
+ store.dispatch('alert/set', {
+ text: `[AXIOS]: ${error.message}`,
+ error: true,
+ });
+ }
+ }
+
+ /* if (error.response?.data?.message.indexOf('Token expired') !== -1) {
+ store.dispatch('auth/logout');
+ } */
}
});
}
diff --git a/src/site/plugins/buefy.js b/src/site/plugins/buefy.js
index 58bf28f..f3f7552 100644
--- a/src/site/plugins/buefy.js
+++ b/src/site/plugins/buefy.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
import Buefy from 'buefy';
+// import 'buefy/dist/buefy.css';
Vue.use(Buefy);
diff --git a/src/site/plugins/flexsearch.js b/src/site/plugins/flexsearch.js
index 595b180..8640232 100644
--- a/src/site/plugins/flexsearch.js
+++ b/src/site/plugins/flexsearch.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
import FlexSearch from 'flexsearch';
+
const search = new FlexSearch('speed');
// https://github.com/nextapps-de/flexsearch
Vue.prototype.$search = {
- items: async items => {
+ items: async (items) => {
await search.clear();
await search.add(items);
},
do: async (term, field) => {
const results = await search.search(term, { field });
return results;
- }
+ },
};
diff --git a/src/site/plugins/handler.js b/src/site/plugins/handler.js
new file mode 100644
index 0000000..3d85b15
--- /dev/null
+++ b/src/site/plugins/handler.js
@@ -0,0 +1,25 @@
+import AlertTypes from '~/constants/alertTypes';
+
+export default ({ store }, inject) => {
+ inject('handler', {
+ async executeAction(action, param) {
+ try {
+ const response = await store.dispatch(action, param);
+
+ store.commit('alert/set', {
+ message: response?.message ?? 'Executed sucesfully',
+ type: AlertTypes.SUCCESS,
+ });
+
+ return response;
+ } catch (e) {
+ store.commit('alert/set', {
+ message: e.message,
+ type: AlertTypes.ERROR,
+ });
+
+ return null;
+ }
+ },
+ });
+};
diff --git a/src/site/plugins/notifier.js b/src/site/plugins/notifier.js
new file mode 100644
index 0000000..4fe1262
--- /dev/null
+++ b/src/site/plugins/notifier.js
@@ -0,0 +1,25 @@
+import AlertTypes from '~/constants/alertTypes';
+
+export default ({ store }, inject) => {
+ inject('notifier', {
+ showMessage({ message = '', type = '', snackbar = false }) {
+ store.commit('alert/set', { message, type, snackbar });
+ },
+ message(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.PRIMARY, snackbar });
+ },
+ info(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.INFO, snackbar });
+ },
+ warning(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.WARNING, snackbar });
+ },
+ success(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.SUCCESS, snackbar });
+ },
+ error(message, snackbar) {
+ this.showMessage({ message, type: AlertTypes.ERROR, snackbar });
+ },
+ types: AlertTypes,
+ });
+};
diff --git a/src/site/plugins/nuxt-client-init.js b/src/site/plugins/nuxt-client-init.js
index 4b10dcd..01f33ff 100644
--- a/src/site/plugins/nuxt-client-init.js
+++ b/src/site/plugins/nuxt-client-init.js
@@ -1,3 +1,3 @@
-export default async ctx => {
+export default async (ctx) => {
await ctx.store.dispatch('nuxtClientInit', ctx);
};
diff --git a/src/site/plugins/vue-isyourpasswordsafe.js b/src/site/plugins/vue-isyourpasswordsafe.js
index 6172ca0..68f313a 100644
--- a/src/site/plugins/vue-isyourpasswordsafe.js
+++ b/src/site/plugins/vue-isyourpasswordsafe.js
@@ -3,5 +3,5 @@ import VueIsYourPasswordSafe from 'vue-isyourpasswordsafe';
Vue.use(VueIsYourPasswordSafe, {
minLength: 6,
- maxLength: 64
+ maxLength: 64,
});
diff --git a/src/site/plugins/vue-timeago.js b/src/site/plugins/vue-timeago.js
index 28f3c6d..81e6e75 100644
--- a/src/site/plugins/vue-timeago.js
+++ b/src/site/plugins/vue-timeago.js
@@ -4,5 +4,5 @@ import VueTimeago from 'vue-timeago';
Vue.use(VueTimeago, {
name: 'timeago',
locale: 'en-US',
- locales: { 'en-US': require('vue-timeago/locales/en-US.json') }
+ locales: { 'en-US': require('vue-timeago/locales/en-US.json') },
});
diff --git a/src/site/store/.eslintrc.json b/src/site/store/.eslintrc.json
new file mode 100644
index 0000000..052e3ef
--- /dev/null
+++ b/src/site/store/.eslintrc.json
@@ -0,0 +1,5 @@
+{
+ "rules": {
+ "no-shadow": ["error", { "allow": ["state"] }]
+ }
+}
diff --git a/src/site/store/admin.js b/src/site/store/admin.js
new file mode 100644
index 0000000..04ad980
--- /dev/null
+++ b/src/site/store/admin.js
@@ -0,0 +1,122 @@
+export const state = () => ({
+ users: [],
+ user: {
+ id: null,
+ username: null,
+ enabled: false,
+ createdAt: null,
+ editedAt: null,
+ apiKeyEditedAt: null,
+ isAdmin: null,
+ files: [],
+ },
+ file: {},
+ settings: {},
+});
+
+export const actions = {
+ async fetchSettings({ commit }) {
+ const response = await this.$axios.$get('service/config');
+ commit('setSettings', response);
+
+ return response;
+ },
+ async fetchUsers({ commit }) {
+ const response = await this.$axios.$get('admin/users');
+ commit('setUsers', response);
+
+ return response;
+ },
+ async fetchUser({ commit }, id) {
+ const response = await this.$axios.$get(`admin/users/${id}`);
+ commit('setUserInfo', response);
+
+ return response;
+ },
+ async fetchFile({ commit }, id) {
+ const response = await this.$axios.$get(`admin/file/${id}`);
+ commit('setFile', response);
+ commit('setUserInfo', response);
+
+ return response;
+ },
+ async banIP(_, ip) {
+ const response = await this.$axios.$post('admin/ban/ip', { ip });
+
+ return response;
+ },
+ async enableUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/enable', { id });
+
+ commit('changeUserState', { userId: id, enabled: true });
+
+ return response;
+ },
+ async disableUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/disable', { id });
+
+ commit('changeUserState', { userId: id, enabled: false });
+
+ return response;
+ },
+ async promoteUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/promote', { id });
+
+ commit('changeUserState', { userId: id, isAdmin: true });
+
+ return response;
+ },
+ async demoteUser({ commit }, id) {
+ const response = await this.$axios.$post('admin/users/demote', { id });
+
+ commit('changeUserState', { userId: id, isAdmin: false });
+
+ return response;
+ },
+ async purgeUserFiles(_, id) {
+ const response = await this.$axios.$post('admin/users/purge', { id });
+
+ return response;
+ },
+ async restartService() {
+ const response = await this.$axios.$post('service/restart');
+
+ return response;
+ },
+};
+
+export const mutations = {
+ setSettings(state, { config }) {
+ state.settings = config;
+ },
+ setUsers(state, { users }) {
+ state.users = users;
+ },
+ setUserInfo(state, { user, files }) {
+ state.user = { ...state.user, ...user };
+ state.user.files = files || [];
+ },
+ setFile(state, { file }) {
+ state.file = file || {};
+ },
+ changeUserState(state, { userId, enabled, isAdmin }) {
+ const foundIndex = state.users.findIndex(({ id }) => id === userId);
+ if (foundIndex > -1) {
+ if (enabled !== undefined) {
+ state.users[foundIndex].enabled = enabled;
+ }
+ if (isAdmin !== undefined) {
+ state.users[foundIndex].isAdmin = isAdmin;
+ }
+ }
+
+ if (state.user.id === userId) {
+ if (enabled !== undefined) {
+ state.user.enabled = enabled;
+ }
+ if (isAdmin !== undefined) {
+ state.user.isAdmin = isAdmin;
+ }
+ }
+ },
+};
diff --git a/src/site/store/albums.js b/src/site/store/albums.js
new file mode 100644
index 0000000..8441182
--- /dev/null
+++ b/src/site/store/albums.js
@@ -0,0 +1,129 @@
+import Vue from 'vue';
+
+export const state = () => ({
+ list: [],
+ isListLoading: false,
+ albumDetails: {},
+ expandedAlbums: [],
+ tinyDetails: [],
+});
+
+export const getters = {
+ isExpanded: (state) => (id) => state.expandedAlbums.indexOf(id) > -1,
+ getDetails: (state) => (id) => state.albumDetails[id] || {},
+};
+
+export const actions = {
+ async fetch({ commit }) {
+ commit('albumsRequest');
+ const response = await this.$axios.$get('albums/mini');
+
+ commit('setAlbums', response.albums);
+
+ return response;
+ },
+ async fetchDetails({ commit }, albumId) {
+ const response = await this.$axios.$get(`album/${albumId}/links`);
+
+ commit('setDetails', {
+ id: albumId,
+ details: {
+ links: response.links,
+ },
+ });
+
+ return response;
+ },
+ async createAlbum({ commit }, name) {
+ const response = await this.$axios.$post('album/new', { name });
+
+ commit('addAlbum', response.data);
+
+ return response;
+ },
+ async deleteAlbum({ commit }, albumId) {
+ const response = await this.$axios.$delete(`album/${albumId}`);
+
+ commit('removeAlbum', albumId);
+
+ return response;
+ },
+ async createLink({ commit }, albumId) {
+ const response = await this.$axios.$post('album/link/new', { albumId });
+
+ commit('addAlbumLink', { albumId, data: response.data });
+
+ return response;
+ },
+ async updateLinkOptions({ commit }, { albumId, linkOpts }) {
+ const response = await this.$axios.$post('album/link/edit', {
+ identifier: linkOpts.identifier,
+ enableDownload: linkOpts.enableDownload,
+ enabled: linkOpts.enabled,
+ });
+
+ commit('updateAlbumLinkOpts', { albumId, linkOpts: response.data });
+
+ return response;
+ },
+ async deleteLink({ commit }, { albumId, identifier }) {
+ const response = await this.$axios.$delete(`album/link/delete/${identifier}`);
+
+ commit('removeAlbumLink', { albumId, identifier });
+
+ return response;
+ },
+ async getTinyDetails({ commit }) {
+ const response = await this.$axios.$get('albums/dropdown');
+
+ commit('setTinyDetails', response);
+
+ return response;
+ },
+};
+
+export const mutations = {
+ albumsRequest(state) {
+ state.isLoading = true;
+ },
+ setAlbums(state, albums) {
+ state.list = albums;
+ state.isLoading = false;
+ },
+ addAlbum(state, album) {
+ state.list.unshift(album);
+ },
+ removeAlbum(state, albumId) {
+ // state.list = state.list.filter(({ id }) => id !== albumId);
+ const foundIndex = state.list.findIndex(({ id }) => id === albumId);
+ state.list.splice(foundIndex, 1);
+ },
+ setDetails(state, { id, details }) {
+ Vue.set(state.albumDetails, id, details);
+ },
+ addAlbumLink(state, { albumId, data }) {
+ state.albumDetails[albumId].links.push(data);
+ },
+ updateAlbumLinkOpts(state, { albumId, linkOpts }) {
+ const foundIndex = state.albumDetails[albumId].links.findIndex(
+ ({ identifier }) => identifier === linkOpts.identifier,
+ );
+ const link = state.albumDetails[albumId].links[foundIndex];
+ state.albumDetails[albumId].links[foundIndex] = { ...link, ...linkOpts };
+ },
+ removeAlbumLink(state, { albumId, identifier }) {
+ const foundIndex = state.albumDetails[albumId].links.findIndex(({ identifier: id }) => id === identifier);
+ if (foundIndex > -1) state.albumDetails[albumId].links.splice(foundIndex, 1);
+ },
+ toggleExpandedState(state, id) {
+ const foundIndex = state.expandedAlbums.indexOf(id);
+ if (foundIndex > -1) {
+ state.expandedAlbums.splice(foundIndex, 1);
+ } else {
+ state.expandedAlbums.push(id);
+ }
+ },
+ setTinyDetails(state, { albums }) {
+ state.tinyDetails = albums;
+ },
+};
diff --git a/src/site/store/alert.js b/src/site/store/alert.js
new file mode 100644
index 0000000..580dcc8
--- /dev/null
+++ b/src/site/store/alert.js
@@ -0,0 +1,33 @@
+import AlertTypes from '~/constants/alertTypes';
+
+const getDefaultState = () => ({
+ message: null,
+ type: null,
+ snackbar: false,
+});
+
+export const state = getDefaultState;
+
+export const actions = {
+ set({ commit }, data) {
+ // Only exists for backwards compatibility, remove one day
+ if (data.error === true) data.type = AlertTypes.ERROR;
+ if (data.text !== undefined) data.message = data.text;
+
+ commit('set', data);
+ },
+ clear({ commit }) {
+ commit('clear');
+ },
+};
+
+export const mutations = {
+ set(state, { message, type, snackbar }) {
+ state.message = message;
+ state.type = type;
+ state.snackbar = snackbar || false;
+ },
+ clear(state) {
+ Object.assign(state, getDefaultState());
+ },
+};
diff --git a/src/site/store/auth.js b/src/site/store/auth.js
new file mode 100644
index 0000000..96631e2
--- /dev/null
+++ b/src/site/store/auth.js
@@ -0,0 +1,106 @@
+const getDefaultState = () => ({
+ loggedIn: false,
+ user: {
+ id: null,
+ isAdmin: false,
+ username: null,
+ },
+ token: null,
+});
+
+export const state = getDefaultState;
+
+export const getters = {
+ isLoggedIn: (state) => state.loggedIn,
+ getApiKey: (state) => state.user?.apiKey,
+ getToken: (state) => state.token,
+};
+
+export const actions = {
+ async verify({ commit, dispatch }) {
+ try {
+ const response = await this.$axios.$get('verify');
+ commit('loginSuccess', response);
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async login({ commit }, { username, password }) {
+ commit('loginRequest');
+
+ const data = await this.$axios.$post('auth/login', { username, password });
+ this.$axios.setToken(data.token, 'Bearer');
+
+ commit('setToken', data.token);
+ commit('loginSuccess', { token: data.token, user: data.user });
+ },
+ async register(_, { username, password }) {
+ return this.$axios.$post('auth/register', {
+ username,
+ password,
+ });
+ },
+ async fetchCurrentUser({ commit, dispatch }) {
+ try {
+ const data = await this.$axios.$get('users/me');
+ commit('setUser', data.user);
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+ },
+ async changePassword({ dispatch }, { password, newPassword }) {
+ try {
+ const response = await this.$axios.$post('user/password/change', {
+ password,
+ newPassword,
+ });
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ async requestAPIKey({ commit, dispatch }) {
+ try {
+ const response = await this.$axios.$post('user/apikey/change');
+ commit('setApiKey', response.apiKey);
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ logout({ commit }) {
+ commit('logout');
+ },
+};
+
+export const mutations = {
+ setToken(state, token) {
+ state.token = token;
+ },
+ setApiKey(state, apiKey) {
+ state.user.apiKey = apiKey;
+ },
+ setUser(state, user) {
+ state.user = user;
+ },
+ loginRequest(state) {
+ state.isLoading = true;
+ },
+ loginSuccess(state, { user }) {
+ this.$cookies.set('token', state.token, { path: '/' });
+ state.user = user;
+ state.loggedIn = true;
+ state.isLoading = false;
+ },
+ logout(state) {
+ this.$cookies.remove('token', { path: '/' });
+ // reset state to default
+ Object.assign(state, getDefaultState());
+ },
+};
diff --git a/src/site/store/config.js b/src/site/store/config.js
new file mode 100644
index 0000000..f52fc0f
--- /dev/null
+++ b/src/site/store/config.js
@@ -0,0 +1,18 @@
+export const state = () => ({
+ development: true,
+ version: '4.0.0',
+ URL: 'http://localhost:8080',
+ baseURL: 'http://localhost:8080/api',
+ serviceName: '',
+ maxFileSize: 100,
+ chunkSize: 90,
+ maxLinksPerAlbum: 5,
+ publicMode: false,
+ userAccounts: false,
+});
+
+export const mutations = {
+ set(state, config) {
+ Object.assign(state, config);
+ },
+};
diff --git a/src/site/store/images.js b/src/site/store/images.js
new file mode 100644
index 0000000..0d5e82a
--- /dev/null
+++ b/src/site/store/images.js
@@ -0,0 +1,144 @@
+import Vue from 'vue';
+
+export const getDefaultState = () => ({
+ files: [],
+ isLoading: false,
+ pagination: {
+ page: 1,
+ limit: 30,
+ totalFiles: 0,
+ },
+ albumName: null,
+ albumDownloadEnabled: false,
+ fileExtraInfoMap: {}, // information about the selected file
+ fileAlbumsMap: {}, // map of file ids with a list of album objects the file is in
+ fileTagsMap: {}, // map of file ids with a list of tag objects for the file
+});
+
+export const state = getDefaultState;
+
+export const getters = {
+ getTotalFiles: ({ pagination }) => pagination.totalFiles,
+ getFetchedCount: ({ files }) => files.length,
+ shouldPaginate: ({ pagination }) => pagination.totalFiles > pagination.limit,
+ getLimit: ({ pagination }) => pagination.limit,
+ getName: ({ name }) => name,
+};
+
+export const actions = {
+ async fetch({ commit, dispatch, state }, page) {
+ commit('setIsLoading');
+
+ page = page || 1;
+
+ try {
+ const response = await this.$axios.$get('files', { params: { limit: state.pagination.limit, page } });
+
+ commit('setFilesAndMeta', { ...response, page });
+
+ return response;
+ } catch (e) {
+ dispatch('alert/set', { text: e.message, error: true }, { root: true });
+ }
+
+ return null;
+ },
+ async fetchByAlbumId({ commit, state }, { id, page }) {
+ commit('setIsLoading');
+
+ page = page || 1;
+
+ const response = await this.$axios.$get(`album/${id}/full`, {
+ params: { limit: state.pagination.limit, page },
+ });
+
+ commit('setFilesAndMeta', { ...response, page });
+
+ return response;
+ },
+ async fetchFileMeta({ commit }, fileId) {
+ const response = await this.$axios.$get(`file/${fileId}`);
+
+ commit('setFileAlbums', { ...response, fileId });
+ commit('setFileTags', { ...response, fileId });
+ commit('setFileExtraInfo', { ...response, fileId });
+
+ return response;
+ },
+ async getFileAlbums({ commit }, fileId) {
+ const response = await this.$axios.$get(`file/${fileId}/albums`);
+
+ commit('setFileAlbums', { ...response, fileId });
+
+ return response;
+ },
+ async addToAlbum({ commit }, { fileId, albumId }) {
+ const response = await this.$axios.$post('file/album/add', { fileId, albumId });
+
+ commit('addAlbumToFile', { fileId, albumId, ...response.data });
+
+ return response;
+ },
+ async removeFromAlbum({ commit }, { fileId, albumId }) {
+ const response = await this.$axios.$post('file/album/del', { fileId, albumId });
+
+ commit('removeAlbumFromFile', { fileId, albumId });
+
+ return response;
+ },
+ async deleteFile({ commit }, fileId) {
+ const response = await this.$axios.$delete(`file/${fileId}`);
+
+ commit('removeFile', fileId);
+
+ return response;
+ },
+};
+
+export const mutations = {
+ setIsLoading(state) {
+ state.isLoading = true;
+ },
+ setFilesAndMeta(state, {
+ files, name, page, count, downloadEnabled,
+ }) {
+ state.files = files || [];
+ state.albumName = name ?? null;
+ state.downloadEnabled = downloadEnabled ?? false;
+ state.isLoading = false;
+ state.pagination.page = page || 1;
+ state.pagination.totalFiles = count || 0;
+ },
+ removeFile(state, fileId) {
+ const foundIndex = state.files.findIndex(({ id }) => id === fileId);
+ if (foundIndex > -1) {
+ state.files.splice(foundIndex, 1);
+ state.pagination.totalFiles -= 1;
+ }
+ },
+ setFileAlbums(state, { fileId, albums }) {
+ Vue.set(state.fileAlbumsMap, fileId, albums);
+ },
+ setFileTags(state, { fileId, tags }) {
+ Vue.set(state.fileTagsMap, fileId, tags);
+ },
+ setFileExtraInfo(state, { fileId, file }) {
+ Vue.set(state.fileExtraInfoMap, fileId, file);
+ },
+ addAlbumToFile(state, { fileId, album }) {
+ if (!state.fileAlbumsMap[fileId]) return;
+
+ state.fileAlbumsMap[fileId].push(album);
+ },
+ removeAlbumFromFile(state, { fileId, albumId }) {
+ if (!state.fileAlbumsMap[fileId]) return;
+
+ const foundIndex = state.fileAlbumsMap[fileId].findIndex(({ id }) => id === albumId);
+ if (foundIndex > -1) {
+ state.fileAlbumsMap[fileId].splice(foundIndex, 1);
+ }
+ },
+ resetState(state) {
+ Object.assign(state, getDefaultState());
+ },
+};
diff --git a/src/site/store/index.js b/src/site/store/index.js
index 1fc2272..c0faffb 100644
--- a/src/site/store/index.js
+++ b/src/site/store/index.js
@@ -1,66 +1,21 @@
import config from '../../../dist/config.json';
-export const state = () => ({
- loggedIn: false,
- user: null,
- token: null,
- config: null,
- alert: null
-});
-
-/* eslint-disable no-shadow */
-export const mutations = {
- loggedIn(state, payload) {
- state.loggedIn = payload;
- },
- user(state, payload) {
- state.user = payload;
- },
- token(state, payload) {
- state.token = payload;
- },
- config(state, payload) {
- state.config = payload;
- },
- alert(state, payload) {
- state.alert = payload;
- }
-};
+// eslint-disable-next-line import/prefer-default-export
export const actions = {
- async nuxtClientInit({ commit, dispatch }, { app, req }) {
- commit('config', config);
+ async nuxtClientInit({ commit, dispatch }) {
+ commit('config/set', config);
const cookies = this.$cookies.getAll();
- if (!cookies.token) return dispatch('logout');
+ if (!cookies.token) return dispatch('auth/logout');
- commit('token', cookies.token);
- try {
- const response = await this.$axios.$get('verify');
- dispatch('login', {
- token: cookies.token,
- user: response.user
- });
- } catch (error) {
- // dispatch('logout');
- }
- },
- login({ commit }, { token, user }) {
- this.$cookies.set('token', token);
- commit('token', token);
- commit('user', user);
- commit('loggedIn', true);
- },
- logout({ commit }) {
- this.$cookies.remove('token');
- commit('token', null);
- commit('user', null);
- commit('loggedIn', false);
+ commit('auth/setToken', cookies.token);
+ return dispatch('auth/verify');
},
- alert({ commit }, payload) {
+ /* alert({ commit }, payload) {
if (!payload) return commit('alert', null);
commit('alert', {
text: payload.text,
error: payload.error
});
- }
+ } */
};