mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: allow multiple selection for photos/notes
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user