Added basic subalbum support.

That is, albums can now contain other albums, which are shown at
the top of the album view. This required some changes to album.js
and the contextMenu.js, because this view contains now both
photos and albums.

The contextMenu on this view has been kept simple by requiring
the user to select either only albums or only photos, but not
a mixture of both.

This feature required a database change, so that the version
has been updated to 3.1.3.

At the moment, album and photo operations (make public, download,
delete, merge) are still "flat", i.e. don't respect the album
hierarchy.
pull/581/head
Nils Asmussen 8 years ago
parent 6d4df5f6b7
commit ef9040870a

@ -72,8 +72,10 @@ final class Admin extends Access {
private static function getAlbumsAction() {
Validator::required(isset($_POST['parent']), __METHOD__);
$albums = new Albums();
Response::json($albums->get(false));
Response::json($albums->get(false, $_POST['parent']));
}
@ -90,10 +92,10 @@ final class Admin extends Access {
private static function addAlbumAction() {
Validator::required(isset($_POST['title']), __METHOD__);
Validator::required(isset($_POST['title'], $_POST['parent']), __METHOD__);
$album = new Album(null);
Response::json($album->add($_POST['title']), JSON_NUMERIC_CHECK);
Response::json($album->add($_POST['title'], $_POST['parent']), JSON_NUMERIC_CHECK);
}

@ -44,8 +44,10 @@ final class Guest extends Access {
private static function getAlbumsAction() {
Validator::required(isset($_POST['parent']), __METHOD__);
$albums = new Albums();
Response::json($albums->get(true));
Response::json($albums->get(true, $_POST['parent']));
}

@ -23,7 +23,7 @@ final class Album {
/**
* @return string|false ID of the created album.
*/
public function add($title = 'Untitled') {
public function add($title = 'Untitled', $parent = 0) {
// Call plugins
Plugins::get()->activate(__METHOD__, 0, func_get_args());
@ -35,7 +35,7 @@ final class Album {
$visible = 1;
// Database
$query = Database::prepare(Database::get(), "INSERT INTO ? (id, title, sysstamp, public, visible) VALUES ('?', '?', '?', '?', '?')", array(LYCHEE_TABLE_ALBUMS, $id, $title, $sysstamp, $public, $visible));
$query = Database::prepare(Database::get(), "INSERT INTO ? (id, title, sysstamp, public, visible, parent) VALUES ('?', '?', '?', '?', '?', '?')", array(LYCHEE_TABLE_ALBUMS, $id, $title, $sysstamp, $public, $visible, $parent));
$result = Database::execute(Database::get(), $query, __METHOD__, __LINE__);
// Call plugins
@ -79,6 +79,8 @@ final class Album {
// Parse thumbs or set default value
$album['thumbs'] = (isset($data['thumbs']) ? explode(',', $data['thumbs']) : array());
$album['parent'] = $data['parent'];
return $album;
}

@ -16,7 +16,7 @@ final class Albums {
/**
* @return array|false Returns an array of albums or false on failure.
*/
public function get($public = true) {
public function get($public = true, $parent = 0) {
// Call plugins
Plugins::get()->activate(__METHOD__, 0, func_get_args());
@ -32,8 +32,8 @@ final class Albums {
if ($public===false) $return['smartalbums'] = $this->getSmartAlbums();
// Albums query
if ($public===false) $query = Database::prepare(Database::get(), 'SELECT id, title, public, sysstamp, password FROM ? ' . Settings::get()['sortingAlbums'], array(LYCHEE_TABLE_ALBUMS));
else $query = Database::prepare(Database::get(), 'SELECT id, title, public, sysstamp, password FROM ? WHERE public = 1 AND visible <> 0 ' . Settings::get()['sortingAlbums'], array(LYCHEE_TABLE_ALBUMS));
if ($public===false) $query = Database::prepare(Database::get(), "SELECT id, title, public, sysstamp, password, parent FROM ? " . ($parent != -1 ? "WHERE parent = '?' " : "") . Settings::get()['sortingAlbums'], array(LYCHEE_TABLE_ALBUMS, $parent));
else $query = Database::prepare(Database::get(), "SELECT id, title, public, sysstamp, password, parent FROM ? " . ($parent != -1 ? "WHERE parent = '?' " : "") . " AND public = 1 AND visible <> 0 " . Settings::get()['sortingAlbums'], array(LYCHEE_TABLE_ALBUMS, $parent));
// Execute query
$albums = Database::execute(Database::get(), $query, __METHOD__, __LINE__);

@ -15,7 +15,8 @@ final class Database {
'030001', // 3.0.1
'030003', // 3.0.3
'030100', // 3.1.0
'030102' // 3.1.2
'030102', // 3.1.2
'030103' // 3.1.3
);
/**

@ -0,0 +1,24 @@
<?php
/**
* Update to version 3.1.3
*/
use Lychee\Modules\Database;
use Lychee\Modules\Response;
// Add parent field to albums
$query = Database::prepare($connection, "SELECT `parent` FROM `?` LIMIT 1", array(LYCHEE_TABLE_ALBUMS));
if (!Database::execute($connection, $query, "update_030103", __LINE__)) {
$query = Database::prepare($connection, "ALTER TABLE `?` ADD `parent` BIGINT(14) NOT NULL DEFAULT 0", array(LYCHEE_TABLE_ALBUMS));
$result = Database::execute($connection, $query, "update_030103", __LINE__);
if (!$result) {
Log::error($database, 'update_030103', __LINE__, 'Could not update database (' . $database->error . ')');
return false;
}
}
// Set version
if (Database::setVersion($connection, '030103')===false) Response::error('Could not update version of database!');
?>

@ -1,6 +1,6 @@
{
"name": "Lychee",
"version": "3.1.2",
"version": "3.1.3",
"description": "Self-hosted photo-management done right.",
"authors": "Tobias Reich <tobias@electerious.com>",
"license": "MIT",

@ -5,7 +5,8 @@
album = {
json: null
json: null,
subjson: null
}
@ -24,18 +25,32 @@ album.getID = function() {
return $.isNumeric(id)
}
if (photo.json) id = photo.json.album
else if (album.json) id = album.json.id
// Search
if (isID(id)===false) id = $('.album:hover, .album.active').attr('data-id')
if (isID(id)===false) id = $('.photo:hover, .photo.active').attr('data-album-id')
if (isID(id)===false) {
if (photo.json) id = photo.json.album
else if (album.json) id = album.json.id
}
if (isID(id)===true) return id
else return false
}
album.getParent = function () {
let id = album.json.id;
if (album.isSmartID(id) || album.json.parent==0) {
return ''
} else {
return album.json.parent
}
}
album.load = function(albumID, refresh = false) {
password.get(albumID, function() {
@ -96,6 +111,28 @@ album.load = function(albumID, refresh = false) {
})
if (!album.isSmartID(albumID)) {
params = {
parent: albumID
}
api.post('Albums::get', params, function(data) {
let waitTime = 0
album.subjson = data
// Calculate delay
let durationTime = (new Date().getTime() - startTime)
if (durationTime>300) waitTime = 0
else waitTime = 300 - durationTime
setTimeout(() => {
view.album.init()
}, waitTime)
})
}
})
}
@ -106,18 +143,35 @@ album.parse = function() {
}
album.add = function() {
function buildAlbumOptions(albums, select, parent = 0, layer = 0) {
var cmbxOptions = ''
for (i in albums) {
if (albums[i].parent == parent) {
let title = (layer > 0 ? "&nbsp;&nbsp;".repeat(layer - 1) + "└ " : "") + albums[i].title
cmbxOptions += `<option `;
if (select == albums[i].id)
cmbxOptions += `selected="selected" `
cmbxOptions += `value='` + albums[i].id + `'>` + title + `</option>`
cmbxOptions += buildAlbumOptions(albums, select, albums[i].id, layer + 1)
}
}
return cmbxOptions
}
album.add = function(albumID = 0) {
const action = function(data) {
let title = data.title
let parent = data.parent
const isNumber = (n) => (!isNaN(parseFloat(n)) && isFinite(n))
basicModal.close()
let params = {
title
title,
parent
}
api.post('Album::add', params, function(data) {
@ -133,18 +187,28 @@ album.add = function() {
}
basicModal.show({
body: `<p>Enter a title for the new album: <input class='text' name='title' type='text' maxlength='50' placeholder='Title' value='Untitled'></p>`,
buttons: {
action: {
title: 'Create Album',
fn: action
},
cancel: {
title: 'Cancel',
fn: basicModal.close
api.post('Albums::get', {
parent: -1
}, function (data) {
var cmbxOptions = `<select name='parent'>`
cmbxOptions += `<option value='0'>- None -</option>`
cmbxOptions += buildAlbumOptions(data.albums, albumID)
cmbxOptions += '</select>'
basicModal.show({
body: `<p>Enter a title for the new album: <input class='text' name='title' type='text' maxlength='50' placeholder='Title' value='Untitled'></p>`
+ `<p>Select the parent album:<br/>` + cmbxOptions + `</p>`,
buttons: {
action: {
title: 'Create Album',
fn: action
},
cancel: {
title: 'Cancel',
fn: basicModal.close
}
}
}
})
})
}
@ -204,8 +268,8 @@ album.delete = function(albumIDs) {
cancel.title = 'Keep Album'
// Get title
if (album.json) albumTitle = album.json.title
else if (albums.json) albumTitle = albums.getByID(albumIDs).title
if (album.json && album.json.id == albumIDs[0]) albumTitle = album.json.title
else if (albums.json || album.subjson) albumTitle = albums.getByID(albumIDs).title
// Fallback for album without a title
if (albumTitle==='') albumTitle = 'Untitled'
@ -249,8 +313,8 @@ album.setTitle = function(albumIDs) {
if (albumIDs.length===1) {
// Get old title if only one album is selected
if (album.json) oldTitle = album.json.title
else if (albums.json) oldTitle = albums.getByID(albumIDs).title
if (album.json && album.json.id == albumIDs[0]) oldTitle = album.json.title
else if (albums.json || album.subjson) oldTitle = albums.getByID(albumIDs).title
}
@ -264,10 +328,17 @@ album.setTitle = function(albumIDs) {
// Rename only one album
album.json.title = newTitle
view.album.title()
if (album.json.id == albumIDs[0]) {
album.json.title = newTitle
view.album.title()
}
if (albums.json) albums.getByID(albumIDs[0]).title = newTitle
if (albums.json || album.subjson) {
albumIDs.forEach(function(id) {
albums.getByID(id).title = newTitle
view.album.content.title(id)
})
}
} else if (visible.albums()) {
@ -545,7 +616,7 @@ album.getArchive = function(albumID) {
}
album.merge = function(albumIDs) {
album.merge = function(albumIDs, titles = []) {
let title = ''
let sTitle = ''
@ -555,7 +626,8 @@ album.merge = function(albumIDs) {
if (albumIDs instanceof Array===false) albumIDs = [ albumIDs ]
// Get title of first album
if (albums.json) title = albums.getByID(albumIDs[0]).title
if (titles.length > 0) title = titles[0]
else if (albums.json || album.subjson) title = albums.getByID(albumIDs[0]).title
// Fallback for first album without a title
if (title==='') title = 'Untitled'
@ -563,7 +635,8 @@ album.merge = function(albumIDs) {
if (albumIDs.length===2) {
// Get title of second album
if (albums.json) sTitle = albums.getByID(albumIDs[1]).title
if (titles.length > 1) sTitle = titles[1]
else if (albums.json || album.subjson) sTitle = albums.getByID(albumIDs[1]).title
// Fallback for second album without a title
if (sTitle==='') sTitle = 'Untitled'

@ -17,7 +17,11 @@ albums.load = function() {
if (albums.json===null) {
api.post('Albums::get', {}, function(data) {
params = {
parent: 0
}
api.post('Albums::get', params, function(data) {
let waitTime = 0
@ -109,18 +113,21 @@ albums.getByID = function(albumID) {
// Function returns the JSON of an album
if (albumID==null) return undefined
if (!albums.json) return undefined
if (!albums.json.albums) return undefined
if (albumID instanceof Array)
albumID = albumID[0]
let json = undefined
$.each(albums.json.albums, function(i) {
let elem = albums.json.albums[i]
if (elem.id==albumID) json = elem
let func = function() {
if (this.id==albumID) json = this
}
})
if (albums.json && albums.json.albums) {
$.each(albums.json.albums, func)
}
else if (album.subjson && album.subjson.albums) {
$.each(album.subjson.albums, func)
}
return json

@ -3,9 +3,47 @@
* @copyright 2015 by Tobias Reich
*/
function buildAlbumList(albums, albumID, action, parent = 0, layer = 0) {
let items = []
for (i in albums) {
if ((layer == 0 && !albums[i].parent) || albums[i].parent == parent) {
let album = albums[i]
let thumb = 'src/images/no_cover.svg'
if (album.thumbs && album.thumbs[0])
thumb = album.thumbs[0]
else if(album.thumbUrl)
thumb = album.thumbUrl
if (album.title==='') album.title = 'Untitled'
let prefix = layer > 0 ? "&nbsp;&nbsp;".repeat(layer - 1) + "└ " : ""
let html = prefix + lychee.html`<img class='cover' width='16' height='16' src='$${ thumb }'><div class='title'>$${ album.title }</div>`
if (album.id!=albumID) {
items.push({
title: html,
fn: () => action(album)
})
}
else {
html = "<div class='disabled'>" + html + "</div>"
items.push({
title: html,
fn: () => {}
})
}
items = items.concat(buildAlbumList(albums, albumID, action, album.id, layer + 1))
}
}
return items
}
contextMenu = {}
contextMenu.add = function(e) {
contextMenu.add = function(albumID, e) {
let items = [
{ title: build.iconic('image') + 'Upload Photo', fn: () => $('#upload_files').click() },
@ -14,7 +52,7 @@ contextMenu.add = function(e) {
{ title: build.iconic('dropbox', 'ionicons') + 'Import from Dropbox', fn: upload.start.dropbox },
{ title: build.iconic('terminal') + 'Import from Server', fn: upload.start.server },
{ },
{ title: build.iconic('folder') + 'New Album', fn: album.add }
{ title: build.iconic('folder') + 'New Album', fn: () => album.add(albumID) }
]
basicContext.show(items, e.originalEvent)
@ -90,26 +128,13 @@ contextMenu.albumMulti = function(albumIDs, e) {
contextMenu.albumTitle = function(albumID, e) {
api.post('Albums::get', {}, function(data) {
api.post('Albums::get', { parent: -1 }, function(data) {
let items = []
if (data.albums && data.num>1) {
// Generate list of albums
$.each(data.albums, function() {
if (!this.thumbs[0]) this.thumbs[0] = 'src/images/no_cover.svg'
if (this.title==='') this.title = 'Untitled'
let html = lychee.html`<img class='cover' width='16' height='16' src='$${ this.thumbs[0] }'><div class='title'>$${ this.title }</div>`
if (this.id!=albumID) items.push({
title: html,
fn: () => lychee.goto(this.id)
})
})
items = buildAlbumList(data.albums, albumID, (a) => lychee.goto(a.id))
items.unshift({ })
@ -125,25 +150,15 @@ contextMenu.albumTitle = function(albumID, e) {
contextMenu.mergeAlbum = function(albumID, e) {
api.post('Albums::get', {}, function(data) {
api.post('Albums::get', { parent: -1 }, function(data) {
let items = []
if (data.albums && data.num>1) {
$.each(data.albums, function() {
let title = albums.getByID(albumID).title
if (!this.thumbs[0]) this.thumbs[0] = 'src/images/no_cover.svg'
if (this.title==='') this.title = 'Untitled'
let html = lychee.html`<img class='cover' width='16' height='16' src='$${ this.thumbs[0] }'><div class='title'>$${ this.title }</div>`
if (this.id!=albumID) items.push({
title: html,
fn: () => album.merge([ albumID, this.id ])
})
})
items = buildAlbumList(data.albums, albumID, (a) => album.merge([ albumID, a.id ], [title, a.title]))
}
@ -177,14 +192,41 @@ contextMenu.photo = function(photoID, e) {
}
function countSubAlbums(photoIDs) {
let count = 0
for (i in photoIDs) {
for (j in album.subjson.albums) {
if (album.subjson.albums[j].id == photoIDs[i]) {
count++
break
}
}
}
return count
}
contextMenu.photoMulti = function(photoIDs, e) {
let subcount = countSubAlbums(photoIDs)
let photocount = photoIDs.length - subcount
if (subcount && photocount) {
$('.photo.active, .album.active').removeClass('active')
multiselect.close()
lychee.error("Please select either albums or photos!")
return
}
if (subcount) {
contextMenu.albumMulti(photoIDs, e)
return
}
multiselect.stopResize()
// Notice for 'Move All':
// fn must call basicContext.close() first,
// in order to keep the selection and multiselect
multiselect.stopResize()
let items = [
{ title: build.iconic('star') + 'Star All', fn: () => photo.setStar(photoIDs) },
{ title: build.iconic('tag') + 'Tag All', fn: () => photo.editTags(photoIDs) },
@ -211,19 +253,7 @@ contextMenu.photoTitle = function(albumID, photoID, e) {
items.push({ })
// Generate list of albums
$.each(data.content, function(index) {
if (this.title==='') this.title = 'Untitled'
let html = lychee.html`<img class='cover' width='16' height='16' src='$${ this.thumbUrl }'><div class='title'>$${ this.title }</div>`
if (this.id!=photoID) items.push({
title: html,
fn: () => lychee.goto(albumID + '/' + this.id)
})
})
items = items.concat(buildAlbumList(data.content, photoID, (a) => lychee.goto(albumID + '/' + a.id)))
}
@ -251,7 +281,7 @@ contextMenu.move = function(photoIDs, e) {
let items = []
api.post('Albums::get', {}, function(data) {
api.post('Albums::get', { parent: -1 }, function(data) {
if (data.num===0) {
@ -262,20 +292,7 @@ contextMenu.move = function(photoIDs, e) {
} else {
// Generate list of albums
$.each(data.albums, function() {
if (!this.thumbs[0]) this.thumbs[0] = 'src/images/no_cover.svg'
if (this.title==='') this.title = 'Untitled'
let html = lychee.html`<img class='cover' width='16' height='16' src='$${ this.thumbs[0] }'><div class='title'>$${ this.title }</div>`
if (this.id!=album.getID()) items.push({
title: html,
fn: () => photo.setAlbum(photoIDs, this.id)
})
})
items = buildAlbumList(data.albums, album.getID(), (a) => photo.setAlbum(photoIDs, a.id))
// Show Unsorted when unsorted is not the current album
if (album.getID()!=='0') {

@ -44,7 +44,7 @@ header.bind = function() {
header.dom('#button_settings') .on(eventName, contextMenu.settings)
header.dom('#button_info_album') .on(eventName, sidebar.toggle)
header.dom('#button_info') .on(eventName, sidebar.toggle)
header.dom('.button_add') .on(eventName, contextMenu.add)
header.dom('.button_add') .on(eventName, function(e) { contextMenu.add(album.getID(), e) })
header.dom('#button_more') .on(eventName, function(e) { contextMenu.photoMore(photo.getID(), e) })
header.dom('#button_move') .on(eventName, function(e) { contextMenu.move([ photo.getID() ], e) })
header.dom('.header__hostedwith') .on(eventName, function() { window.open(lychee.website) })
@ -52,7 +52,7 @@ header.bind = function() {
header.dom('#button_trash') .on(eventName, function() { photo.delete([ photo.getID() ]) })
header.dom('#button_archive') .on(eventName, function() { album.getArchive(album.getID()) })
header.dom('#button_star') .on(eventName, function() { photo.setStar([ photo.getID() ]) })
header.dom('#button_back_home') .on(eventName, function() { lychee.goto() })
header.dom('#button_back_home') .on(eventName, function() { lychee.goto(album.getParent()) })
header.dom('#button_back') .on(eventName, function() { lychee.goto(album.getID()) })
header.dom('.header__search').on('keyup click', function() { search.find($(this).val()) })

@ -6,8 +6,8 @@
lychee = {
title : document.title,
version : '3.1.2',
versionCode : '030102',
version : '3.1.3',
versionCode : '030103',
updatePath : '//update.electerious.com/index.json',
updateURL : 'https://github.com/electerious/Lychee',
@ -170,6 +170,7 @@ lychee.load = function() {
// Trash data
photo.json = null
albums.json = null
// Show Photo
if (lychee.content.html()==='' || (header.dom('.header__search').length && header.dom('.header__search').val().length!==0)) {
@ -182,6 +183,7 @@ lychee.load = function() {
// Trash data
photo.json = null
albums.json = null
// Show Album
if (visible.photo()) view.photo.hide()
@ -197,6 +199,7 @@ lychee.load = function() {
}
// Trash data
album.subjson = null
album.json = null
photo.json = null

@ -144,8 +144,22 @@ view.album = {
let photosData = ''
// Sub albums
if (album.subjson && album.subjson.albums && album.subjson.num!==0) {
photosData = build.divider('Albums')
$.each(album.subjson.albums, function() {
albums.parse(this)
photosData += build.album(this)
})
}
if (album.json.content && album.json.content!==false) {
photosData += build.divider('Photos')
// Build photos
$.each(album.json.content, function() {
photosData += build.photo(this)
@ -164,13 +178,25 @@ view.album = {
title: function(photoID) {
let title = album.json.content[photoID].title
if (album.json.content[photoID]) {
ntitle = album.json.content[photoID].title
prefix = '.photo'
}
else {
for (i in album.subjson.albums) {
if (album.subjson.albums[i].id == photoID) {
ntitle = album.subjson.albums[i].title
break
}
}
prefix = '.album'
}
title = lychee.escapeHTML(title)
ntitle = lychee.escapeHTML(ntitle)
$('.photo[data-id="' + photoID + '"] .overlay h1')
.html(title)
.attr('title', title)
$(prefix + '[data-id="' + photoID + '"] .overlay h1')
.html(ntitle)
.attr('title', ntitle)
},

@ -8,6 +8,10 @@
box-shadow: 0 0 0 1px black(.5);
}
&__data .disabled {
opacity: 0.5;
}
&__data .title {
display: inline-block;
margin: 0 0 3px 26px;

Loading…
Cancel
Save