feat: allow multi-share with sel manager (close #472)

Signed-off-by: Varun Patil <radialapps@gmail.com>
pulsejet/aio-hw-docs
Varun Patil 2023-11-14 00:40:04 -08:00
parent f0e1b00096
commit 0273ae8537
7 changed files with 170 additions and 30 deletions

View File

@ -62,6 +62,7 @@
<ShareModal /> <ShareModal />
<MoveToFolderModal /> <MoveToFolderModal />
<FaceMoveModal /> <FaceMoveModal />
<AlbumShareModal />
</NcContent> </NcContent>
</template> </template>
@ -91,6 +92,7 @@ import NodeShareModal from '@components/modal/NodeShareModal.vue';
import ShareModal from '@components/modal/ShareModal.vue'; import ShareModal from '@components/modal/ShareModal.vue';
import MoveToFolderModal from '@components/modal/MoveToFolderModal.vue'; import MoveToFolderModal from '@components/modal/MoveToFolderModal.vue';
import FaceMoveModal from '@components/modal/FaceMoveModal.vue'; import FaceMoveModal from '@components/modal/FaceMoveModal.vue';
import AlbumShareModal from '@components/modal/AlbumShareModal.vue';
import * as utils from '@services/utils'; import * as utils from '@services/utils';
import * as nativex from '@native'; import * as nativex from '@native';
@ -139,6 +141,7 @@ export default defineComponent({
ShareModal, ShareModal,
MoveToFolderModal, MoveToFolderModal,
FaceMoveModal, FaceMoveModal,
AlbumShareModal,
ImageMultiple, ImageMultiple,
FolderIcon, FolderIcon,

View File

@ -334,6 +334,12 @@ export default defineComponent({
}, },
async createPublicLinkForAlbum() { async createPublicLinkForAlbum() {
// Check if link already exists
if (this.isPublicLinkSelected) {
return await this.copyPublicLink();
}
// Create new link
this.selectEntity(`${Type.SHARE_TYPE_LINK}`); this.selectEntity(`${Type.SHARE_TYPE_LINK}`);
await this.updateAlbumCollaborators(); await this.updateAlbumCollaborators();
try { try {
@ -343,9 +349,7 @@ export default defineComponent({
if (!utils.uid) return; if (!utils.uid) return;
const album = await dav.getAlbum(utils.uid, this.albumName); const album = await dav.getAlbum(utils.uid, this.albumName);
this.populateCollaborators(album.collaborators); this.populateCollaborators(album.collaborators);
await this.copyPublicLink();
// Direct share if native share is available
if (nativex.has()) this.copyPublicLink();
} catch (error) { } catch (error) {
if (error.response?.status === 404) { if (error.response?.status === 404) {
this.errorFetchingAlbum = 404; this.errorFetchingAlbum = 404;
@ -396,16 +400,12 @@ export default defineComponent({
await navigator.clipboard.writeText(link); await navigator.clipboard.writeText(link);
this.publicLinkCopied = true; this.publicLinkCopied = true;
setTimeout(() => { await new Promise((resolve) => setTimeout(resolve, 2000));
this.publicLinkCopied = false; this.publicLinkCopied = false;
}, 10000);
}, },
selectEntity(collaboratorKey: string) { selectEntity(collaboratorKey: string) {
if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) { if (this.selectedCollaboratorsKeys.includes(collaboratorKey)) return;
return;
}
this.refs.popover?.$refs.popover.hide(); this.refs.popover?.$refs.popover.hide();
this.selectedCollaboratorsKeys.push(collaboratorKey); this.selectedCollaboratorsKeys.push(collaboratorKey);
}, },

View File

@ -4,8 +4,24 @@
{{ t('memories', 'Share Album') }} {{ t('memories', 'Share Album') }}
</template> </template>
<template v-if="showEditFields">
<span class="field-title">
{{ t('memories', 'Name of the album') }}
</span>
<NcTextField
:value.sync="albumName"
type="text"
name="name"
:required="true"
autofocus="true"
:placeholder="t('memories', 'Name of the album')"
/>
</template>
<AlbumCollaborators <AlbumCollaborators
v-if="album" v-if="album"
ref="collaborators"
:album-name="album.basename" :album-name="album.basename"
:collaborators="album.collaborators" :collaborators="album.collaborators"
:public-link="album.publicLink" :public-link="album.publicLink"
@ -16,7 +32,7 @@
:aria-label="t('memories', 'Save collaborators for this album.')" :aria-label="t('memories', 'Save collaborators for this album.')"
type="primary" type="primary"
:disabled="loadingAddCollaborators" :disabled="loadingAddCollaborators"
@click="handleSetCollaborators(collaborators)" @click="save(collaborators)"
> >
<template #icon> <template #icon>
<XLoadingIcon v-if="loadingAddCollaborators" /> <XLoadingIcon v-if="loadingAddCollaborators" />
@ -32,7 +48,10 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import { showError } from '@nextcloud/dialogs';
import NcButton from '@nextcloud/vue/dist/Components/NcButton'; import NcButton from '@nextcloud/vue/dist/Components/NcButton';
const NcTextField = () => import('@nextcloud/vue/dist/Components/NcTextField');
import Modal from './Modal.vue'; import Modal from './Modal.vue';
import ModalMixin from './ModalMixin'; import ModalMixin from './ModalMixin';
@ -44,6 +63,7 @@ export default defineComponent({
name: 'AlbumShareModal', name: 'AlbumShareModal',
components: { components: {
NcButton, NcButton,
NcTextField,
Modal, Modal,
AlbumCollaborators, AlbumCollaborators,
}, },
@ -54,32 +74,85 @@ export default defineComponent({
data: () => ({ data: () => ({
album: null as any, album: null as any,
albumName: String(),
loadingAddCollaborators: false, loadingAddCollaborators: false,
collaborators: [] as any[], collaborators: [] as any[],
}), }),
computed: {
refs() {
return this.$refs as {
collaborators?: InstanceType<typeof AlbumCollaborators>;
};
},
showEditFields() {
return this.album?.basename?.startsWith('.link-');
},
},
created() {
console.assert(!_m.modals.albumShare, 'AlbumShareModal created twice');
_m.modals.albumShare = this.open;
},
methods: { methods: {
async open() { async open(user: string, name: string, link?: boolean) {
this.show = true; this.show = true;
this.loadingAddCollaborators = true;
const { user, name } = this.$route.params; // Load album info
this.album = await dav.getAlbum(user, name); try {
this.loadingAddCollaborators = false; this.loadingAddCollaborators = true;
this.albumName = name;
this.album = await dav.getAlbum(user, name);
} catch {
showError(this.t('memories', 'Failed to load album info: {name}', { name }));
} finally {
this.loadingAddCollaborators = false;
}
// Check if we immediately want to share a link
if (link) {
await this.$nextTick(); // load collaborators component
this.refs.collaborators?.createPublicLinkForAlbum();
}
}, },
cleanup() { cleanup() {
this.show = false; this.show = false;
this.album = null; this.album = null;
this.albumName = String();
}, },
async handleSetCollaborators(collaborators: any[]) { async save(collaborators: any[]) {
try { try {
this.loadingAddCollaborators = true; this.loadingAddCollaborators = true;
// Update album collaborators
await dav.updateAlbum(this.album, { await dav.updateAlbum(this.album, {
albumName: this.album.basename, albumName: this.album.basename,
properties: { collaborators }, properties: { collaborators },
}); });
this.close();
// Update album name if changed
if (this.album.basename !== this.albumName) {
await dav.renameAlbum(this.album, this.album.basename, this.albumName);
// Change route to new album name if we're on album page
if (this.routeIsAlbums) {
// Do not await but proceed to close modal instantly
this.$router.replace({
name: this.$route.name!,
params: {
user: this.$route.params.user,
name: this.albumName,
},
});
}
}
// Close modal
await this.close();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@ -94,4 +167,8 @@ export default defineComponent({
.album-share.loading-icon { .album-share.loading-icon {
height: 350px; height: 350px;
} }
span.field-title {
color: var(--color-text-lighter);
}
</style> </style>

View File

@ -119,7 +119,7 @@ export default defineComponent({
}, },
hasVideos(): boolean { hasVideos(): boolean {
return !!this.photos?.some(utils.isVideo); return Boolean(this.photos?.some(utils.isVideo));
}, },
canShareNative(): boolean { canShareNative(): boolean {
@ -137,11 +137,20 @@ export default defineComponent({
}, },
canShareLink(): boolean { canShareLink(): boolean {
return !this.routeIsAlbums && !!this.photos?.every((p) => p?.imageInfo?.permissions?.includes('S')); if (this.routeIsAlbums || !this.photos?.length) return false;
// Check if all imageInfos are loaded (e.g. on viewer)
// Then check if all images can be shared
if (this.photos.every((p) => !!p.imageInfo)) {
return Boolean(this.photos.every((p) => p.imageInfo?.permissions?.includes('S')));
}
// If imageInfos are not loaded, fail later
return true;
}, },
hasLocal(): boolean { hasLocal(): boolean {
return !!this.photos?.some(utils.isLocalPhoto); return Boolean(this.photos?.some(utils.isLocalPhoto));
}, },
}, },
@ -198,9 +207,58 @@ export default defineComponent({
}, },
async shareLink() { async shareLink() {
const fileInfo = await this.l(async () => (await dav.getFiles([this.photos![0]]))[0]); // Check if we have photos
await this.close(); // wait till transition is done if (!this.photos) return;
_m.modals.shareNodeLink(fileInfo.filename, true);
// Fill in image infos to get permissions and paths
await this.l(async () => await dav.fillImageInfo(this.photos!));
// Check if permissions allow sharing
for (const photo of this.photos!) {
// Error shown by fillImageInfo
if (!photo.imageInfo) return;
// Check if we can share this file
if (!photo.imageInfo.permissions?.includes('S')) {
const err = this.t('memories', 'Not allowed to share file: {name}', {
name: photo.basename ?? photo.fileid,
});
showError(err);
return;
}
}
// Open node share modal if single file
if (this.photos.length === 1) {
const filename = this.photos[0].imageInfo!.filename;
if (!filename) return;
await this.close(); // wait till transition is done
_m.modals.shareNodeLink(filename, true);
return;
}
// Generate random alphanumeric string name for album
const name = '.link-' + (Math.random() + 1).toString(36).substring(2);
// Create hidden album if multiple files are selected
await this.l(async () => {
// Create album using WebDAV
try {
await dav.createAlbum(name);
} catch (e) {
showError(this.t('memories', 'Failed to create album for public link'));
return null;
}
// Album is created, now add photos to it
for await (const _ of dav.addToAlbum(utils.uid!, name, this.photos!)) {
// do nothing
}
// Open album share modal
await this.close(); // wait till transition is done
await _m.modals.albumShare(utils.uid!, name, true);
});
}, },
/** /**

View File

@ -50,7 +50,7 @@
</NcActionButton> </NcActionButton>
<NcActionButton <NcActionButton
:aria-label="t('memories', 'Share album')" :aria-label="t('memories', 'Share album')"
@click="refs.shareModal.open()" @click="openShareModal()"
close-after-click close-after-click
v-if="canEditAlbum" v-if="canEditAlbum"
> >
@ -89,7 +89,6 @@
<AlbumCreateModal ref="createModal" /> <AlbumCreateModal ref="createModal" />
<AlbumDeleteModal ref="deleteModal" /> <AlbumDeleteModal ref="deleteModal" />
<AlbumShareModal ref="shareModal" />
</div> </div>
</template> </template>
@ -106,7 +105,6 @@ import axios from '@nextcloud/axios';
import AlbumCreateModal from '@components/modal/AlbumCreateModal.vue'; import AlbumCreateModal from '@components/modal/AlbumCreateModal.vue';
import AlbumDeleteModal from '@components/modal/AlbumDeleteModal.vue'; import AlbumDeleteModal from '@components/modal/AlbumDeleteModal.vue';
import AlbumShareModal from '@components/modal/AlbumShareModal.vue';
import { downloadWithHandle } from '@services/dav/download'; import { downloadWithHandle } from '@services/dav/download';
import { API } from '@services/API'; import { API } from '@services/API';
@ -132,7 +130,6 @@ export default defineComponent({
AlbumCreateModal, AlbumCreateModal,
AlbumDeleteModal, AlbumDeleteModal,
AlbumShareModal,
BackIcon, BackIcon,
DownloadIcon, DownloadIcon,
@ -152,7 +149,6 @@ export default defineComponent({
return this.$refs as { return this.$refs as {
createModal: InstanceType<typeof AlbumCreateModal>; createModal: InstanceType<typeof AlbumCreateModal>;
deleteModal: InstanceType<typeof AlbumDeleteModal>; deleteModal: InstanceType<typeof AlbumDeleteModal>;
shareModal: InstanceType<typeof AlbumShareModal>;
}; };
}, },
@ -179,6 +175,10 @@ export default defineComponent({
this.$router.go(-1); this.$router.go(-1);
}, },
openShareModal() {
_m.modals.albumShare(this.$route.params.user, this.$route.params.name);
},
async downloadAlbum() { async downloadAlbum() {
const res = await axios.post(API.ALBUM_DOWNLOAD(this.$route.params.user, this.$route.params.name)); const res = await axios.post(API.ALBUM_DOWNLOAD(this.$route.params.user, this.$route.params.name));
if (res.status === 200 && res.data.handle) { if (res.status === 200 && res.data.handle) {

1
src/globals.d.ts vendored
View File

@ -44,6 +44,7 @@ declare global {
shareNodeLink: (path: string, immediate?: boolean) => Promise<void>; shareNodeLink: (path: string, immediate?: boolean) => Promise<void>;
moveToFolder: (photos: IPhoto[]) => void; moveToFolder: (photos: IPhoto[]) => void;
moveToFace: (photos: IPhoto[]) => void; moveToFace: (photos: IPhoto[]) => void;
albumShare: (user: string, name: string, link?: boolean) => Promise<void>;
showSettings: () => void; showSettings: () => void;
}; };

View File

@ -365,7 +365,8 @@ export async function fillImageInfo(photos: IPhoto[], query?: { tags?: number },
p.datetaken = res.data.datetaken; p.datetaken = res.data.datetaken;
p.imageInfo = res.data; p.imageInfo = res.data;
} catch (error) { } catch (error) {
console.error('Failed to get image info for', p.fileid, error); console.error('Failed to get image info', p, error);
showError(t('memories', 'Failed to load image info: {name}', { name: p.basename ?? p.fileid }));
} finally { } finally {
done++; done++;
progress?.(done); progress?.(done);