shiori/view/index.html
2018-06-07 15:37:07 +07:00

746 lines
No EOL
34 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="stylesheet" href="/css/fontawesome.css">
<link rel="stylesheet" href="/css/source-sans-pro.css">
<link rel="stylesheet" href="/css/yla-dialog.css">
<link rel="stylesheet" href="/css/yla-tooltip.css">
<link rel="stylesheet" href="/css/stylesheet.css">
<script src="/js/vue.js"></script>
<script src="/js/axios.js"></script>
<script src="/js/js-cookie.js"></script>
<script src="/js/component/yla-tooltip.js"></script>
<script src="/js/component/yla-dialog.js"></script>
<script src="/js/page/base.js"></script>
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="/res/apple-touch-icon-144x144.png" />
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="/res/apple-touch-icon-152x152.png" />
<link rel="icon" type="image/png" href="/res/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/res/favicon-16x16.png" sizes="16x16" />
<title>Shiori - Bookmarks Manager</title>
</head>
<body>
<div id="index-page" class="page" :class="{night: options.nightMode}">
<div id="sidebar">
<p id="logo"></p>
<yla-tooltip placement="right" content="Reload data">
<a @click="reloadData" v-show="!editMode">
<i class="fas fa-sync-alt fa-fw" :class="loading && 'fa-spin'"></i>
</a>
</yla-tooltip>
<yla-tooltip placement="right" content="Add new bookmark">
<a :href="bookmarklet" @click="showDialogAdd" v-show="!editMode && !loading">
<span>+Shiori</span>
<i class="fas fa-plus fa-fw"></i>
</a>
</yla-tooltip>
<yla-tooltip placement="right" :content="editMode ? 'Cancel batch edit' : 'Batch edit'">
<a @click="toggleEditMode" v-show="!loading">
<i class="fas fa-fw" :class="editMode ? 'fa-times' : 'fa-pencil-alt'"></i>
</a>
</yla-tooltip>
<yla-tooltip placement="right" content="Show tags">
<a @click="showDialogTags" v-show="!editMode && !loading">
<i class="fas fa-fw fa-tags"></i>
</a>
</yla-tooltip>
<div class="spacer"></div>
<yla-tooltip placement="right" content="Options">
<a @click="showDialogOptions">
<i class="fas fa-fw fa-cog"></i>
</a>
</yla-tooltip>
<yla-tooltip placement="right" content="Log out">
<a @click="showDialogLogout">
<i class="fas fa-sign-out-alt fa-fw"></i>
</a>
</yla-tooltip>
</div>
<div id="body">
<div id="header" class="header" v-if="!editMode">
<input type="text" v-model.trim="search" placeholder="Search bookmarks by url, tags, title or content" @focus="$event.target.select()" @keyup.enter="loadData">
<a title="Search" @click="loadData">
<i class="fas fa-search fa-fw"></i>
</a>
</div>
<div id="batch-edit" class="header" v-if="editMode">
<p>{{selected.length}} Items selected</p>
<a :class="{disabled: selected.length === 0}" @click="showDialogDelete(selected)">
<i class="fas fa-fw fa-trash-alt"></i>
<span>Delete</span>
</a>
<a :class="{disabled: selected.length === 0}" @click="showDialogAddTags(selected)">
<i class="fas fa-fw fa-tags"></i>
<span>Add tags</span>
</a>
<a :class="{disabled: selected.length === 0}" @click="showDialogUpdateCache(selected)">
<i class="fas fa-fw fa-cloud-download-alt"></i>
<span>Update cache</span>
</a>
<a id="cancel-edit" @click="toggleEditMode">
<i class="fas fa-fw fa-times"></i>
</a>
</div>
<div id="grid" :class="{list: options.listView}">
<div class="pagination-box" v-if="maxPage > 0">
<p>Page</p>
<input type="text" placeholder="1" :value="page+1" @focus="$event.target.select()" @keyup.enter="changePage($event.target.value-1)" :disabled="editMode">
<p>{{maxPage+1}}</p>
<div class="spacer"></div>
<template v-if="!editMode">
<a v-if="page > 1" title="Go to first page" @click="changePage(0)">
<i class="fas fa-fw fa-angle-double-left"></i>
</a>
<a v-if="page > 0" title="Go to previous page" @click="changePage(page-1)">
<i class="fa fa-fw fa-angle-left"></i>
</a>
<a v-if="page < maxPage" title="Go to next page" @click="changePage(page+1)">
<i class="fa fa-fw fa-angle-right"></i>
</a>
<a v-if="page < maxPage - 1" title="Go to last page" @click="changePage(maxPage)">
<i class="fas fa-fw fa-angle-double-right"></i>
</a>
</template>
</div>
<div class="bookmark" v-for="(book, idx) in visibleBookmarks" :class="{selected: isSelected(idx)}">
<a class="bookmark-selector" v-if="editMode" @click="toggleSelection(idx)"></a>
<a class="bookmark-link" :href="book.hasContent ? '/bookmark/'+book.id : null" :title="book.hasContent ? 'View cache' : null" target="_blank">
<img v-if="book.imageURL !== ''" :src="book.imageURL">
<p class="title">{{book.title}}</p>
<p class="excerpt" v-if="book.imageURL === ''">{{book.excerpt}}</p>
<p v-show="options.showBookmarkID" class="id">{{book.id}}</p>
</a>
<div class="bookmark-tags" v-if="book.tags.length > 0">
<a v-for="tag in book.tags" @click="filterTag(tag.name)">{{tag.name}}</a>
</div>
<div class="spacer"></div>
<div class="bookmark-menu">
<a class="url" title="View original" :href="book.url" target="_blank">
{{getHostname(book.url)}}
</a>
<a title="Edit bookmark" @click="showDialogEdit(idx)">
<i class="fas fa-pencil-alt"></i>
</a>
<a title="Delete bookmark" @click="showDialogDelete([idx])">
<i class="fas fa-trash-alt"></i>
</a>
<a title="Update cache" @click="showDialogUpdateCache([idx])">
<i class="fas fa-cloud-download-alt"></i>
</a>
</div>
</div>
<div class="pagination-box" v-if="maxPage > 0">
<p>Page</p>
<input type="text" placeholder="1" :value="page+1" @focus="$event.target.select()" @keyup.enter="changePage($event.target.value-1)" :disabled="editMode">
<p>{{maxPage+1}}</p>
<div class="spacer"></div>
<template v-if="!editMode">
<a v-if="page > 1" title="Go to first page" @click="changePage(0)">
<i class="fas fa-fw fa-angle-double-left"></i>
</a>
<a v-if="page > 0" title="Go to previous page" @click="changePage(page-1)">
<i class="fa fa-fw fa-angle-left"></i>
</a>
<a v-if="page < maxPage" title="Go to next page" @click="changePage(page+1)">
<i class="fa fa-fw fa-angle-right"></i>
</a>
<a v-if="page < maxPage - 1" title="Go to last page" @click="changePage(maxPage)">
<i class="fas fa-fw fa-angle-double-right"></i>
</a>
</template>
</div>
<div id="grid-padding"></div>
</div>
</div>
<yla-dialog id="dialog-tags" v-bind="dialogTags">
<a v-for="tag in tags" @click="filterTag(tag.name)">
<span>{{tag.name}}</span>
<span>{{tag.nBookmarks}}</span>
</a>
</yla-dialog>
<yla-dialog id="dialog-options" v-bind="dialogOptions">
<a @click="toggleListView">
<i class="fa-fw" :class="options.listView ? 'fas fa-check-square' : 'far fa-square'"></i>Use list view
</a>
<a @click="toggleNightMode">
<i class="fa-fw" :class="options.nightMode ? 'fas fa-check-square' : 'far fa-square'"></i>Use night mode
</a>
<a @click="toggleBookmarkID">
<i class="fa-fw" :class="options.showBookmarkID ? 'fas fa-check-square' : 'far fa-square'"></i>Show bookmark's ID
</a>
</yla-dialog>
<yla-dialog v-bind="dialog"></yla-dialog>
</div>
<script>
// Define global variable
var pageSize = 30;
// Prepare axios instance
var token = Cookies.get('token'),
rest = axios.create();
rest.defaults.timeout = 60000;
rest.defaults.headers.common['Authorization'] = 'Bearer ' + token;
// Register Vue component
Vue.component('yla-dialog', new YlaDialog());
Vue.component('yla-tooltip', new YlaTooltip());
new Vue({
el: '#index-page',
mixins: [new Base()],
data() {
return {
loading: false,
tags: [],
bookmarks: [],
search: '',
page: 0,
maxPage: 0,
editMode: false,
selected: [],
bookmarklet: '',
options: {
listView: false,
nightMode: false,
showBookmarkID: false,
},
dialogTags: {
visible: false,
loading: false,
title: 'Existing Tags',
mainText: 'Cancel',
mainClick: () => {
this.dialogTags.visible = false;
},
},
dialogOptions: {
visible: false,
title: 'Options',
mainText: 'OK',
mainClick: () => {
this.dialogOptions.visible = false;
},
}
}
},
computed: {
visibleBookmarks() {
var start = this.page * pageSize,
finish = start + pageSize;
return this.bookmarks.slice(start, finish);
}
},
methods: {
loadData() {
if (this.loading) return;
// Parse search query
var rxTagA = /['"]#([^'"]+)['"]/g,
rxTagB = /(^|\s+)#(\S+)/g,
keyword = this.search,
tags = [];
// Fetch tag A first
while ((result = rxTagA.exec(keyword)) !== null) {
tags.push(result[1]);
}
// Clear tag A from keyword
keyword = keyword.replace(rxTagA, '');
// Fetch tag B
while ((result = rxTagB.exec(keyword)) !== null) {
tags.push(result[2]);
}
// Clear tag B from keyword and clean it
keyword = keyword.replace(rxTagB, '').trim().replace(/\s+/g, ' ');
// Fetch data
this.loading = true;
rest.get('/api/bookmarks', {
params: {
keyword: keyword,
tags: tags.join(',')
}
})
.then((response) => {
this.page = 0;
this.bookmarks = response.data;
this.maxPage = Math.ceil(this.bookmarks.length / pageSize) - 1;
window.scrollTo(0, 0);
return rest.get('/api/tags');
})
.then((response) => {
this.tags = response.data;
this.loading = false;
})
.catch((error) => {
this.loading = false;
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
},
reloadData() {
if (this.loading) return;
this.search = '';
this.loadData();
},
changePage(target) {
target = parseInt(target, 10) || 0;
if (target >= this.maxPage) this.page = this.maxPage;
else if (target <= 0) this.page = 0;
else this.page = target;
window.scrollTo(0, 0);
},
toggleListView() {
this.options.listView = !this.options.listView;
window.scrollTo(0, 0);
localStorage.setItem('shiori-list-view', this.options.listView ? '1' : '0');
},
toggleNightMode() {
this.options.nightMode = !this.options.nightMode;
localStorage.setItem('shiori-night-mode', this.options.nightMode ? '1' : '0');
},
toggleBookmarkID() {
this.options.showBookmarkID = !this.options.showBookmarkID;
localStorage.setItem('shiori-show-id', this.options.showBookmarkID ? '1' : '0');
},
toggleEditMode() {
this.editMode = !this.editMode;
this.selected = [];
},
toggleSelection(idx) {
var pos = this.selected.indexOf(idx);
if (pos === -1) this.selected.push(idx);
else this.selected.splice(pos, 1);
},
isSelected(idx) {
return this.selected.indexOf(idx) > -1;
},
filterTag(tag) {
// Prepare variable
var rxSpace = /\s+/g,
searchTag = rxSpace.test(tag) ? '"#' + tag + '"' : '#' + tag;
// Check if tag already exist in search
rxTag = new RegExp(searchTag, 'g');
if (rxTag.test(this.search)) return;
// Create new search query
var newSearch = this.search
.replace(rxTag, '')
.replace(rxSpace, ' ')
.trim();
// Load data
this.search = (newSearch + ' ' + searchTag).trim();
this.dialogTags.visible = false;
this.loadData();
},
showDialogAdd(e) {
e.preventDefault();
this.showDialog({
title: 'New Bookmark',
content: 'Create a new bookmark',
fields: [{
name: 'url',
label: 'Url, start with http://...',
}, {
name: 'title',
label: 'Custom title (optional)'
}, {
name: 'excerpt',
label: 'Custom excerpt (optional)',
type: 'area'
}, {
name: 'tags',
label: 'Comma separated tags (optional)',
separator: ',',
dictionary: this.tags.map(tag => tag.name)
}, ],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Prepare tags
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.split(/\s*,\s*/g)
.filter(tag => tag !== '')
.map(tag => {
return {
name: tag
};
});
// Send data
this.dialog.loading = true;
rest.post('/api/bookmarks', {
url: data.url.trim(),
title: data.title.trim(),
excerpt: data.excerpt.trim(),
tags: tags
})
.then((response) => {
this.dialog.loading = false;
this.dialog.visible = false;
this.bookmarks.splice(0, 0, response.data);
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
}
});
},
showDialogEdit(idx) {
idx += this.page * pageSize;
var book = JSON.parse(JSON.stringify(this.bookmarks[idx])),
strTags = book.tags.map(tag => tag.name).join(', ');
this.showDialog({
title: 'Edit Bookmark',
content: 'Edit the bookmark\'s data',
showLabel: true,
fields: [{
name: 'title',
label: 'Title',
value: book.title,
}, {
name: 'excerpt',
label: 'Excerpt',
type: 'area',
value: book.excerpt,
}, {
name: 'tags',
label: 'Tags',
value: strTags,
}],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Validate input
if (data.title.trim() === '') return;
// Prepare tags
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.split(/\s*,\s*/g)
.filter(tag => tag !== '')
.map(tag => {
return {
name: tag
};
});
// Set new data
book.title = data.title.trim();
book.excerpt = data.excerpt.trim();
book.tags = tags;
// Send data
this.dialog.loading = true;
rest.put('/api/bookmarks', book)
.then((response) => {
this.dialog.loading = false;
this.dialog.visible = false;
this.bookmarks.splice(idx, 1, response.data);
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
}
});
},
showDialogDelete(indices) {
// Check and prepare indices
if (!(indices instanceof Array)) return;
if (indices.length === 0) return;
indices.sort();
// Set real indices value
indices = indices.map(item => item + this.page * pageSize)
// Create title and content
var title = "Delete Bookmarks",
content = "Delete the selected bookmarks ? This action is irreversible.";
if (indices.length === 1) {
title = "Delete Bookmark";
content = "Are you sure ? This action is irreversible.";
}
// Get list of bookmark ID
var listID = [];
for (var i = 0; i < indices.length; i++) {
listID.push(this.bookmarks[indices[i]].id);
}
// Show dialog
this.showDialog({
title: title,
content: content,
mainText: 'Yes',
secondText: 'No',
mainClick: () => {
this.dialog.loading = true;
rest.delete('/api/bookmarks/', {
data: listID
})
.then((response) => {
this.selected = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
for (var i = indices.length - 1; i >= 0; i--) {
this.bookmarks.splice(indices[i], 1);
}
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
}
});
},
showDialogUpdateCache(indices) {
// Check and prepare indices
if (!(indices instanceof Array)) return;
if (indices.length === 0) return;
indices.sort();
// Set real indices value
indices = indices.map(item => item + this.page * pageSize)
// Get list of bookmark ID
var listID = [];
for (var i = 0; i < indices.length; i++) {
listID.push(this.bookmarks[indices[i]].id);
}
// Show dialog
this.showDialog({
title: 'Update Cache',
content: 'Update cache for selected bookmarks ? This action is irreversible.',
mainText: 'Yes',
secondText: 'No',
mainClick: () => {
this.dialog.loading = true;
rest.put('/api/cache/', listID)
.then((response) => {
this.selected = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
response.data.forEach(book => {
for (var i = 0; i < indices.length; i++) {
var idx = indices[i];
if (book.id === this.bookmarks[idx].id) {
this.bookmarks.splice(idx, 1, book);
break;
}
}
});
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
}
});
},
showDialogAddTags(indices) {
// Check and prepare indices
if (!(indices instanceof Array)) return;
if (indices.length === 0) return;
indices.sort();
// Set real indices value
indices = indices.map(item => item + this.page * pageSize)
// Get list of bookmark ID
var listID = [];
for (var i = 0; i < indices.length; i++) {
listID.push(this.bookmarks[indices[i]].id);
}
this.showDialog({
title: 'Add New Tags',
content: 'Add new tags to selected bookmarks',
fields: [{
name: 'tags',
label: 'Comma separated tags',
value: '',
}],
mainText: 'OK',
secondText: 'Cancel',
mainClick: (data) => {
// Validate input
var tags = data.tags
.toLowerCase()
.replace(/\s+/g, ' ')
.split(/\s*,\s*/g)
.filter(tag => tag !== '')
.map(tag => {
return {
name: tag
};
});
if (tags.length === 0) return;
// Send data
this.dialog.loading = true;
rest.put('/api/bookmarks/tags', {
ids: listID,
tags: tags,
})
.then((response) => {
this.selected = [];
this.editMode = false;
this.dialog.loading = false;
this.dialog.visible = false;
response.data.forEach(book => {
for (var i = 0; i < indices.length; i++) {
var idx = indices[i];
if (book.id === this.bookmarks[idx].id) {
this.bookmarks.splice(idx, 1, book);
break;
}
}
});
})
.catch((error) => {
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
}
});
},
showDialogTags() {
this.dialogTags.visible = true;
this.dialogTags.loading = true;
rest.get('/api/tags', {
timeout: 5000
})
.then((response) => {
this.tags = response.data;
this.dialogTags.loading = false;
})
.catch((error) => {
this.dialogTags.loading = false;
this.dialogTags.visible = false;
var errorMsg = (error.response ? error.response.data : error.message).trim();
if (errorMsg.startsWith('Token error')) this.showDialogSessionExpired(errorMsg);
else this.showErrorDialog(errorMsg);
});
},
showDialogLogout() {
this.showDialog({
title: 'Log Out',
content: 'Do you want to log out from shiori ?',
mainText: 'Yes',
secondText: 'No',
mainClick: () => {
Cookies.remove('token');
location.href = '/login';
}
});
},
showDialogSessionExpired(msg) {
this.showDialog({
title: 'Error',
content: msg + '. Please login again.',
mainText: 'OK',
mainClick: () => {
Cookies.remove('token');
location.href = '/login';
}
});
},
showDialogOptions() {
this.dialogOptions.visible = true;
},
getHostname(url) {
parser = document.createElement('a');
parser.href = url;
return parser.hostname.replace(/^www\./g, '');
}
},
mounted() {
// Read config from local storage
var listView = localStorage.getItem('shiori-list-view'),
nightMode = localStorage.getItem('shiori-night-mode'),
showBookmarkID = localStorage.getItem('shiori-show-id');
this.options.listView = listView === '1';
this.options.nightMode = nightMode === '1';
this.options.showBookmarkID = showBookmarkID === '1';
// Create bookmarklet
var shioriURL = location.href.replace(/\/+$/g, ''),
baseBookmarklet = `(function () {
var shioriURL = '$SHIORI_URL',
bookmarkURL = location.href,
submitURL = shioriURL + '/submit?url=' + encodeURIComponent(bookmarkURL);
if (bookmarkURL.startsWith('https://') && !shioriURL.startsWith('https://')) {
window.open(submitURL, '_blank');
return;
}
var i = document.createElement('iframe');
i.src = submitURL;
i.frameBorder = '0';
i.allowTransparency = true;
i.style.position = 'fixed';
i.style.top = 0;
i.style.left = 0;
i.style.width = '100%';
i.style.height = '100%';
i.style.zIndex = 99999;
document.body.appendChild(i);
window.addEventListener('message', function onMessage(e) {
if (e.origin !== shioriURL) return;
if (e.data !== 'finished') return;
window.removeEventListener('message', onMessage);
document.body.removeChild(i);
});
}())`;
this.bookmarklet = 'javascript:' + baseBookmarklet
.replace('$SHIORI_URL', shioriURL)
.replace(/\s+/gm, ' ');
// Load data
this.loadData();
}
});
</script>
</body>
</html>