Improve album sharing and album UX #18 #309

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer
2020-06-27 13:08:45 +02:00
parent 92d096fe88
commit e7fecd3b27
22 changed files with 213 additions and 74 deletions

View File

@@ -57,7 +57,7 @@ class Viewer {
show(items, index = 0) {
if (!Array.isArray(items) || items.length === 0 || index >= items.length) {
console.log("Array passed to gallery was empty:", items);
console.log("photo list passed to gallery was empty:", items);
return;
}

View File

@@ -103,13 +103,13 @@
<v-flex xs12 sm6 class="pa-2">
<v-select
:disabled="!model.AccShare"
:label="label.ShareExpires"
:label="label.Expires"
browser-autocomplete="off"
hide-details
color="secondary-dark"
item-text="text"
item-value="value"
v-model="model.ShareExpires"
v-model="model.Expires"
:items="items.expires">
</v-select>
</v-flex>
@@ -346,7 +346,7 @@
AccType: this.$gettext("Type"),
SharePath: this.$gettext("Default Folder"),
ShareSize: this.$gettext("Size"),
ShareExpires: this.$gettext("Expires"),
Expires: this.$gettext("Expires"),
SyncPath: this.$gettext("Folder"),
SyncInterval: this.$gettext("Interval"),
SyncFilenames: this.$gettext("Preserve filenames"),

View File

@@ -20,7 +20,8 @@
<template v-slot:header>
<button class="text-xs-left action-url ml-0 mt-0 mb-0 pa-0 mr-2" @click.stop="copyUrl(link)" style="user-select: none;">
<v-icon size="16" class="pr-1">link</v-icon>
/s/<strong style="font-weight: 500;" v-if="link.ShareToken">{{ link.ShareToken.toLowerCase() }}</strong><span v-else>...</span>
/s/<strong style="font-weight: 500;" v-if="link.Token">{{ link.Token.toLowerCase()
}}</strong><span v-else>...</span>
</button>
</template>
<v-card>
@@ -46,7 +47,7 @@
color="secondary-dark"
item-text="text"
item-value="value"
v-model="link.ShareExpires"
v-model="link.Expires"
:items="items.expires"
class="input-expires"
>
@@ -59,7 +60,7 @@
:label="$gettext('Secret')"
:placeholder="$gettext('Token')"
color="secondary-dark"
v-model="link.ShareToken"
v-model="link.Token"
class="input-secret"
></v-text-field>
</v-flex>
@@ -171,7 +172,7 @@
expires(link) {
let result = this.$gettext('Expires');
if (link.ShareExpires <= 0) {
if (link.Expires <= 0) {
return result
}

View File

@@ -49,7 +49,7 @@ export class Account extends RestModel {
RetryLimit: 3,
SharePath: "/",
ShareSize: "",
ShareExpires: 0,
Expires: 0,
SyncPath: "/",
SyncStatus: "",
SyncInterval: 86400,

View File

@@ -66,10 +66,6 @@ export class Album extends RestModel {
return this.Slug;
}
getId() {
return this.UID;
}
getTitle() {
return this.Title;
}

View File

@@ -98,10 +98,6 @@ export class File extends RestModel {
return this.Root + "/" + this.Name;
}
getId() {
return this.UID;
}
thumbnailUrl(type) {
if(this.Error) {
return "/api/v1/svg/broken";

View File

@@ -85,10 +85,6 @@ export class Folder extends RestModel {
return this.Root + "/" + this.Path;
}
getId() {
return this.UID;
}
thumbnailUrl() {
return "/api/v1/svg/folder";
}

View File

@@ -56,10 +56,6 @@ export class Label extends RestModel {
return this.Slug;
}
getId() {
return this.UID;
}
getTitle() {
return this.Name;
}

View File

@@ -36,30 +36,35 @@ export default class Link extends Model {
getDefaults() {
return {
UID: "",
ShareUID: "",
ShareToken: "",
ShareExpires: 0,
Share: "",
Slug: "",
Token: "",
Expires: 0,
Password: "",
HasPassword: false,
CanComment: false,
CanEdit: false,
CreatedAt: "",
UpdatedAt: "",
ModifiedAt: "",
};
}
url() {
let token = this.ShareToken.toLowerCase();
let token = this.Token.toLowerCase();
if(!token) {
token = "...";
}
return `${window.location.origin}/s/${token}/${this.ShareUID}`;
if(this.hasSlug()) {
return `${window.location.origin}/s/${token}/${this.Slug}`;
}
return `${window.location.origin}/s/${token}/${this.Share}`;
}
caption() {
return `/s/${this.ShareToken.toLowerCase()}`;
return `/s/${this.Token.toLowerCase()}`;
}
getId() {
@@ -70,6 +75,14 @@ export default class Link extends Model {
return !!this.getId();
}
getSlug() {
return this.Slug ? this.Slug : "";
}
hasSlug() {
return !!this.getSlug();
}
clone() {
return new this.constructor(this.getValues());
}
@@ -95,7 +108,7 @@ export default class Link extends Model {
}
expires() {
return DateTime.fromISO(this.UpdatedAt).plus({ seconds: this.ShareExpires }).toLocaleString(DateTime.DATE_SHORT);
return DateTime.fromISO(this.UpdatedAt).plus({ seconds: this.Expires }).toLocaleString(DateTime.DATE_SHORT);
}
static getCollectionResource() {

View File

@@ -146,10 +146,6 @@ export class Photo extends RestModel {
return this.Title;
}
getId() {
return this.UID;
}
getTitle() {
return this.Title;
}

View File

@@ -35,13 +35,17 @@ import Link from "./link";
export class Rest extends Model {
getId() {
return this.ID;
return this.UID ? this.UID : this.ID;
}
hasId() {
return !!this.getId();
}
getSlug() {
return this.Slug ? this.Slug : "";
}
clone() {
return new this.constructor(this.getValues());
}
@@ -86,7 +90,8 @@ export class Rest extends Model {
return Api
.post(this.getEntityResource() + "/links", {
"Password": password ? password : "",
"ShareExpires": expires ? expires : 0,
"Expires": expires ? expires : 0,
"Slug": this.getSlug(),
"CanEdit": false,
"CanComment": false,
})
@@ -148,6 +153,10 @@ export class Rest extends Model {
return Api.options(this.getCollectionResource()).then(resp => Promise.resolve(new Form(resp.data)));
}
static limit() {
return 3333;
}
static search(params) {
const options = {
params: params,

View File

@@ -70,10 +70,6 @@ export class User extends RestModel {
return this.FirstName + " " + this.LastName;
}
getId() {
return this.ID;
}
getRegisterForm() {
return Api.options(this.getEntityResource() + "/register").then(response => Promise.resolve(new Form(response.data)));
}

View File

@@ -92,7 +92,7 @@
uid: uid,
results: [],
scrollDisabled: true,
pageSize: 120,
pageSize: 60,
offset: 0,
page: 0,
selection: this.$clipboard.selection,
@@ -157,11 +157,41 @@
} else if (showMerged) {
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
} else {
this.$viewer.show(Thumb.fromPhotos(this.results), index);
this.findAll().then((results) => {
this.$viewer.show(Thumb.fromPhotos(results), index);
});
}
return true;
},
findAll() {
if(this.scrollDisabled) {
return Promise.resolve(this.results);
}
const count = Photo.limit();
const offset = 0;
const params = {
count: count,
offset: offset,
album: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true,
};
Object.assign(params, this.lastFilter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
return Photo.search(params).then(resp => {
return Promise.resolve(resp.models);
}).catch(() => {
return Promise.resolve(this.results);
});
},
loadMore() {
if (this.scrollDisabled) return;

View File

@@ -106,6 +106,7 @@
listen: false,
dirty: false,
results: [],
allResults: [],
scrollDisabled: true,
pageSize: 60,
offset: 0,
@@ -197,9 +198,42 @@
} else if (showMerged) {
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
} else {
this.$viewer.show(Thumb.fromPhotos(this.results), index);
this.findAll().then((results) => {
this.$viewer.show(Thumb.fromPhotos(results), index);
});
}
},
findAll() {
if(this.scrollDisabled) {
return Promise.resolve(this.results);
}
if(this.allResults && !this.dirty) {
return Promise.resolve(this.allResults);
}
const count = Photo.limit();
const offset = 0;
const params = {
count: count,
offset: offset,
merged: true,
};
Object.assign(params, this.lastFilter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
return Photo.search(params).then(resp => {
this.allResults = resp.models
return Promise.resolve(this.allResults);
}).catch(() => {
return Promise.resolve(this.results);
});
},
loadMore() {
if (this.scrollDisabled) return;

View File

@@ -130,7 +130,7 @@
uid: uid,
results: [],
scrollDisabled: true,
pageSize: 120,
pageSize: 60,
offset: 0,
page: 0,
selection: this.$clipboard.selection,
@@ -200,11 +200,41 @@
} else if (showMerged) {
this.$viewer.show(Thumb.fromFiles([this.results[index]]), 0)
} else {
this.$viewer.show(Thumb.fromPhotos(this.results), index);
this.findAll().then((results) => {
this.$viewer.show(Thumb.fromPhotos(results), index);
});
}
return true;
},
findAll() {
if(this.scrollDisabled) {
return Promise.resolve(this.results);
}
const count = Photo.limit();
const offset = 0;
const params = {
count: count,
offset: offset,
album: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true,
};
Object.assign(params, this.lastFilter);
if (this.staticFilter) {
Object.assign(params, this.staticFilter);
}
return Photo.search(params).then(resp => {
return Promise.resolve(resp.models);
}).catch(() => {
return Promise.resolve(this.results);
});
},
loadMore() {
if (this.scrollDisabled) return;

View File

@@ -31,6 +31,8 @@ func UpdateLink(c *gin.Context) {
link := entity.FindLink(c.Param("link"))
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
link.ShareExpires = f.ShareExpires
if f.ShareToken != "" {
@@ -93,9 +95,9 @@ func CreateLink(c *gin.Context) {
link := entity.NewLink(c.Param("uid"), f.CanComment, f.CanEdit)
if f.ShareExpires > 0 {
link.ShareExpires = f.ShareExpires
}
link.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews
link.ShareExpires = f.ShareExpires
if f.Password != "" {
if err := link.SetPassword(f.Password); err != nil {

View File

@@ -18,7 +18,7 @@ func TestLinkAlbum(t *testing.T) {
CreateAlbumLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
if resp.Code != http.StatusOK {
t.Fatal(resp.Body.String())
@@ -39,7 +39,7 @@ func TestLinkAlbum(t *testing.T) {
t.Run("album does not exist", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateAlbumLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/xxx/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/xxx/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
@@ -53,7 +53,7 @@ func TestLinkAlbum(t *testing.T) {
CreateAlbumLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "ShareExpires": "abc", "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/albums/at9lxuqxpogaaba7/links", `{"Password": "foobar", "Expires": "abc", "CanEdit": true}`)
if resp.Code != http.StatusBadRequest {
t.Fatal(resp.Body.String())
@@ -69,7 +69,7 @@ func TestLinkPhoto(t *testing.T) {
CreatePhotoLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, resp.Code)
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil {
@@ -88,7 +88,7 @@ func TestLinkPhoto(t *testing.T) {
CreatePhotoLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/xxx/link", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/xxx/link", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
@@ -97,7 +97,7 @@ func TestLinkPhoto(t *testing.T) {
t.Run("invalid request", func(t *testing.T) {
app, router, _ := NewApiTest()
CreatePhotoLink(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"xxx": 123, "ShareExpires": "abc", "CanEdit": "xxx"}`)
r := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"xxx": 123, "Expires": "abc", "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}
@@ -110,7 +110,7 @@ func TestLinkLabel(t *testing.T) {
CreateLabelLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
assert.Equal(t, http.StatusOK, resp.Code)
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil {
@@ -127,7 +127,7 @@ func TestLinkLabel(t *testing.T) {
t.Run("label not found", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateLabelLink(router)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/xxx/links", `{"Password": "foobar", "ShareExpires": 0, "CanEdit": true}`)
resp := PerformRequestWithBody(app, "POST", "/api/v1/labels/xxx/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`)
if resp.Code != http.StatusNotFound {
t.Fatal(resp.Body.String())
@@ -136,7 +136,7 @@ func TestLinkLabel(t *testing.T) {
t.Run("invalid request", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateLabelLink(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"xxx": 123, "ShareExpires": "abc", "CanEdit": "xxx"}`)
r := PerformRequestWithBody(app, "POST", "/api/v1/labels/lt9k3pw1wowuy3c2/links", `{"xxx": 123, "Expires": "abc", "CanEdit": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
}

View File

@@ -34,9 +34,9 @@ func Shares(router *gin.RouterGroup) {
conf := service.Config()
shareToken := c.Param("token")
shareUID := c.Param("uid")
share := c.Param("uid")
links := entity.FindValidLinks(shareToken, shareUID)
links := entity.FindValidLinks(shareToken, share)
if len(links) != 1 {
log.Warn("share: invalid token or uid")
@@ -44,10 +44,17 @@ func Shares(router *gin.RouterGroup) {
return
}
clientConfig := conf.GuestConfig()
clientConfig.SitePreview = fmt.Sprintf("%ss/%s/%s/preview", clientConfig.SiteUrl, shareToken, shareUID)
uid := links[0].ShareUID
if a, err := query.AlbumByUID(shareUID); err == nil {
if uid != share {
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("/s/%s/%s", shareToken, uid))
return
}
clientConfig := conf.GuestConfig()
clientConfig.SitePreview = fmt.Sprintf("%ss/%s/%s/preview", clientConfig.SiteUrl, shareToken, uid)
if a, err := query.AlbumByUID(uid); err == nil {
clientConfig.SiteCaption = a.AlbumTitle
if a.AlbumDescription != "" {

View File

@@ -4,8 +4,11 @@ import (
"fmt"
"time"
"github.com/gosimple/slug"
"github.com/jinzhu/gorm"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt"
)
type Links []Link
@@ -13,9 +16,10 @@ type Links []Link
// Link represents a sharing link.
type Link struct {
LinkUID string `gorm:"type:varbinary(42);primary_key;" json:"UID,omitempty" yaml:"UID,omitempty"`
ShareUID string `gorm:"type:varbinary(42);unique_index:idx_links_uid_token;" json:"ShareUID"`
ShareToken string `gorm:"type:varbinary(255);unique_index:idx_links_uid_token;" json:"ShareToken"`
ShareExpires int `json:"ShareExpires" yaml:"ShareExpires,omitempty"`
ShareUID string `gorm:"type:varbinary(42);unique_index:idx_links_uid_token;" json:"Share" yaml:"Share"`
ShareSlug string `gorm:"type:varbinary(255);index;" json:"Slug" yaml:"Slug,omitempty"`
ShareToken string `gorm:"type:varbinary(255);unique_index:idx_links_uid_token;" json:"Token" yaml:"Token,omitempty"`
ShareExpires int `json:"Expires" yaml:"Expires,omitempty"`
ShareViews uint `json:"ShareViews" yaml:"-"`
MaxViews uint `json:"MaxViews" yaml:"-"`
HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"`
@@ -76,6 +80,10 @@ func (m *Link) Expired() bool {
return now.Before(expires)
}
func (m *Link) SetSlug(s string) {
m.ShareSlug = slug.Make(txt.Clip(s, txt.ClipSlug))
}
func (m *Link) SetPassword(password string) error {
pw := NewPassword(m.LinkUID, password)
@@ -140,8 +148,8 @@ func FindLink(linkUID string) *Link {
}
// FindLinks returns a slice of links for a token and share UID (at least one must be provided).
func FindLinks(shareToken, shareUID string) (result Links) {
if shareToken == "" && shareUID == "" {
func FindLinks(shareToken, share string) (result Links) {
if shareToken == "" && share == "" {
log.Errorf("link: share token and uid must not be empty at the same time (find links)")
return []Link{}
}
@@ -152,8 +160,12 @@ func FindLinks(shareToken, shareUID string) (result Links) {
q = q.Where("share_token = ?", shareToken)
}
if shareUID != "" {
q = q.Where("share_uid = ?", shareUID)
if share != "" {
if rnd.IsPPID(share, 'a') {
q = q.Where("share_uid = ?", share)
} else {
q = q.Where("share_slug = ?", share)
}
}
if err := q.Find(&result).Error; err != nil {

View File

@@ -3,8 +3,9 @@ package form
// Link represents a link sharing form.
type Link struct {
Password string `json:"Password"`
ShareToken string `json:"ShareToken"`
ShareExpires int `json:"ShareExpires"`
ShareSlug string `json:"Slug"`
ShareToken string `json:"Token"`
ShareExpires int `json:"Expires"`
MaxViews uint `json:"MaxViews"`
CanComment bool `json:"CanComment"`
CanEdit bool `json:"CanEdit"`

View File

@@ -1,8 +1,18 @@
olymp
olympus
ilford
fujifilm
fuji
leica
panasonic
sony
canon
nikon
iphone
flickr
picasa
backup
backups
imac
ipad
android
@@ -14,7 +24,9 @@ pict
picture
pictures
upload
uploads
download
downloads
temp
var
lib

View File

@@ -4,10 +4,20 @@ package txt
// StopWords contains a list of stopwords for full-text indexing.
var StopWords = map[string]bool{
"olymp": true,
"olympus": true,
"ilford": true,
"fujifilm": true,
"fuji": true,
"leica": true,
"panasonic": true,
"sony": true,
"canon": true,
"nikon": true,
"iphone": true,
"flickr": true,
"picasa": true,
"backup": true,
"backups": true,
"imac": true,
"ipad": true,
"android": true,
@@ -19,7 +29,9 @@ var StopWords = map[string]bool{
"picture": true,
"pictures": true,
"upload": true,
"uploads": true,
"download": true,
"downloads": true,
"temp": true,
"var": true,
"lib": true,