UX: Refactor Library UI

This commit is contained in:
Michael Mayer 2020-12-18 13:05:48 +01:00
parent 0925d7179c
commit 43714c00d5
10 changed files with 207 additions and 93 deletions

View file

@ -32,7 +32,7 @@ import Event from "pubsub-js";
class Log {
constructor() {
this.cap = 100;
this.cap = 150;
this.created = new Date;
this.logs = [
/* EXAMPLE LOG MESSAGE

View file

@ -31,6 +31,7 @@ https://docs.photoprism.org/developer-guide/
import RestModel from "model/rest";
import Api from "common/api";
import {$gettext} from "common/vm";
import {config} from "../session";
export class Account extends RestModel {
getDefaults() {
@ -57,7 +58,7 @@ export class Account extends RestModel {
SyncDate: null,
SyncFilenames: true,
SyncUpload: false,
SyncDownload: true,
SyncDownload: !config.get("readonly"),
SyncRaw: true,
CreatedAt: "",
UpdatedAt: "",

View file

@ -8,35 +8,17 @@
slider-color="secondary-dark"
:height="$vuetify.breakpoint.smAndDown ? 48 : 64"
>
<v-tab id="tab-index" ripple @click="changePath('/library')">
<translate key="Index">Index</translate>
</v-tab>
<v-tab id="tab-import" :disabled="readonly || !$config.feature('import')" ripple
@click="changePath('/library/import')">
<template v-if="config.settings.import.move">
<translate key="Move">Move</translate>
</template>
<v-tab v-for="(tab, index) in tabs" :key="index" :id="'tab-' + tab.name" :class="tab.class" ripple
@click="changePath(tab.path)">
<v-icon v-if="$vuetify.breakpoint.smAndDown" :title="tab.label">{{ tab.icon }}</v-icon>
<template v-else>
<translate key="Copy">Copy</translate>
<v-icon :size="18" left>{{ tab.icon }}</v-icon> {{ tab.label }}
</template>
</v-tab>
<v-tab id="tab-logs" ripple @click="changePath('/library/logs')" v-if="$config.feature('logs')">
<translate key="Logs">Logs</translate>
</v-tab>
<v-tabs-items touchless>
<v-tab-item lazy>
<p-tab-index></p-tab-index>
</v-tab-item>
<v-tab-item :disabled="readonly" lazy>
<p-tab-import></p-tab-import>
</v-tab-item>
<v-tab-item v-if="$config.feature('logs')">
<p-tab-logs></p-tab-logs>
<v-tab-item lazy v-for="(tab, index) in tabs" :key="index">
<component v-bind:is="tab.component"></component>
</v-tab-item>
</v-tabs-items>
</v-tabs>
@ -48,10 +30,21 @@ import tabImport from "pages/library/import.vue";
import tabIndex from "pages/library/index.vue";
import tabLogs from "pages/library/logs.vue";
function initTabs(flag, tabs) {
let i = 0;
while(i < tabs.length) {
if(!tabs[i][flag]) {
tabs.splice(i,1);
} else {
i++;
}
}
}
export default {
name: 'p-page-library',
props: {
tab: Number
tab: String,
},
components: {
'p-tab-index': tabIndex,
@ -59,10 +52,66 @@ export default {
'p-tab-logs': tabLogs,
},
data() {
const config = this.$config.values;
const isDemo = this.$config.get("demo");
const isPublic = this.$config.get("public");
const isReadOnly = this.$config.get("readonly");
const canImport = this.$config.feature('import') && !isReadOnly;
const tabs = [
{
'name': 'library-index',
'component': tabIndex,
'label': this.$gettext('Index'),
'class': '',
'path': '/library',
'icon': 'update',
'readonly': true,
'demo': true,
},
{
'name': 'library-import',
'component': tabImport,
'label': this.$gettext('Import'),
'class': '',
'path': '/library/import',
'icon': 'perm_media',
'readonly': false,
'demo': true,
},
{
'name': 'library-logs',
'component': tabLogs,
'label': this.$gettext('Logs'),
'class': '',
'path': '/library/logs',
'icon': 'notes',
'readonly': true,
'demo': true,
},
];
if(isDemo) {
initTabs("demo", tabs);
}
if(!canImport) {
initTabs("readonly", tabs);
}
let active = 0;
if (typeof this.tab === 'string' && this.tab !== '') {
active = tabs.findIndex((t) => t.name === this.tab);
}
return {
config: this.$config.values,
readonly: this.$config.get("readonly"),
active: this.tab,
tabs: tabs,
demo: isDemo,
public: isPublic,
config: config,
readonly: isReadOnly,
active: active,
}
},
methods: {

View file

@ -10,8 +10,11 @@
:height="$vuetify.breakpoint.smAndDown ? 48 : 64"
>
<v-tab v-for="(tab, index) in tabs" :key="index" :id="'tab-' + tab.name" :class="tab.class" ripple
@click="changePath(tab.path)" :title="tab.label">
<v-icon>{{ tab.icon }}</v-icon>
@click="changePath(tab.path)">
<v-icon v-if="$vuetify.breakpoint.smAndDown" :title="tab.label">{{ tab.icon }}</v-icon>
<template v-else>
<v-icon :size="18" left>{{ tab.icon }}</v-icon> {{ tab.label }}
</template>
</v-tab>
<v-tabs-items touchless>
@ -28,52 +31,8 @@ import tabGeneral from "pages/settings/general.vue";
import tabLibrary from "pages/settings/library.vue";
import tabSync from "pages/settings/sync.vue";
import tabAccount from "pages/settings/account.vue";
import {$gettext} from "common/vm";
const tabs = [
{
'name': 'settings-general',
'component': tabGeneral,
'label': $gettext('General'),
'class': '',
'path': '/settings',
'icon': 'tv',
'public': true,
'demo': true,
},
{
'name': 'settings-library',
'component': tabLibrary,
'label': $gettext('Library'),
'class': '',
'path': '/settings/library',
'icon': 'camera_roll',
'public': true,
'demo': true,
},
{
'name': 'settings-sync',
'component': tabSync,
'label': $gettext('Sync'),
'class': '',
'path': '/settings/sync',
'icon': 'sync_alt',
'public': false,
'demo': true,
},
{
'name': 'settings-account',
'component': tabAccount,
'label': $gettext('Account'),
'class': '',
'path': '/settings/account',
'icon': 'vpn_key',
'public': false,
'demo': true,
},
];
function initTabs(flag) {
function initTabs(flag, tabs) {
let i = 0;
while(i < tabs.length) {
if(!tabs[i][flag]) {
@ -98,11 +57,53 @@ export default {
data() {
const isDemo = this.$config.get("demo");
const isPublic = this.$config.get("public");
const tabs = [
{
'name': 'settings-general',
'component': tabGeneral,
'label': this.$gettext('General'),
'class': '',
'path': '/settings',
'icon': 'tv',
'public': true,
'demo': true,
},
{
'name': 'settings-library',
'component': tabLibrary,
'label': this.$gettext('Library'),
'class': '',
'path': '/settings/library',
'icon': 'camera_roll',
'public': true,
'demo': true,
},
{
'name': 'settings-sync',
'component': tabSync,
'label': this.$gettext('Sync'),
'class': '',
'path': '/settings/sync',
'icon': 'sync_alt',
'public': true,
'demo': true,
},
{
'name': 'settings-account',
'component': tabAccount,
'label': this.$gettext('Account'),
'class': '',
'path': '/settings/account',
'icon': 'person',
'public': false,
'demo': true,
},
];
if(isDemo) {
initTabs("demo");
initTabs("demo", tabs);
} else if(isPublic) {
initTabs("public");
initTabs("public", tabs);
}
let active = 0;

View file

@ -190,7 +190,7 @@
color="secondary-dark"
:label="$gettext('Import')"
:hint="$gettext('Imported files will be sorted by date and given a unique name.')"
prepend-icon="create_new_folder"
prepend-icon="perm_media"
persistent-hint
>
</v-checkbox>

View file

@ -249,25 +249,25 @@ export default [
meta: {title: $gettext("People"), auth: true},
},
{
name: "library_logs",
path: "/library/logs",
name: "library",
path: "/library",
component: Library,
meta: {title: $gettext("Library"), auth: true, background: "application-light"},
props: {tab: 2},
props: {tab: "library-index"},
},
{
name: "library_import",
path: "/library/import",
component: Library,
meta: {title: $gettext("Library"), auth: true, background: "application-light"},
props: {tab: 1},
props: {tab: "library-import"},
},
{
name: "library",
path: "/library",
name: "library_logs",
path: "/library/logs",
component: Library,
meta: {title: $gettext("Library"), auth: true, background: "application-light"},
props: {tab: 0},
props: {tab: "library-logs"},
},
{
name: "settings",

View file

@ -31,6 +31,13 @@ func GetAccounts(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
c.JSON(http.StatusOK, entity.Accounts{})
return
}
var f form.AccountSearch
err := c.MustBindWith(&f, binding.Form)
@ -68,6 +75,13 @@ func GetAccount(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
if m, err := query.AccountByID(id); err == nil {
@ -91,6 +105,13 @@ func GetAccountFolders(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
start := time.Now()
id := ParseUint(c.Param("id"))
cache := service.Cache()
@ -191,6 +212,13 @@ func CreateAccount(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
var f form.Account
if err := c.BindJSON(&f); err != nil {
@ -206,8 +234,6 @@ func CreateAccount(router *gin.RouterGroup) {
m, err := entity.CreateAccount(f)
log.Debugf("account: creating %+v %+v", f, m)
if err != nil {
log.Error(err)
AbortBadRequest(c)
@ -233,6 +259,13 @@ func UpdateAccount(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
m, err := query.AccountByID(id)
@ -295,6 +328,13 @@ func DeleteAccount(router *gin.RouterGroup) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
return
}
id := ParseUint(c.Param("id"))
m, err := query.AccountByID(id)

View file

@ -12,9 +12,9 @@ import (
func TestGetAccounts(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, _ := NewApiTest()
app, router, _, sess := NewAdminApiTest()
GetAccounts(router)
r := PerformRequest(app, "GET", "/api/v1/accounts?count=10")
r := AuthenticatedRequest(app, "GET", "/api/v1/accounts?count=10", sess)
val := gjson.Get(r.Body.String(), "#(AccName=\"Test Account\").AccURL")
count := gjson.Get(r.Body.String(), "#")
assert.LessOrEqual(t, int64(1), count.Int())

View file

@ -13,7 +13,7 @@ import (
"github.com/sirupsen/logrus"
)
// NewApiTest returns new API test helper
// NewApiTest returns new API test helper.
func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) {
gin.SetMode(gin.TestMode)
app = gin.New()
@ -21,6 +21,20 @@ func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config
return app, router, service.Config()
}
// NewApiTest returns new API test helper with authenticated admin session.
func NewAdminApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config, sessId string) {
app = gin.New()
router = app.Group("/api/v1")
CreateSession(router)
reader := strings.NewReader(`{"username": "admin", "password": "photoprism"}`)
req, _ := http.NewRequest("POST", "/api/v1/session", reader)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)
sessId = w.Header().Get("X-Session-ID")
gin.SetMode(gin.TestMode)
return app, router, service.Config(), sessId
}
// Performs API request with empty request body.
// See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
@ -30,6 +44,15 @@ func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecor
return w
}
// Performs authenticated API request with empty request body.
func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
req.Header.Add("X-Session-ID", sess)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Performs API request including request body as string.
func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)

View file

@ -16,7 +16,7 @@ func ChangePassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := service.Config()
if conf.Public() {
if conf.Public() || conf.DisableSettings() {
Abort(c, http.StatusForbidden, i18n.ErrPublic)
return
}