Merge branch 'issue-738.ux-improvements' of https://github.com/johnSamilin/memories into johnSamilin-issue-738.ux-improvements

pull/767/head
Varun Patil 2023-08-02 11:20:05 -07:00
commit 5890daba1a
10 changed files with 432 additions and 36 deletions

View File

@ -95,11 +95,12 @@ class AlbumsBackend extends Backend
// Run actual query
$list = [];
$t = (int) $request->getParam('t', 0);
$fileid = (int) $request->getParam('fid', -1);
if ($t & 1) { // personal
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID()));
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID(), $fileid));
}
if ($t & 2) { // shared
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID(), true));
$list = array_merge($list, $this->albumsQuery->getList(Util::getUID(), $fileid, true));
}
// Remove elements with duplicate album_id

View File

@ -17,11 +17,16 @@ class AlbumsQuery
}
/** Get list of albums */
public function getList(string $uid, bool $shared = false)
public function getList(string $uid, int $fileId, bool $shared = false)
{
$query = $this->connection->getQueryBuilder();
$allPhotosQuery = $this->connection->getQueryBuilder();
// SELECT everything from albums
$allPhotosQuery->select('album_id')->from('photos_albums_files');
$allPhotosQuery->where(
$allPhotosQuery->expr()->eq('file_id', $allPhotosQuery->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))
);
$count = $query->func()->count($query->createFunction('DISTINCT m.fileid'), 'count');
$query->select(
'pa.album_id',
@ -64,13 +69,21 @@ class AlbumsQuery
// FETCH all albums
$albums = $query->executeQuery()->fetchAll();
$allPhotos = $allPhotosQuery->executeQuery()->fetchAll();
$albumIds = array();
foreach ($allPhotos as &$album) {
$albumIds[$album['album_id']] = true;
}
// Post process
foreach ($albums as &$row) {
$albumId = (int) $row['album_id'];
$row['cluster_id'] = $row['user'].'/'.$row['name'];
$row['album_id'] = (int) $row['album_id'];
$row['album_id'] = $albumId;
$row['created'] = (int) $row['created'];
$row['last_added_photo'] = (int) $row['last_added_photo'];
$row['has_file'] = !!$albumIds[$albumId];
}
return $albums;

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="none"/>
<path d="M5 13.3636L8.03559 16.3204C8.42388 16.6986 9.04279 16.6986 9.43108 16.3204L19 7" stroke="#000000" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@ -0,0 +1,204 @@
<template>
<div class="fill-block wrapper">
<div class="loading-icon fill-block" v-if="loadingAlbums">
<XLoadingIcon />
</div>
<NcButton
:aria-label="t('memories', 'Add to album.')"
class="new-album-button"
type="tertiary"
@click="addToAlbum"
>
<template #icon>
<Plus />
</template>
{{ t('memories', 'Add to album') }}
</NcButton>
<span class="empty-state" v-if="albums.length === 0">{{ t('memories', 'No albums') }}</span>
<ul v-else class="albums-container">
<NcListItem
v-for="album in albums"
class="album"
:key="album.album_id"
:title="album.name"
@click="ignoreClick"
>
<template #icon>
<XImg class="album__image" :src="toCoverUrl(album.last_added_photo)" />
</template>
<template #subtitle>
{{ getSubtitle(album) }}
</template>
</NcListItem>
</ul>
<AddToAlbumModal ref="addToAlbumModal" @added="loadAlbums" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import axios from '@nextcloud/axios';
import { subscribe, unsubscribe } from '@nextcloud/event-bus';
import { IAlbum, IPhoto } from '../types';
import { API } from '../services/API';
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
import Plus from 'vue-material-design-icons/Plus.vue';
import AddToAlbumModal from './modal/AddToAlbumModal.vue';
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue';
import { getPreviewUrl } from '../services/utils/helpers';
import { getCurrentUser } from '@nextcloud/auth';
const NcListItem = () => import('@nextcloud/vue/dist/Components/NcListItem');
export default defineComponent({
name: 'AlbumsList',
components: {
ImageMultiple,
NcListItem,
NcButton,
Plus,
AddToAlbumModal,
},
props: {
fileid: {
type: Number,
required: true,
},
filename: {
type: String,
required: true,
},
},
data: () => ({
photo: {} as { id: number, name: string },
albums: [] as IAlbum[],
loadingAlbums: false,
}),
mounted() {
subscribe('files:file:updated', this.handleFileUpdated);
this.update({ id: this.$props.fileid, name: this.$props.filename });
},
beforeDestroy() {
unsubscribe('files:file:updated', this.handleFileUpdated);
},
methods: {
update(photo: { id: number, name: string }){
this.photo = photo;
this.loadAlbums();
},
async loadAlbums() {
try {
this.loadingAlbums = true;
const res = await axios.get<IAlbum[]>(API.ALBUM_LIST(3, this.photo.id));
this.albums = res.data.filter(album => album.has_file);
} catch (e) {
console.error(e);
} finally {
this.loadingAlbums = false;
}
},
handleFileUpdated({ fileid }: { fileid: number }) {
if (fileid && this.photo.id === fileid) {
this.update(this.photo);
}
},
toCoverUrl(fileId: string | number) {
return getPreviewUrl({
photo: {
fileid: Number(fileId),
} as IPhoto,
sqsize: 256,
});
},
getSubtitle(album: IAlbum) {
let text = this.n('memories', '%n item', '%n items', album.count);
if (album.user !== getCurrentUser()?.uid) {
text +=
' / ' +
this.t('memories', 'shared by {owner}', {
owner: album.user_display || album.user,
});
}
return text;
},
ignoreClick(e) {
e.preventDefault();
},
addToAlbum() {
(<any>this.$refs.addToAlbumModal).open([{
fileid: this.photo.id,
basename: this.photo.name,
}]);
},
},
});
</script>
<style lang="scss" scoped>
.loading-icon {
height: 75%;
}
.wrapper {
display: flex;
flex-direction: column;
}
.albums-container {
flex-grow: 1;
overflow: auto;
}
.album {
:deep .list-item {
box-sizing: border-box;
display: flex;
}
:deep .list-item-content__wrapper {
flex-grow: 1;
}
:deep .line-one__title {
font-weight: 500;
}
&__image {
width: auto;
height: 100%;
aspect-ratio: 1/1;
object-fit: cover;
border-radius: 50%;
margin-right: 5px;
}
}
.empty-state {
width: 100%;
flex-grow: 1;
align-items: center;
justify-content: center;
display: flex;
}
.new-album-button {
margin: auto;
}
</style>

View File

@ -38,6 +38,8 @@
<div v-if="lat && lon" class="map">
<iframe class="fill-block" :src="mapUrl" />
</div>
<AlbumsList :fileid="fileid" :filename="filename" />
</div>
<div class="loading-icon fill-block" v-else>
@ -65,6 +67,7 @@ import ImageIcon from 'vue-material-design-icons/Image.vue';
import InfoIcon from 'vue-material-design-icons/InformationOutline.vue';
import LocationIcon from 'vue-material-design-icons/MapMarker.vue';
import TagIcon from 'vue-material-design-icons/Tag.vue';
import AlbumsList from './AlbumsList.vue';
import { API } from '../services/API';
import type { IImageInfo, IPhoto } from '../types';
@ -82,10 +85,12 @@ export default defineComponent({
NcActions,
NcActionButton,
EditIcon,
AlbumsList,
},
data: () => ({
fileid: null as number | null,
filename: '',
exif: {} as { [prop: string]: any },
baseInfo: {} as IImageInfo,
state: 0,
@ -330,6 +335,7 @@ export default defineComponent({
if (state !== this.state) return res.data;
this.fileid = res.data.fileid;
this.filename = res.data.basename;
this.exif = res.data.exif || {};
this.baseInfo = res.data;
return this.baseInfo;

View File

@ -5,10 +5,10 @@
</template>
<div class="outer">
<AlbumPicker @select="selectAlbum" />
<AlbumPicker @select="updateAlbums" :photos="photos" />
<div v-if="processing">
<NcProgressBar :value="Math.round((photosDone * 100) / photos.length)" :error="true" />
<NcProgressBar :value="progress" :error="true" />
</div>
</div>
</Modal>
@ -37,13 +37,16 @@ export default defineComponent({
data: () => ({
show: false,
photos: [] as IPhoto[],
photosDone: 0,
progress: 0,
processing: false,
processed: new Set<IPhoto>(),
photosDone: 0,
totalOperations: 0,
}),
methods: {
open(photos: IPhoto[]) {
this.photosDone = 0;
this.progress = 0;
this.processing = false;
this.show = true;
this.photos = photos;
@ -60,20 +63,34 @@ export default defineComponent({
this.$emit('close');
},
async selectAlbum(album: IAlbum) {
if (this.processing) return;
async processAlbum(album: IAlbum, action: 'add' | 'remove') {
const name = album.name || album.album_id.toString();
const gen = dav.addToAlbum(album.user, name, this.photos);
this.processing = true;
const gen = action === 'add'
? dav.addToAlbum(album.user, name, this.photos)
: dav.removeFromAlbum(album.user, name, this.photos);
for await (const fids of gen) {
this.photosDone += fids.filter((f) => f).length;
this.added(this.photos.filter((p) => fids.includes(p.fileid)));
this.photosDone += fids.length;
this.photos.forEach((p) => {
if (fids.includes(p.fileid)) {
this.processed.add(p);
}
});
}
this.progress = Math.round((this.photosDone * 100) / this.totalOperations);
},
const n = this.photosDone;
showInfo(this.n('memories', '{n} item added to album', '{n} items added to album', n, { n }));
async updateAlbums(albumsToAddTo: IAlbum[], albumsToRemoveFrom: IAlbum[] = []) {
if (this.processing) return;
this.processing = true;
this.processed = new Set<IPhoto>();
this.totalOperations = this.photos.length * (albumsToAddTo.length + albumsToRemoveFrom.length);
await Promise.all(albumsToAddTo.map((album) => this.processAlbum(album, 'add')));
await Promise.all(albumsToRemoveFrom.map((album) => this.processAlbum(album, 'remove')));
const n = this.processed.size;
this.added(Array.from(this.processed));
showInfo(this.n('memories', '{n} processed', '{n} processed', n, { n }));
this.close();
},
},

View File

@ -13,7 +13,7 @@
albumName: album.name,
})
"
@click="pickAlbum(album)"
@click.prevent="() => {}"
>
<template #icon>
<XImg v-if="album.last_added_photo !== -1" class="album__image" :src="toCoverUrl(album.last_added_photo)" />
@ -23,22 +23,60 @@
</template>
<template #subtitle>
{{ getSubtitle(album) }}
<div @click.prevent="pickAlbum(album)">
{{ getSubtitle(album) }}
</div>
</template>
<template #extra>
<div
v-if="!selectedAlbums.has(album)"
class="check-circle-icon check-circle-icon--inactive"
@click.prevent="toggleAlbumSelection(album)"
>
<XImg :src="checkmarkIcon" />
</div>
<div
v-if="selectedAlbums.has(album)"
class="check-circle-icon"
@click.prevent="toggleAlbumSelection(album)"
>
<XImg :src="checkmarkIcon" />
</div>
</template>
</NcListItem>
</ul>
<NcButton
:aria-label="t('memories', 'Create a new album.')"
class="new-album-button"
type="tertiary"
@click="showAlbumCreationForm = true"
>
<template #icon>
<Plus />
</template>
{{ t('memories', 'Create new album') }}
</NcButton>
<div class="actions">
<NcButton
:aria-label="t('memories', 'Create a new album.')"
class="new-album-button"
type="tertiary"
@click="showAlbumCreationForm = true"
>
<template #icon>
<Plus />
</template>
{{ t('memories', 'Create new album') }}
</NcButton>
<div class="submit-btn-wrapper">
<NcButton
:aria-label="t('memories', `Add to ${selectedCount} albums.`)"
class="new-album-button"
type="primary"
@click="submit"
>
{{ t('memories', 'Add to albums') }}
</NcButton>
<span class="remove-notice" v-if="unselectedCount > 0">
{{ t('memories', 'And remove from') }} {{ n('memories', '{n} album', '{n} albums', unselectedCount , {
n: unselectedCount,
})}}
</span>
</div>
</div>
</div>
<AlbumForm
@ -58,6 +96,7 @@ import { getCurrentUser } from '@nextcloud/auth';
import AlbumForm from './AlbumForm.vue';
import Plus from 'vue-material-design-icons/Plus.vue';
import ImageMultiple from 'vue-material-design-icons/ImageMultiple.vue';
import checkmarkIcon from '../../assets/checkmark.svg';
import axios from '@nextcloud/axios';
@ -67,9 +106,17 @@ const NcListItem = () => import('@nextcloud/vue/dist/Components/NcListItem');
import { getPreviewUrl } from '../../services/utils/helpers';
import { IAlbum, IPhoto } from '../../types';
import { API } from '../../services/API';
import { PropType } from 'vue';
export default defineComponent({
name: 'AlbumPicker',
props: {
/** List of pictures that are selected */
photos: {
type: Array as PropType<IPhoto[]>,
required: true,
},
},
components: {
AlbumForm,
Plus,
@ -82,9 +129,19 @@ export default defineComponent({
showAlbumCreationForm: false,
albums: [] as IAlbum[],
loadingAlbums: true,
photoId: -1,
checkmarkIcon,
selectedAlbums: new Set<IAlbum>(),
unselectedAlbums: new Set<IAlbum>(),
selectedCount: 0,
unselectedCount: 0,
}),
mounted() {
if (this.photos.length === 1) {
// this only makes sense when we try to add single photo to albums
this.photoId = this.photos[0].fileid;
}
this.loadAlbums();
},
@ -119,8 +176,11 @@ export default defineComponent({
async loadAlbums() {
try {
const res = await axios.get<IAlbum[]>(API.ALBUM_LIST());
const res = await axios.get<IAlbum[]>(API.ALBUM_LIST(3, this.photoId));
this.albums = res.data;
this.selectedAlbums = new Set(this.albums.filter(album => album.has_file));
this.unselectedAlbums = new Set();
this.unselectedCount = 0;
} catch (e) {
console.error(e);
} finally {
@ -128,9 +188,33 @@ export default defineComponent({
}
},
pickAlbum(album: IAlbum) {
this.$emit('select', album);
toggleAlbumSelection(album: IAlbum) {
if (this.selectedAlbums.has(album)) {
this.selectedAlbums.delete(album);
this.unselectedAlbums.add(album)
} else if (this.unselectedAlbums.has(album)) {
this.selectedAlbums.add(album)
this.unselectedAlbums.delete(album);
} else {
this.selectedAlbums.add(album)
}
this.unselectedCount = this.albums.reduce((acc, album) => {
if (album.has_file && this.unselectedAlbums.has(album)) {
acc += 1;
}
return acc;
}, 0); this.selectedAlbums.size;
this.selectedCount = this.selectedAlbums.size;
},
pickAlbum(album: IAlbum) {
this.$emit('select', [album], []);
},
submit() {
this.$emit('select', Array.from(this.selectedAlbums), Array.from(this.unselectedAlbums));
}
},
});
</script>
@ -156,6 +240,11 @@ export default defineComponent({
.album {
:deep .list-item {
box-sizing: border-box;
display: flex;
}
:deep .list-item-content__wrapper {
flex-grow: 1;
}
:deep .line-one__title {
@ -184,10 +273,47 @@ export default defineComponent({
}
}
}
.check-circle-icon {
border-radius: 50%;
border: 1px solid rgba(0, 255, 0, 0.1882352941);
background-color: rgba(0, 255, 0, 0.1882352941);
height: 34px;
width: 34px;
display: flex;
align-items: center;
justify-content: center;
&--inactive {
border: 1px solid rgba($color: black, $alpha: 0.1);
background-color: transparent;
}
& img {
width: 50%;
height: 50%;
}
}
}
.new-album-button {
margin-top: 32px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.submit-btn-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.remove-notice {
font-size: small;
}
}
</style>

View File

@ -136,6 +136,17 @@
<EditFileIcon :size="24" />
</template>
</NcActionButton>
<NcActionButton
:aria-label="t('memories', 'Add to album')"
v-if="canEdit"
@click="addToAlbum"
:close-after-click="true"
>
{{ t('memories', 'Add to album') }}
<template #icon>
<AlbumIcon :size="24" />
</template>
</NcActionButton>
</NcActions>
</div>
@ -151,6 +162,7 @@
</div>
</div>
</div>
<AddToAlbumModal ref="addToAlbumModal" />
</div>
</template>
@ -179,6 +191,7 @@ import 'photoswipe/style.css';
import PsImage from './PsImage';
import PsVideo from './PsVideo';
import PsLivePhoto from './PsLivePhoto';
import AddToAlbumModal from '../modal/AddToAlbumModal.vue';
import ShareIcon from 'vue-material-design-icons/ShareVariant.vue';
import DeleteIcon from 'vue-material-design-icons/TrashCanOutline.vue';
@ -192,6 +205,7 @@ import SlideshowIcon from 'vue-material-design-icons/PlayBox.vue';
import EditFileIcon from 'vue-material-design-icons/FileEdit.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import LivePhotoIcon from 'vue-material-design-icons/MotionPlayOutline.vue';
import AlbumIcon from 'vue-material-design-icons/ImageAlbum.vue';
const SLIDESHOW_MS = 5000;
const BODY_HAS_VIEWER = 'has-viewer';
@ -216,6 +230,8 @@ export default defineComponent({
EditFileIcon,
AlbumRemoveIcon,
LivePhotoIcon,
AlbumIcon,
AddToAlbumModal,
},
mixins: [UserConfig],
@ -1123,6 +1139,12 @@ export default defineComponent({
editMetadata() {
globalThis.editMetadata([globalThis.currentViewerPhoto]);
},
addToAlbum() {
if (this.currentPhoto) {
(<any>this.$refs.addToAlbumModal).open([this.currentPhoto]);
}
},
},
});
</script>

View File

@ -84,8 +84,8 @@ export class API {
return tok(gen(`${BASE}/folders/sub`));
}
static ALBUM_LIST(t: 1 | 2 | 3 = 3) {
return gen(`${BASE}/clusters/albums?t=${t}`);
static ALBUM_LIST(t: 1 | 2 | 3 = 3, photoId: number = -1) {
return gen(`${BASE}/clusters/albums?t=${t}&fid=${photoId}`);
}
static ALBUM_DOWNLOAD(user: string, name: string) {

View File

@ -140,6 +140,8 @@ export interface IAlbum extends ICluster {
location: string;
/** File ID of last added photo */
last_added_photo: number;
/** Whether an album contains the file */
has_file: boolean;
}
export interface IFace extends ICluster {