feat: allow multiple selection for photos/notes

This commit is contained in:
2026-04-08 22:52:47 +03:00
parent 79859153ed
commit 64aea12903
6 changed files with 405 additions and 34 deletions

View File

@@ -20,6 +20,14 @@ vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
template: '<button class="nc-action-button"><slot name="icon" /><slot /></button>',
},
}))
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template:
'<label class="nc-checkbox"><input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', !modelValue)" /><slot /></label>',
props: ['modelValue'],
},
}))
vi.mock('@nextcloud/vue/components/NcRichText', () => ({
default: {
name: 'NcRichText',

View File

@@ -1,7 +1,7 @@
<template>
<div
class="note-card"
:class="{ 'note-card--dragging': isDragging }"
:class="{ 'note-card--dragging': isDragging, 'note-card--selected': selected }"
:style="cardStyle"
:data-drag-id="note.id"
:draggable="draggableEnabled ? 'true' : 'false'"
@@ -10,6 +10,12 @@
@dragover.prevent="onDragOver"
@click="$emit('edit', note)"
>
<div class="note-card__select" @click.stop>
<NcCheckboxRadioSwitch
:model-value="selected"
@update:model-value="$emit('select', note.id)"
/>
</div>
<div class="note-card__actions" @click.stop>
<NcActions :aria-label="strings.actions">
<NcActionButton @click.stop="$emit('delete', note)">
@@ -30,19 +36,22 @@ import { computed, ref } from 'vue'
import { t } from '@nextcloud/l10n'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcRichText from '@nextcloud/vue/components/NcRichText'
import DeleteIcon from '@icons/Delete.vue'
import { contrastColor } from './noteColors'
import type { Note } from '@/api/types'
const props = withDefaults(defineProps<{ note: Note; draggableEnabled?: boolean }>(), {
draggableEnabled: true,
})
const props = withDefaults(
defineProps<{ note: Note; draggableEnabled?: boolean; selected?: boolean }>(),
{ draggableEnabled: true, selected: false },
)
const emit = defineEmits<{
edit: [note: Note]
delete: [note: Note]
'drag-start': [noteId: number]
'reorder-over': [noteId: number, event: MouseEvent]
select: [noteId: number]
}>()
const isDragging = ref(false)
@@ -89,8 +98,9 @@ const strings = {
color: var(--note-fg, inherit);
border: 1px solid var(--color-border);
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
box-shadow 0.2s ease,
transform 0.2s ease,
outline-color 0.2s ease;
min-height: 80px;
max-height: 240px;
display: flex;
@@ -112,13 +122,53 @@ const strings = {
cursor: grabbing;
}
&--selected {
outline: 3px solid var(--color-primary-element);
outline-offset: -3px;
}
&__select {
position: absolute;
top: 0.25rem;
inset-inline-start: 0.25rem;
z-index: 2;
opacity: 0;
transition:
opacity 0.2s ease,
background 0.2s ease;
background: rgba(0, 0, 0, 0.3);
border-radius: 99px;
&:hover {
background: rgba(0, 0, 0, 0.45);
}
:deep(.checkbox-radio-switch__content) {
transition:
color 0.2s ease,
opacity 0.2s ease,
border-radius 0.2s ease;
}
}
&--selected &__select,
&:hover &__select {
opacity: 1;
}
@media (hover: none) {
.note-card__select {
opacity: 1;
}
}
&__actions {
position: absolute;
top: 0.25rem;
inset-inline-end: 0.25rem;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
transition: opacity 0.2s ease;
background: rgba(0, 0, 0, 0.3);
border-radius: 99px;
}

View File

@@ -32,6 +32,14 @@ vi.mock('@nextcloud/vue/components/NcActionButton', () => ({
template: '<button class="nc-action-button"><slot name="icon" /><slot /></button>',
},
}))
vi.mock('@nextcloud/vue/components/NcCheckboxRadioSwitch', () => ({
default: {
name: 'NcCheckboxRadioSwitch',
template:
'<label class="nc-checkbox"><input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', !modelValue)" /><slot /></label>',
props: ['modelValue'],
},
}))
import PhotoCard from './PhotoCard.vue'

View File

@@ -1,7 +1,7 @@
<template>
<div
class="photo-card"
:class="{ 'photo-card--dragging': isDragging }"
:class="{ 'photo-card--dragging': isDragging, 'photo-card--selected': selected }"
:data-drag-id="photo.id"
draggable="true"
@dragstart="onDragStart"
@@ -10,6 +10,12 @@
@click="$emit('preview', photo)"
>
<img :src="thumbUrl(photo.id)" :alt="photo.caption ?? ''" class="photo-card__img" />
<div class="photo-card__select" @click.stop>
<NcCheckboxRadioSwitch
:model-value="selected"
@update:model-value="$emit('select', photo.id)"
/>
</div>
<div class="photo-card__actions" @click.stop>
<NcActions :aria-label="strings.actions">
<NcActionButton @click.stop="$emit('edit', photo)">
@@ -42,14 +48,15 @@ import { t } from '@nextcloud/l10n'
import { photoPreviewUrl } from '@/api/images'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import ArrowUpIcon from '@icons/ArrowUp.vue'
import type { Photo } from '@/api/types'
const props = withDefaults(
defineProps<{ photo: Photo; houseId: number; reorderEnabled?: boolean }>(),
{ reorderEnabled: true },
defineProps<{ photo: Photo; houseId: number; reorderEnabled?: boolean; selected?: boolean }>(),
{ reorderEnabled: true, selected: false },
)
const emit = defineEmits<{
preview: [photo: Photo]
@@ -58,6 +65,7 @@ const emit = defineEmits<{
'move-to-root': [photo: Photo]
'drag-start': [photoId: number]
'reorder-over': [photoId: number, event: MouseEvent]
select: [photoId: number]
}>()
const isDragging = ref(false)
@@ -105,8 +113,9 @@ const strings = {
cursor: grab;
aspect-ratio: 1;
transition:
box-shadow 0.15s ease,
transform 0.15s ease;
box-shadow 0.2s ease,
transform 0.2s ease,
outline-color 0.2s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
@@ -133,13 +142,53 @@ const strings = {
cursor: inherit;
}
&--selected {
outline: 3px solid var(--color-primary-element);
outline-offset: -3px;
}
&__select {
position: absolute;
top: 0.25rem;
inset-inline-start: 0.25rem;
z-index: 2;
opacity: 0;
transition:
opacity 0.2s ease,
background 0.2s ease;
background: rgba(0, 0, 0, 0.45);
border-radius: 99px;
&:hover {
background: rgba(0, 0, 0, 0.6);
}
:deep(.checkbox-radio-switch__content) {
transition:
color 0.2s ease,
opacity 0.2s ease,
border-radius 0.2s ease;
}
}
&--selected &__select,
&:hover &__select {
opacity: 1;
}
@media (hover: none) {
.photo-card__select {
opacity: 1;
}
}
&__actions {
position: absolute;
top: 0.25rem;
inset-inline-end: 0.25rem;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
transition: opacity 0.2s ease;
background: rgba(0, 0, 0, 0.45);
border-radius: 99px;
}

View File

@@ -46,25 +46,39 @@
</template>
</NcEmptyContent>
<div v-else class="pantry-notes__grid">
<template v-for="item in gridItems" :key="item.key">
<div
v-if="item.type === 'placeholder'"
class="pantry-notes__placeholder"
@dragover.prevent
@drop.prevent.stop="onPlaceholderDrop"
/>
<NoteCard
v-else
:note="item.note"
:draggable-enabled="isCustomSort"
@edit="openEditDialog"
@delete="confirmDelete"
@drag-start="onDragStart"
@reorder-over="onReorderOver"
/>
</template>
</div>
<template v-else>
<!-- Selection bar -->
<div v-if="selectedNoteIds.size > 0" class="pantry-selection-bar">
<span>{{ selectionLabel }}</span>
<NcButton variant="error" @click="confirmBulkDelete">
<template #icon><DeleteIcon :size="20" /></template>
{{ strings.delete }}
</NcButton>
<NcButton @click="selectedNoteIds.clear()">{{ strings.clearSelection }}</NcButton>
</div>
<div class="pantry-notes__grid">
<template v-for="item in gridItems" :key="item.key">
<div
v-if="item.type === 'placeholder'"
class="pantry-notes__placeholder"
@dragover.prevent
@drop.prevent.stop="onPlaceholderDrop"
/>
<NoteCard
v-else
:note="item.note"
:draggable-enabled="isCustomSort"
:selected="selectedNoteIds.has(item.note.id)"
@edit="openEditDialog"
@delete="confirmDelete"
@drag-start="onDragStart"
@reorder-over="onReorderOver"
@select="toggleNoteSelection"
/>
</template>
</div>
</template>
</div>
<!-- Create/Edit dialog -->
@@ -90,12 +104,27 @@
<NcButton variant="error" @click="submitDelete">{{ strings.delete }}</NcButton>
</template>
</NcDialog>
<!-- Bulk delete confirm -->
<NcDialog
v-if="bulkDeleting"
:name="strings.deleteTitle"
:open="bulkDeleting"
close-on-click-outside
@update:open="(v) => !v && (bulkDeleting = false)"
>
<p>{{ bulkDeleteBody }}</p>
<template #actions>
<NcButton @click="bulkDeleting = false">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="submitBulkDelete">{{ strings.delete }}</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
@@ -106,6 +135,7 @@ import PageToolbar from '@/components/PageToolbar'
import { NoteCard, NoteDialog } from '@/components/Notes'
import PlusIcon from '@icons/Plus.vue'
import NoteIcon from '@icons/Note.vue'
import DeleteIcon from '@icons/Delete.vue'
import SortIcon from '@icons/Sort.vue'
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
@@ -313,6 +343,47 @@ async function submitDialog(data: { title: string; content: string; color: strin
}
}
// ----- Selection -----
const selectedNoteIds = ref(new Set<number>())
const selectionLabel = computed(() =>
n('pantry', '%n item selected', '%n items selected', selectedNoteIds.value.size),
)
function toggleNoteSelection(noteId: number) {
const next = new Set(selectedNoteIds.value)
if (next.has(noteId)) {
next.delete(noteId)
} else {
next.add(noteId)
}
selectedNoteIds.value = next
}
const bulkDeleting = ref(false)
const bulkDeleteBody = computed(() =>
n(
'pantry',
'Are you sure you want to delete %n note?',
'Are you sure you want to delete %n notes?',
selectedNoteIds.value.size,
),
)
function confirmBulkDelete() {
bulkDeleting.value = true
}
async function submitBulkDelete() {
const ids = [...selectedNoteIds.value]
for (const id of ids) {
await remove(id)
}
selectedNoteIds.value = new Set()
bulkDeleting.value = false
}
// ----- Delete -----
const deletingNote = ref<Note | null>(null)
@@ -341,6 +412,7 @@ const strings = {
emptyBody: t('pantry', 'Create your first note to start sharing reminders with your household.'),
deleteTitle: t('pantry', 'Delete note'),
sortLabel: t('pantry', 'Sort order'),
clearSelection: t('pantry', 'Clear selection'),
}
</script>
@@ -375,6 +447,21 @@ const strings = {
padding: 2rem;
}
.pantry-selection-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
margin: 0 1rem 0.75rem;
background: var(--color-primary-element-light);
border-radius: var(--border-radius-large, 12px);
font-weight: 500;
span:first-child {
flex: 1;
}
}
.pantry-sort-active {
font-weight: 600;
}

View File

@@ -59,6 +59,20 @@
<p>{{ strings.dropToUpload }}</p>
</div>
<!-- Selection bar -->
<div v-if="selectedPhotoIds.size > 0" class="pantry-selection-bar">
<span>{{ photoSelectionLabel }}</span>
<NcButton @click="showMoveToFolderDialog = true">
<template #icon><FolderMoveIcon :size="20" /></template>
{{ strings.moveToFolder }}
</NcButton>
<NcButton variant="error" @click="confirmBulkDeletePhotos">
<template #icon><DeleteIcon :size="20" /></template>
{{ strings.delete }}
</NcButton>
<NcButton @click="selectedPhotoIds.clear()">{{ strings.clearSelection }}</NcButton>
</div>
<div v-if="loading" class="pantry-center">
<NcLoadingIcon :size="36" />
</div>
@@ -124,12 +138,14 @@
:photo="item.photo"
:house-id="houseIdNum"
:reorder-enabled="isCustomSort"
:selected="selectedPhotoIds.has(item.photo.id)"
@preview="openPreview"
@edit="startEditPhoto"
@delete="confirmDeletePhoto"
@move-to-root="movePhotoToRoot"
@drag-start="onPhotoDragStart"
@reorder-over="(id, e) => onReorderOver(id, rootPhotos, e)"
@select="togglePhotoSelection"
/>
</template>
</div>
@@ -169,12 +185,14 @@
:photo="item.photo"
:house-id="houseIdNum"
:reorder-enabled="isCustomSort"
:selected="selectedPhotoIds.has(item.photo.id)"
@preview="openPreview"
@edit="startEditPhoto"
@delete="confirmDeletePhoto"
@move-to-root="movePhotoToRoot"
@drag-start="onPhotoDragStart"
@reorder-over="(id, e) => onReorderOver(id, activeFolderPhotos, e)"
@select="togglePhotoSelection"
/>
</template>
</div>
@@ -293,13 +311,66 @@
</NcButton>
</template>
</NcDialog>
<!-- Bulk delete photos confirm -->
<NcDialog
v-if="bulkDeletingPhotos"
:name="strings.deletePhotoTitle"
:open="bulkDeletingPhotos"
close-on-click-outside
@update:open="(v) => !v && (bulkDeletingPhotos = false)"
>
<p>{{ bulkDeletePhotosBody }}</p>
<template #actions>
<NcButton @click="bulkDeletingPhotos = false">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="submitBulkDeletePhotos">{{ strings.delete }}</NcButton>
</template>
</NcDialog>
<!-- Move to folder dialog -->
<NcDialog
v-if="showMoveToFolderDialog"
:name="strings.moveToFolderTitle"
:open="showMoveToFolderDialog"
close-on-click-outside
@update:open="(v) => !v && (showMoveToFolderDialog = false)"
>
<div class="pantry-move-folder-list">
<NcButton v-if="activeFolderId" wide @click="submitMoveToFolder(0)">
<template #icon><ImageIcon :size="20" /></template>
{{ strings.board }}
</NcButton>
<NcButton
v-for="f in folders"
:key="f.id"
wide
:disabled="f.id === activeFolderId"
@click="submitMoveToFolder(f.id)"
>
<template #icon><FolderIcon :size="20" /></template>
{{ f.name }}
</NcButton>
<NcButton wide @click="createFolderAndMove">
<template #icon><FolderPlusIcon :size="20" /></template>
{{ strings.newFolder }}
</NcButton>
</div>
</NcDialog>
<!-- Create folder for move -->
<FolderDialog
v-if="creatingFolderForMove"
:open="creatingFolderForMove"
@update:open="(v) => !v && (creatingFolderForMove = false)"
@save="submitCreateFolderAndMove"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import { n, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
@@ -316,6 +387,9 @@ import { PhotoCard, FolderStack, FolderDialog, PhotoPreview } from '@/components
import UploadIcon from '@icons/Upload.vue'
import ImageIcon from '@icons/Image.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import DeleteIcon from '@icons/Delete.vue'
import FolderIcon from '@icons/Folder.vue'
import FolderMoveIcon from '@icons/FolderMove.vue'
import FolderPlusIcon from '@icons/FolderPlus.vue'
import SortIcon from '@icons/Sort.vue'
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
@@ -758,6 +832,75 @@ async function submitDeleteFolder() {
deletingFolder.value = null
}
// ----- Selection -----
const selectedPhotoIds = ref(new Set<number>())
const photoSelectionLabel = computed(() =>
n('pantry', '%n photo selected', '%n photos selected', selectedPhotoIds.value.size),
)
function togglePhotoSelection(photoId: number) {
const next = new Set(selectedPhotoIds.value)
if (next.has(photoId)) {
next.delete(photoId)
} else {
next.add(photoId)
}
selectedPhotoIds.value = next
}
// Bulk delete
const bulkDeletingPhotos = ref(false)
const bulkDeletePhotosBody = computed(() =>
n(
'pantry',
'Are you sure you want to delete %n photo? The file will also be removed.',
'Are you sure you want to delete %n photos? The files will also be removed.',
selectedPhotoIds.value.size,
),
)
function confirmBulkDeletePhotos() {
bulkDeletingPhotos.value = true
}
async function submitBulkDeletePhotos() {
const ids = [...selectedPhotoIds.value]
for (const id of ids) {
await removePhoto(id)
}
selectedPhotoIds.value = new Set()
bulkDeletingPhotos.value = false
}
// Move to folder
const showMoveToFolderDialog = ref(false)
const creatingFolderForMove = ref(false)
async function submitMoveToFolder(folderId: number) {
const ids = [...selectedPhotoIds.value]
for (const id of ids) {
await updatePhoto(id, { folderId })
}
selectedPhotoIds.value = new Set()
showMoveToFolderDialog.value = false
}
function createFolderAndMove() {
showMoveToFolderDialog.value = false
creatingFolderForMove.value = true
}
async function submitCreateFolderAndMove(name: string) {
const folder = await createFolder(name)
creatingFolderForMove.value = false
const ids = [...selectedPhotoIds.value]
for (const id of ids) {
await updatePhoto(id, { folderId: folder.id })
}
selectedPhotoIds.value = new Set()
}
const strings = {
title: t('pantry', 'Photo board'),
upload: t('pantry', 'Upload'),
@@ -783,6 +926,10 @@ const strings = {
deleteFolderDeleteHint: t('pantry', 'All photos and their files will be permanently deleted.'),
sortLabel: t('pantry', 'Sort order'),
foldersFirst: t('pantry', 'Folders first'),
clearSelection: t('pantry', 'Clear selection'),
moveToFolder: t('pantry', 'Move to folder'),
moveToFolderTitle: t('pantry', 'Move to folder'),
board: t('pantry', 'Board (root)'),
}
</script>
@@ -868,6 +1015,28 @@ const strings = {
pointer-events: none;
}
.pantry-selection-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
margin: 0 1rem 0.75rem;
background: var(--color-primary-element-light);
border-radius: var(--border-radius-large, 12px);
font-weight: 500;
span:first-child {
flex: 1;
}
}
.pantry-move-folder-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0;
}
.pantry-sort-active {
font-weight: 600;
}