Implement batch edit in web interface

This commit is contained in:
Radhi Fadlillah 2019-05-30 16:35:18 +07:00
parent a040b85ee2
commit 05504c2317
10 changed files with 274 additions and 60 deletions

View file

@ -1 +1 @@
.bookmark{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;min-width:0;border:1px solid var(--border);background-color:var(--contentBg);height:100%;position:relative}.bookmark:hover .bookmark-menu>a,.bookmark:focus .bookmark-menu>a{display:block}.bookmark.selected{background-color:var(--selectedBg)}.bookmark .bookmark-selector{position:absolute;top:0;left:0;width:100%;height:100%;z-index:9}.bookmark .bookmark-link{display:block;cursor:default}.bookmark .bookmark-link[href]{cursor:pointer}.bookmark .bookmark-link[href]:hover .title,.bookmark .bookmark-link[href]:focus .title{color:var(--main)}.bookmark .bookmark-link span.thumbnail{width:100%;height:200px;display:block;background-size:cover;background-repeat:no-repeat;background-position:center center;margin-bottom:8px;border-bottom:1px solid var(--border)}.bookmark .bookmark-link .id{color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);font-size:.7em;font-weight:bold;left:-1px;top:-1px;position:absolute;padding:0 .3em;opacity:.7}.bookmark .bookmark-link .title{text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:1.2em;line-height:1.3em;max-height:5.2em;font-weight:600;padding:0 16px;color:var(--color)}.bookmark .bookmark-link .title:first-child{margin-top:16px}.bookmark .bookmark-link .excerpt{color:var(--color);margin-top:8px;padding:0 16px;text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:.9em;line-height:1.5em;max-height:10.5em}.bookmark .bookmark-tags{display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;margin:4px 0 -4px;padding:0 8px}.bookmark .bookmark-tags a{margin:4px;padding:4px 8px;font-size:.8em;font-weight:600;border:1px solid var(--border);border-radius:4px;color:var(--colorLink)}.bookmark .bookmark-tags a:hover,.bookmark .bookmark-tags a:focus{color:var(--main)}.bookmark .bookmark-menu{padding:8px 16px 16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row nowrap;min-width:0;min-height:0;-webkit-box-align:center;align-items:center}.bookmark .bookmark-menu a{color:var(--colorLink);flex-shrink:0;opacity:.8;display:none;font-size:.9em}.bookmark .bookmark-menu a:not(:last-child){margin-right:12px}.bookmark .bookmark-menu a:hover,.bookmark .bookmark-menu a:focus{color:var(--main);opacity:1}.bookmark .bookmark-menu .url{-webkit-box-flex:1;flex:1 0;opacity:1;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:21px}.bookmark .bookmark-menu .url:not([href]){cursor:default;color:var(--colorLink)}.bookmark.list{border-top-width:0;border-bottom-width:1px;padding:16px 24px 16px 100px}.bookmark.list:first-child{border-top-width:1px}.bookmark.list .bookmark-link span.thumbnail{position:absolute;top:0;left:0;width:100px;height:100%;margin-bottom:0;border-bottom:0;border-right:1px solid var(--border)}.bookmark.list .bookmark-link .title{margin:0;padding-left:24px}.bookmark.list .excerpt,.bookmark.list>.spacer{display:none}.bookmark.list .bookmark-tags{padding-left:16px;padding-right:0}.bookmark.list .bookmark-menu{padding:8px 0 0 24px;-webkit-box-align:end;align-items:flex-end}@media (max-width:600px){.bookmark.list{padding:8px 16px 8px 70px;border-width:0 !important;border-bottom-width:1px !important}.bookmark.list .bookmark-link span.thumbnail{width:70px}.bookmark.list .bookmark-link .title{font-size:1.1em;font-weight:500;padding-left:16px}.bookmark.list .bookmark-tags{padding-left:8px}.bookmark.list .bookmark-menu{padding-left:16px}.bookmark.list .bookmark-menu a:not(:first-of-type){display:none}}
.bookmark{display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-flow:column nowrap;min-width:0;border:1px solid var(--border);background-color:var(--contentBg);height:100%;position:relative}.bookmark:hover .bookmark-menu>a,.bookmark:focus .bookmark-menu>a{display:block}.bookmark.selected{background-color:var(--selectedBg)}.bookmark .bookmark-selector{position:absolute;top:0;left:0;width:100%;height:100%;z-index:9}.bookmark .bookmark-link{display:block;cursor:default}.bookmark .bookmark-link[href]{cursor:pointer}.bookmark .bookmark-link[href]:hover .title,.bookmark .bookmark-link[href]:focus .title{color:var(--main)}.bookmark .bookmark-link span.thumbnail{width:100%;height:200px;display:block;background-size:cover;background-repeat:no-repeat;background-position:center center;margin-bottom:8px;border-bottom:1px solid var(--border)}.bookmark .bookmark-link .id{color:var(--color);border:1px solid var(--border);background-color:var(--contentBg);font-size:.7em;font-weight:bold;left:-1px;top:-1px;position:absolute;padding:0 .3em;opacity:.7}.bookmark .bookmark-link .title{text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:1.2em;line-height:1.3em;max-height:5.2em;font-weight:600;padding:0 16px;color:var(--color)}.bookmark .bookmark-link .title:first-child{margin-top:16px}.bookmark .bookmark-link .excerpt{color:var(--color);margin-top:8px;padding:0 16px;text-overflow:ellipsis;word-wrap:break-word;overflow:hidden;font-size:.9em;line-height:1.5em;max-height:10.5em}.bookmark .bookmark-tags{display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row wrap;margin:4px 0 -4px;padding:0 8px}.bookmark .bookmark-tags a{margin:4px;padding:4px 8px;font-size:.8em;font-weight:600;border:1px solid var(--border);border-radius:4px;color:var(--colorLink);background-color:var(--contentBg)}.bookmark .bookmark-tags a:hover,.bookmark .bookmark-tags a:focus{color:var(--main)}.bookmark .bookmark-menu{padding:8px 16px 16px;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-flow:row nowrap;min-width:0;min-height:0;-webkit-box-align:center;align-items:center}.bookmark .bookmark-menu a{color:var(--colorLink);flex-shrink:0;opacity:.8;display:none;font-size:.9em}.bookmark .bookmark-menu a:not(:last-child){margin-right:12px}.bookmark .bookmark-menu a:hover,.bookmark .bookmark-menu a:focus{color:var(--main);opacity:1}.bookmark .bookmark-menu .url{-webkit-box-flex:1;flex:1 0;opacity:1;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:21px}.bookmark .bookmark-menu .url:not([href]){cursor:default;color:var(--colorLink)}.bookmark.list{border-top-width:0;border-bottom-width:1px;padding:16px 24px 16px 100px}.bookmark.list:first-child{border-top-width:1px}.bookmark.list .bookmark-link span.thumbnail{position:absolute;top:0;left:0;width:100px;height:100%;margin-bottom:0;border-bottom:0;border-right:1px solid var(--border)}.bookmark.list .bookmark-link .title{margin:0;padding-left:24px}.bookmark.list .excerpt,.bookmark.list>.spacer{display:none}.bookmark.list .bookmark-tags{padding-left:16px;padding-right:0}.bookmark.list .bookmark-menu{padding:8px 0 0 24px;-webkit-box-align:end;align-items:flex-end}@media (max-width:600px){.bookmark.list{padding:8px 16px 8px 70px;border-width:0 !important;border-bottom-width:1px !important}.bookmark.list .bookmark-link span.thumbnail{width:70px}.bookmark.list .bookmark-link .title{font-size:1.1em;font-weight:500;padding-left:16px}.bookmark.list .bookmark-tags{padding-left:8px}.bookmark.list .bookmark-menu{padding-left:16px}.bookmark.list .bookmark-menu a:not(:first-of-type){display:none}}

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,9 @@
var template = `
<div class="bookmark" :class="{list: listMode}">
<div class="bookmark" :class="{list: listMode, selected: selected}">
<a class="bookmark-selector"
v-if="editMode"
@click="selectBookmark">
</a>
<a class="bookmark-link" :href="url" target="_blank">
<span class="thumbnail" v-if="imageURL" :style="thumbnailStyleURL"></span>
<p class="title">{{title}}</p>
@ -14,15 +18,17 @@ var template = `
<a class="url" :href="url" target="_blank">
{{hostnameURL}}
</a>
<a title="Edit bookmark" @click="editBookmark">
<i class="fas fa-pencil-alt"></i>
</a>
<a title="Delete bookmark" @click="deleteBookmark">
<i class="fas fa-trash-alt"></i>
</a>
<a title="Update cache" @click="updateBookmark">
<i class="fas fa-cloud-download-alt"></i>
</a>
<template v-if="!editMode">
<a title="Edit bookmark" @click="editBookmark">
<i class="fas fa-fw fa-pencil-alt"></i>
</a>
<a title="Delete bookmark" @click="deleteBookmark">
<i class="fas fa-fw fa-trash-alt"></i>
</a>
<a title="Update archive" @click="updateBookmark">
<i class="fas fa-fw fa-cloud-download-alt"></i>
</a>
</template>
</div>
</div>`;
@ -34,9 +40,11 @@ export default {
title: String,
excerpt: String,
imageURL: String,
showId: Boolean,
listMode: Boolean,
index: Number,
showId: Boolean,
editMode: Boolean,
listMode: Boolean,
selected: Boolean,
tags: {
type: Array,
default () {
@ -68,6 +76,9 @@ export default {
tagClicked(name) {
this.$emit("tagClicked", name);
},
selectBookmark() {
this.$emit("select", this.eventItem);
},
editBookmark() {
this.$emit("edit", this.eventItem);
},

View file

@ -4,7 +4,7 @@ export default {
dialog: {},
displayOptions: {
showId: false,
listMode: true,
listMode: false,
}
}
},

View file

@ -8,33 +8,51 @@ var template = `
<a title="Add new bookmark" @click="showDialogAdd">
<i class="fas fa-fw fa-plus-circle"></i>
</a>
<a title="Batch edit">
<i class="fas fa-fw fa-pencil-alt"></i>
</a>
<a title="Show tags">
<i class="fas fa-fw fa-tags"></i>
</a>
<a title="Batch edit" @click="toggleEditMode">
<i class="fas fa-fw fa-pencil-alt"></i>
</a>
</div>
<div class="page-header" id="edit-box" v-if="editMode">
<p>{{selection.length}} items selected</p>
<a title="Delete bookmark" @click="showDialogDelete(selection)">
<i class="fas fa-fw fa-trash-alt"></i>
</a>
<a title="Add tags" @click="showDialogAddTags(selection)">
<i class="fas fa-fw fa-tags"></i>
</a>
<a title="Update archives" @click="showDialogUpdateArchive(selection)">
<i class="fas fa-fw fa-cloud-download-alt"></i>
</a>
<a title="Cancel" @click="toggleEditMode">
<i class="fas fa-fw fa-times"></i>
</a>
</div>
<div id="bookmarks-grid" ref="bookmarksGrid" :class="{list: displayOptions.listMode}">
<bookmark-item v-for="(book, index) in bookmarks"
v-bind="book"
:index="index"
:key="book.id"
:editMode="editMode"
:showId="displayOptions.showId"
:listMode="displayOptions.listMode"
:selected="isSelected(book.id)"
@select="toggleSelection"
@tagClicked="filterTag"
@delete="showDialogDelete"
@edit="showDialogEdit"
@delete="showDialogDelete"
@update="showDialogUpdateArchive">
</bookmark-item>
<div class="pagination-box" v-if="maxPage > 0">
<p>Page</p>
<input type="text"
placeholder="1"
:value="page"
@focus="$event.target.select()"
@keyup.enter="changePage($event.target.value)"
:disabled="editMode">
placeholder="1"
:value="page"
@focus="$event.target.select()"
@keyup.enter="changePage($event.target.value)"
:disabled="editMode">
<p>{{maxPage}}</p>
<div class="spacer"></div>
<template v-if="!editMode">
@ -73,6 +91,8 @@ export default {
return {
loading: false,
editMode: false,
selection: [],
search: "",
page: 0,
maxPage: 0,
@ -213,6 +233,18 @@ export default {
this.loadData();
}
},
toggleEditMode() {
this.selection = [];
this.editMode = !this.editMode;
},
toggleSelection(item) {
var idx = this.selection.findIndex(el => el.id === item.id);
if (idx === -1) this.selection.push(item);
else this.selection.splice(idx, 1);
},
isSelected(bookId) {
return this.selection.findIndex(el => el.id === bookId) > -1;
},
showDialogAdd() {
this.showDialog({
title: "New Bookmark",
@ -418,6 +450,8 @@ export default {
return response;
})
.then(() => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
indices.forEach(index => this.bookmarks.splice(index, 1))
@ -427,7 +461,10 @@ export default {
}
})
.catch(err => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
@ -453,10 +490,10 @@ export default {
var ids = items.map(item => item.id);
this.showDialog({
title: 'Update Archive',
content: 'Update archive for selected bookmarks ? This action is irreversible.',
mainText: 'Yes',
secondText: 'No',
title: "Update Archive",
content: "Update archive for selected bookmarks ? This action is irreversible.",
mainText: "Yes",
secondText: "No",
mainClick: () => {
this.dialog.loading = true;
fetch("/api/archive", {
@ -471,15 +508,104 @@ export default {
return response.json();
})
.then(json => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
json.forEach(book => {
var item = items.find(el => el.id === book.id);
this.bookmarks.splice(item.index, 1, book);
});
})
.catch(err => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})
});
}
});
},
showDialogAddTags(items) {
// Check and filter items
if (typeof items !== "object") return;
if (!Array.isArray(items)) items = [items];
items = items.filter(item => {
var id = (typeof item.id === "number") ? item.id : 0,
index = (typeof item.index === "number") ? item.index : -1;
return id > 0 && index > -1;
});
if (items.length === 0) return;
// Show dialog
this.showDialog({
title: "Add New Tags",
content: "Add new tags to selected bookmarks",
fields: [{
name: "tags",
label: "Comma separated tags",
value: "",
separator: ",",
dictionary: this.tags.map(tag => tag.name)
}],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Validate input
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.split(/\s*,\s*/g)
.filter(tag => tag.trim() !== '')
.map(tag => {
return {
name: tag.trim()
};
});
if (tags.length === 0) return;
// Send data
var request = {
ids: items.map(item => item.id),
tags: tags
}
this.dialog.loading = true;
fetch("/api/bookmarks/tags", {
method: "put",
body: JSON.stringify(request),
headers: {
"Content-Type": "application/json",
},
})
.then(response => {
if (!response.ok) throw response;
return response.json();
})
.then(json => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
json.forEach(book => {
var item = items.find(el => el.id === book.id);
this.bookmarks.splice(item.index, 1, book);
});
})
.catch(err => {
this.selection = [];
this.editMode = false;
this.dialog.loading = false;
err.text().then(msg => {
this.showErrorDialog(`${msg} (${err.status})`);
})

View file

@ -109,6 +109,7 @@
border: 1px solid var(--border);
border-radius: 4px;
color: var(--colorLink);
background-color: var(--contentBg);
&:hover,
&:focus {

View file

@ -10,6 +10,7 @@
--colorSidebar: #FFF;
--main: #F44336;
--errorColor: #F44336;
--selectedBg: #ffe7e5;
}
&.night {
@ -18,6 +19,7 @@
--contentBg: #292929;
--border: #191919;
--color: #FFF;
--selectedBg: #261918;
}
* {
@ -226,6 +228,10 @@ body {
background-color: var(--headerBg);
border-bottom: 1px solid var(--border);
padding: 0 16px;
grid-row-end: 1;
grid-row-start: 1;
grid-column-end: -1;
grid-column-start: 1;
p {
flex: 1 0;
@ -317,11 +323,10 @@ body {
flex-flow: row wrap;
p {
flex: auto;
text-align: center;
flex: 1 0;
font-size: 1em;
font-weight: 500;
line-height: 3em;
width: 100%;
padding: 0;
}
@ -330,11 +335,6 @@ body {
font-size: 1em;
font-weight: 500;
line-height: 3em;
color: var(--color);
&::placeholder {
color: var(--colorLink);
}
}
a {
@ -351,6 +351,11 @@ body {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr);
#edit-box {
background-color: var(--selectedBg);
border-bottom: 1px solid var(--main);
}
#bookmarks-grid {
display: grid;
grid-template-rows: min-content;

File diff suppressed because one or more lines are too long

View file

@ -384,7 +384,8 @@ func (h *handler) apiUpdateArchive(w http.ResponseWriter, r *http.Request, ps ht
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: ids,
IDs: ids,
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(filter)
@ -500,3 +501,73 @@ func (h *handler) apiUpdateArchive(w http.ResponseWriter, r *http.Request, ps ht
err = json.NewEncoder(w).Encode(&bookmarks)
checkError(err)
}
// apiUpdateBookmarkTags is handler for PUT /api/bookmarks/tags
func (h *handler) apiUpdateBookmarkTags(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
// Make sure session still valid
err := h.validateSession(r)
checkError(err)
// Decode request
request := struct {
IDs []int `json:"ids"`
Tags []model.Tag `json:"tags"`
}{}
err = json.NewDecoder(r.Body).Decode(&request)
checkError(err)
// Validate input
if len(request.IDs) == 0 || len(request.Tags) == 0 {
panic(fmt.Errorf("IDs and tags must not empty"))
}
// Get existing bookmark from database
filter := database.GetBookmarksOptions{
IDs: request.IDs,
WithContent: true,
}
bookmarks, err := h.DB.GetBookmarks(filter)
checkError(err)
if len(bookmarks) == 0 {
panic(fmt.Errorf("no bookmark with matching ids"))
}
// Set new tags
for i, book := range bookmarks {
for _, newTag := range request.Tags {
for _, oldTag := range book.Tags {
if newTag.Name == oldTag.Name {
newTag.ID = oldTag.ID
break
}
}
if newTag.ID == 0 {
book.Tags = append(book.Tags, newTag)
}
}
bookmarks[i] = book
}
// Update database
bookmarks, err = h.DB.SaveBookmarks(bookmarks...)
checkError(err)
// Get image URL for each bookmark
for i := range bookmarks {
strID := strconv.Itoa(bookmarks[i].ID)
imgPath := fp.Join(h.DataDir, "thumb", strID)
imgURL := path.Join("/", "thumb", strID)
if fileExists(imgPath) {
bookmarks[i].ImageURL = imgURL
}
}
// Return new saved result
err = json.NewEncoder(w).Encode(&bookmarks)
checkError(err)
}

View file

@ -43,7 +43,7 @@ func ServeApp(DB database.DB, dataDir string, port int) error {
router.DELETE("/api/bookmarks", hdl.apiDeleteBookmark)
router.PUT("/api/bookmarks", hdl.apiUpdateBookmark)
router.PUT("/api/archive", hdl.apiUpdateArchive)
// router.PUT("/api/bookmarks/tags", hdl.apiUpdateBookmarkTags)
router.PUT("/api/bookmarks/tags", hdl.apiUpdateBookmarkTags)
// Route for panic
router.PanicHandler = func(w http.ResponseWriter, r *http.Request, arg interface{}) {