diff --git a/controllers/Download.php b/controllers/Download.php index b5de5496..ffd60c2f 100644 --- a/controllers/Download.php +++ b/controllers/Download.php @@ -21,22 +21,30 @@ class Download $tag = (int)$_GET['tag']; $album = Tag::fromId($tag); + if (isset($_GET['by']) && ($user = Member::fromSlug($_GET['by'])) !== false) + $id_user_uploaded = $user->getUserId(); + else + $id_user_uploaded = null; + if (isset($_SESSION['current_export'])) throw new UserFacingException('You can only export one album at the same time. Please wait until the other download finishes, or try again later.'); // So far so good? - $this->exportAlbum($album); + $this->exportAlbum($album, $id_user_uploaded); exit; } - private function exportAlbum(Tag $album) + private function exportAlbum(Tag $album, $id_user_uploaded) { $files = []; $album_ids = array_merge([$album->id_tag], $this->getChildAlbumIds($album->id_tag)); foreach ($album_ids as $album_id) { - $iterator = AssetIterator::getByOptions(['id_tag' => $album_id]); + $iterator = AssetIterator::getByOptions([ + 'id_tag' => $album_id, + 'id_user_uploaded' => $id_user_uploaded, + ]); while ($asset = $iterator->next()) $files[] = join(DIRECTORY_SEPARATOR, [$asset->getSubdir(), $asset->getFilename()]); } @@ -71,6 +79,9 @@ class Download // STDOUT should not block. stream_set_blocking($pipes[1], 0); + // Allow this the download to take its time... + set_time_limit(0); + header('Pragma: no-cache'); header('Content-Description: File Download'); header('Content-disposition: attachment; filename="' . $album->tag . '.tar"'); diff --git a/controllers/ViewPhoto.php b/controllers/ViewPhoto.php index ab53c0ff..0b8a6b8d 100644 --- a/controllers/ViewPhoto.php +++ b/controllers/ViewPhoto.php @@ -46,6 +46,18 @@ class ViewPhoto extends HTMLController if (isset($tag)) $page->setTag($tag); + // Keeping tabs on a filter? + if (isset($_GET['by'])) + { + // Let's first verify that the filter is valid + $user = Member::fromSlug($_GET['by']); + if (!$user) + throw new UnexpectedValueException('Invalid filter for this album or tag.'); + + // Alright, let's run with it then + $page->setActiveFilter($user->getSlug()); + } + $this->page->adopt($page); $this->page->setCanonicalUrl($this->photo->getPageUrl()); } diff --git a/controllers/ViewPhotoAlbum.php b/controllers/ViewPhotoAlbum.php index 6524c86b..f6872df4 100644 --- a/controllers/ViewPhotoAlbum.php +++ b/controllers/ViewPhotoAlbum.php @@ -26,60 +26,89 @@ class ViewPhotoAlbum extends HTMLController $tag = Tag::fromSlug($_GET['tag']); $id_tag = $tag->id_tag; $title = $tag->tag; - $description = !empty($tag->description) ? $tag->description : ''; - - // Can we go up a level? - if ($tag->id_parent != 0) - { - $ptag = Tag::fromId($tag->id_parent); - $back_link = BASEURL . '/' . (!empty($ptag->slug) ? $ptag->slug . '/' : ''); - $back_link_title = 'Back to "' . $ptag->tag . '"'; - } - elseif ($tag->kind === 'Person') - { - $back_link = BASEURL . '/people/'; - $back_link_title = 'Back to "People"'; - $is_person = true; - } - - $header_box = new AlbumHeaderBox($title, $description, $back_link, $back_link_title); + $header_box = $this->getHeaderBox($tag); } // View the album root. else { $id_tag = 1; + $tag = Tag::fromId($id_tag); $title = 'Albums'; } // What page are we at? - $page = isset($_GET['page']) ? (int) $_GET['page'] : 1; + $current_page = isset($_GET['page']) ? (int) $_GET['page'] : 1; - parent::__construct($title . ' - Page ' . $page . ' - ' . SITE_TITLE); + parent::__construct($title . ' - Page ' . $current_page . ' - ' . SITE_TITLE); if (isset($header_box)) $this->page->adopt($header_box); - // Can we do fancy things here? - // !!! TODO: permission system? - $buttons = $this->getAlbumButtons($id_tag, $tag ?? null); - if (!empty($buttons)) - $this->page->adopt(new AlbumButtonBox($buttons)); + // Who contributed to this album? + $contributors = $tag->getContributorList(); + + // Enumerate possible filters + $filters = []; + if (!empty($contributors)) + { + $filters[''] = ['id_user' => null, 'label' => '', 'caption' => 'All photos', + 'link' => $tag->getUrl()]; + + foreach ($contributors as $contributor) + { + $filters[$contributor['slug']] = [ + 'id_user' => $contributor['id_user'], + 'label' => $contributor['first_name'], + 'caption' => sprintf('By %s (%s photos)', + $contributor['first_name'], $contributor['num_assets']), + 'link' => $tag->getUrl() . '?by=' . $contributor['slug'], + ]; + } + } + + // Limit to a particular uploader? + $active_filter = ''; + $id_user_uploaded = null; + if (!empty($_GET['by'])) + { + if (!isset($filters[$_GET['by']])) + throw new UnexpectedValueException('Invalid filter for this album or tag.'); + + $active_filter = $_GET['by']; + $id_user_uploaded = $filters[$active_filter]['id_user']; + $filters[$active_filter]['is_active'] = true; + } + + // Add an interface to query and modify the album/tag + $buttons = $this->getAlbumButtons($tag, $active_filter); + $button_strip = new AlbumButtonBox($buttons, $filters, $active_filter); + $this->page->adopt($button_strip); // Fetch subalbums, but only if we're on the first page. - if ($page === 1) + if ($current_page === 1) { $albums = $this->getAlbums($id_tag); $index = new AlbumIndex($albums); $this->page->adopt($index); } + // Are we viewing a person tag? + $is_person = $tag->kind === 'Person'; + // Load a photo mosaic for the current tag. - list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $page, !isset($is_person)); + list($mosaic, $total_count) = $this->getPhotoMosaic($id_tag, $id_user_uploaded, $current_page, !$is_person); if (isset($mosaic)) { $index = new PhotosIndex($mosaic, Registry::get('user')->isAdmin()); $this->page->adopt($index); - if ($id_tag > 1) - $index->setUrlSuffix('?in=' . $id_tag); + + $url_params = []; + if (isset($tag) && $tag->id_parent != 0) + $url_params['in'] = $tag->id_tag; + if (!empty($active_filter)) + $url_params['by'] = $active_filter; + + if (!empty($url_params)) + $index->setUrlSuffix('?' . http_build_query($url_params)); } // Make a page index as needed, while we're at it. @@ -88,24 +117,24 @@ class ViewPhotoAlbum extends HTMLController $index = new PageIndex([ 'recordCount' => $total_count, 'items_per_page' => self::PER_PAGE, - 'start' => (isset($_GET['page']) ? $_GET['page'] - 1 : 0) * self::PER_PAGE, - 'base_url' => BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : ''), - 'page_slug' => 'page/%PAGE%/', + 'start' => ($current_page - 1) * self::PER_PAGE, + 'base_url' => $tag->getUrl(), + 'page_slug' => 'page/%PAGE%/' . (!empty($active_filter) ? '?by=' . $active_filter : ''), 'index_class' => 'pagination-lg justify-content-around justify-content-lg-center', ]); $this->page->adopt(new PageIndexWidget($index)); } // Set the canonical url. - $this->page->setCanonicalUrl(BASEURL . '/' . (isset($_GET['tag']) ? $_GET['tag'] . '/' : '') . - ($page > 1 ? 'page/' . $page . '/' : '')); + $this->page->setCanonicalUrl($tag->getUrl() . ($current_page > 1 ? 'page/' . $current_page . '/' : '')); } - public function getPhotoMosaic($id_tag, $page, $sort_linear) + public function getPhotoMosaic($id_tag, $id_user_uploaded, $page, $sort_linear) { // Create an iterator. list($this->iterator, $total_count) = AssetIterator::getByOptions([ 'id_tag' => $id_tag, + 'id_user_uploaded' => $id_user_uploaded, 'order' => 'date_captured', 'direction' => $sort_linear ? 'asc' : 'desc', 'limit' => self::PER_PAGE, @@ -145,25 +174,26 @@ class ViewPhotoAlbum extends HTMLController return $albums; } - private function getAlbumButtons($id_tag, $tag) + private function getAlbumButtons(Tag $tag, $active_filter) { $buttons = []; $user = Registry::get('user'); if ($user->isLoggedIn()) { + $suffix = !empty($active_filter) ? '&by=' . $active_filter : ''; $buttons[] = [ - 'url' => BASEURL . '/download/?tag=' . $id_tag, + 'url' => BASEURL . '/download/?tag=' . $tag->id_tag . $suffix, 'caption' => 'Download album', ]; } - if (isset($tag)) + if ($tag->id_parent != 0) { if ($tag->kind === 'Album') { $buttons[] = [ - 'url' => BASEURL . '/uploadmedia/?tag=' . $id_tag, + 'url' => BASEURL . '/uploadmedia/?tag=' . $tag->id_tag, 'caption' => 'Upload photos here', ]; } @@ -173,14 +203,14 @@ class ViewPhotoAlbum extends HTMLController if ($tag->kind === 'Album') { $buttons[] = [ - 'url' => BASEURL . '/editalbum/?id=' . $id_tag, + 'url' => BASEURL . '/editalbum/?id=' . $tag->id_tag, 'caption' => 'Edit album', ]; } elseif ($tag->kind === 'Person') { $buttons[] = [ - 'url' => BASEURL . '/edittag/?id=' . $id_tag, + 'url' => BASEURL . '/edittag/?id=' . $tag->id_tag, 'caption' => 'Edit tag', ]; } @@ -190,7 +220,7 @@ class ViewPhotoAlbum extends HTMLController if ($user->isAdmin() && (!isset($tag) || $tag->kind === 'Album')) { $buttons[] = [ - 'url' => BASEURL . '/addalbum/?tag=' . $id_tag, + 'url' => BASEURL . '/addalbum/?tag=' . $tag->id_tag, 'caption' => 'Create subalbum', ]; } @@ -198,6 +228,25 @@ class ViewPhotoAlbum extends HTMLController return $buttons; } + private function getHeaderBox(Tag $tag) + { + // Can we go up a level? + if ($tag->id_parent != 0) + { + $ptag = Tag::fromId($tag->id_parent); + $back_link = BASEURL . '/' . (!empty($ptag->slug) ? $ptag->slug . '/' : ''); + $back_link_title = 'Back to "' . $ptag->tag . '"'; + } + elseif ($tag->kind === 'Person') + { + $back_link = BASEURL . '/people/'; + $back_link_title = 'Back to "People"'; + } + + $description = !empty($tag->description) ? $tag->description : ''; + return new AlbumHeaderBox($tag->tag, $description, $back_link, $back_link_title); + } + public function __destruct() { if (isset($this->iterator)) diff --git a/models/Asset.php b/models/Asset.php index 3fc9d046..c2973c7f 100644 --- a/models/Asset.php +++ b/models/Asset.php @@ -697,65 +697,79 @@ class Asset $params); } - public function getUrlForPreviousInSet(?Tag $tag) + protected function getUrlForAdjacentInSet($prevNext, ?Tag $tag, $activeFilter) { - $row = Registry::get('db')->queryAssoc(' - SELECT a.* - ' . (isset($tag) ? ' - FROM assets_tags AS t - INNER JOIN assets AS a ON a.id_asset = t.id_asset - WHERE t.id_tag = {int:id_tag} AND - (a.date_captured, a.id_asset) < ({datetime:date_captured}, {int:id_asset}) - ORDER BY a.date_captured DESC, a.id_asset DESC' - : ' - FROM assets AS a - WHERE (a.date_captured, a.id_asset) > ({datetime:date_captured}, {int:id_asset}) - ORDER BY date_captured ASC, a.id_asset ASC') - . ' - LIMIT 1', - [ - 'id_asset' => $this->id_asset, - 'id_tag' => isset($tag) ? $tag->id_tag : null, - 'date_captured' => $this->date_captured, - ]); + $next = $prevNext === 'next'; + $previous = !$next; - if ($row) + $where = []; + $params = [ + 'id_asset' => $this->id_asset, + 'date_captured' => $this->date_captured, + ]; + + // Direction depends on whether we're browsing a tag or timeline + if (isset($tag)) { - $obj = self::byRow($row, 'object'); - return $obj->getPageUrl() . ($tag ? '?in=' . $tag->id_tag : ''); + $where[] = 't.id_tag = {int:id_tag}'; + $params['id_tag'] = $tag->id_tag; + $params['where_op'] = $previous ? '<' : '>'; + $params['order_dir'] = $previous ? 'DESC' : 'ASC'; } else + { + $params['where_op'] = $previous ? '>' : '<'; + $params['order_dir'] = $previous ? 'ASC' : 'DESC'; + } + + // Take active filter into account as well + if (!empty($activeFilter) && ($user = Member::fromSlug($activeFilter)) !== false) + { + $where[] = 'id_user_uploaded = {int:id_user_uploaded}'; + $params['id_user_uploaded'] = $user->getUserId(); + } + + // Use complete ordering when sorting the set + $where[] = '(a.date_captured, a.id_asset) {raw:where_op} ' . + '({datetime:date_captured}, {int:id_asset})'; + + // Stringify conditions together + $where = '(' . implode(') AND (', $where) . ')'; + + // Run query, leaving out tags table if not required + $row = Registry::get('db')->queryAssoc(' + SELECT a.* + FROM assets AS a + ' . (isset($tag) ? ' + INNER JOIN assets_tags AS t ON a.id_asset = t.id_asset' : '') . ' + WHERE ' . $where . ' + ORDER BY a.date_captured {raw:order_dir}, a.id_asset {raw:order_dir} + LIMIT 1', + $params); + + if (!$row) return false; + + $obj = self::byRow($row, 'object'); + + $urlParams = []; + if (isset($tag)) + $urlParams['in'] = $tag->id_tag; + if (!empty($activeFilter)) + $urlParams['by'] = $activeFilter; + + $queryString = !empty($urlParams) ? '?' . http_build_query($urlParams) : ''; + + return $obj->getPageUrl() . $queryString; } - public function getUrlForNextInSet(?Tag $tag) + public function getUrlForPreviousInSet(?Tag $tag, $activeFilter) { - $row = Registry::get('db')->queryAssoc(' - SELECT a.* - ' . (isset($tag) ? ' - FROM assets_tags AS t - INNER JOIN assets AS a ON a.id_asset = t.id_asset - WHERE t.id_tag = {int:id_tag} AND - (a.date_captured, a.id_asset) > ({datetime:date_captured}, {int:id_asset}) - ORDER BY a.date_captured ASC, a.id_asset ASC' - : ' - FROM assets AS a - WHERE (a.date_captured, a.id_asset) < ({datetime:date_captured}, {int:id_asset}) - ORDER BY date_captured DESC, a.id_asset DESC') - . ' - LIMIT 1', - [ - 'id_asset' => $this->id_asset, - 'id_tag' => isset($tag) ? $tag->id_tag : null, - 'date_captured' => $this->date_captured, - ]); + return $this->getUrlForAdjacentInSet('previous', $tag, $activeFilter); + } - if ($row) - { - $obj = self::byRow($row, 'object'); - return $obj->getPageUrl() . ($tag ? '?in=' . $tag->id_tag : ''); - } - else - return false; + public function getUrlForNextInSet(?Tag $tag, $activeFilter) + { + return $this->getUrlForAdjacentInSet('next', $tag, $activeFilter); } } diff --git a/models/AssetIterator.php b/models/AssetIterator.php index 94132aae..2eabbf32 100644 --- a/models/AssetIterator.php +++ b/models/AssetIterator.php @@ -118,6 +118,11 @@ class AssetIterator extends Asset else $where[] = 'a.mimetype = {string:mime_type}'; } + if (isset($options['id_user_uploaded'])) + { + $params['id_user_uploaded'] = $options['id_user_uploaded']; + $where[] = 'id_user_uploaded = {int:id_user_uploaded}'; + } if (isset($options['id_tag'])) { $params['id_tag'] = $options['id_tag']; diff --git a/models/PageIndex.php b/models/PageIndex.php index 0c988ccf..b1206816 100644 --- a/models/PageIndex.php +++ b/models/PageIndex.php @@ -155,24 +155,20 @@ class PageIndex public function getLink($start = null, $order = null, $dir = null) { - $url = $this->base_url; - $amp = strpos($this->base_url, '?') ? '&' : '?'; + $page = !is_string($start) ? ($start / $this->items_per_page) + 1 : $start; + $url = $this->base_url . str_replace('%PAGE%', $page, $this->page_slug); - if (!empty($start)) - { - $page = $start !== '%d' ? ($start / $this->items_per_page) + 1 : $start; - $url .= strtr($this->page_slug, ['%PAGE%' => $page, '%AMP%' => $amp]); - $amp = '&'; - } + $urlParams = []; if (!empty($order)) - { - $url .= $amp . 'order=' . $order; - $amp = '&'; - } + $urlParams['order'] = $order; if (!empty($dir)) + $urlParams['dir'] = $dir; + + if (!empty($urlParams)) { - $url .= $amp . 'dir=' . $dir; - $amp = '&'; + $queryString = (strpos($uri, '?') !== false ? '&' : '?'); + $queryString .= http_build_query($urlParams); + $url .= $queryString; } return $url; diff --git a/models/Tag.php b/models/Tag.php index 14696f3e..145db1eb 100644 --- a/models/Tag.php +++ b/models/Tag.php @@ -141,6 +141,21 @@ class Tag return $rows; } + public function getContributorList() + { + return Registry::get('db')->queryPairs(' + SELECT u.id_user, u.first_name, u.surname, u.slug, COUNT(*) AS num_assets + FROM assets_tags AS at + LEFT JOIN assets AS a ON at.id_asset = a.id_asset + LEFT JOIN users AS u ON a.id_user_uploaded = u.id_user + WHERE at.id_tag = {int:id_tag} + GROUP BY a.id_user_uploaded + ORDER BY u.first_name, u.surname', + [ + 'id_tag' => $this->id_tag, + ]); + } + public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array') { $rows = Registry::get('db')->queryAssocs(' diff --git a/models/User.php b/models/User.php index f690f272..b69790d1 100644 --- a/models/User.php +++ b/models/User.php @@ -75,6 +75,11 @@ abstract class User return $this->ip_address; } + public function getSlug() + { + return $this->slug; + } + /** * Returns whether user is logged in. */ diff --git a/public/css/default.css b/public/css/default.css index 55f35816..ee3b117d 100644 --- a/public/css/default.css +++ b/public/css/default.css @@ -339,9 +339,11 @@ div.polaroid a { ---------------------*/ .album_button_box { float: right; + display: flex; margin-bottom: 3rem; } -.album_button_box > a { +.album_button_box > a, +.album_button_box .btn { background: var(--bs-body-bg); box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); border-color: var( --bs-secondary-bg); @@ -349,7 +351,9 @@ div.polaroid a { padding: 8px 10px; margin-left: 12px; } -.album_button_box > a:hover { +.album_button_box > a:hover, +.album_button_box .btn:hover, +.album_button_box .btn:focus { background: var(--bs-secondary-bg); border-color: var(--bs-tertiary-bg); color: var(--bs-secondary-color); diff --git a/templates/AlbumButtonBox.php b/templates/AlbumButtonBox.php index 3956ffb3..7626fa3b 100644 --- a/templates/AlbumButtonBox.php +++ b/templates/AlbumButtonBox.php @@ -8,11 +8,15 @@ class AlbumButtonBox extends Template { + private $active_filter; private $buttons; + private $filters; - public function __construct($buttons) + public function __construct(array $buttons, array $filters, $active_filter) { + $this->active_filter = $active_filter; $this->buttons = $buttons; + $this->filters = $filters; } public function html_main() @@ -24,6 +28,39 @@ class AlbumButtonBox extends Template echo ' ', $button['caption'], ''; + if (!empty($this->filters)) + { + echo ' +