Files
people/src/components/CollectionEditor.vue
2026-02-25 00:17:21 -05:00

296 lines
8.3 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@PeopleManager/stores/servicesStore'
import { CollectionObject } from '@PeopleManager/models/collection'
import type { ServiceObject } from '@PeopleManager/models/service'
// Store
const servicesStore = useServicesStore()
// Props
const props = defineProps<{
modelValue: boolean
collection: CollectionObject | null
mode: 'create' | 'edit'
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'save': [collection: CollectionObject, service: ServiceObject]
'delete': [collection: CollectionObject]
}>()
// State
const services = ref<ServiceObject[]>([])
const editingCollection = ref<CollectionObject | null>(null)
const editingCollectionService = ref<ServiceObject | null>(null)
const editingCollectionValidated = ref(false)
const colorMenuOpen = ref(false)
const colorOptions = [
'#FF6B6B', '#FF8E4D', '#FFB347', '#FFD166', '#E5FF7F', '#B5E86D',
'#7CD992', '#46CBB7', '#2ECCCB', '#27B1E6', '#2196F3', '#2E6FE3',
'#3A4CC9', '#5A3FC0', '#7F3FBF', '#B339AE', '#DE3A9B', '#FF6FB7'
]
const COLORS_PER_ROW = 6
const colorRows = computed(() => {
const rows: string[][] = []
for (let i = 0; i < colorOptions.length; i += COLORS_PER_ROW) {
rows.push(colorOptions.slice(i, i + COLORS_PER_ROW))
}
return rows
})
// Computed
const dialogOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
// Functions
const onOpen = async () => {
if (services.value.length === 0) {
services.value = Object.values(await servicesStore.list())
}
if (!props.collection) {
return
}
// Clone the collection to avoid mutating the original
editingCollection.value = props.collection.clone()
if (props.collection.identifier) {
// Edit mode - find the service
editingCollectionService.value = services.value.find(s =>
s.provider === props.collection!.provider && s.identifier === props.collection!.service
) || null
} else {
// Create mode - use first service that can create
editingCollectionService.value = services.value.filter(s => s.capable('CollectionCreate'))[0] || null
}
}
const onSave = () => {
if (!editingCollection.value || !editingCollectionService.value) {
return
}
emit('save', editingCollection.value, editingCollectionService.value)
dialogOpen.value = false
}
const onDelete = () => {
if (!editingCollection.value) {
return
}
emit('delete', editingCollection.value)
dialogOpen.value = false
}
const onColorSelect = (color: string | null, closeMenu = true) => {
if (!editingCollection.value) {
return
}
editingCollection.value.properties.color = color
if (closeMenu) {
colorMenuOpen.value = false
}
}
const onCustomColorChange = (color: string) => {
if (!color) {
return
}
onColorSelect(color, false)
}
// Watch for dialog opening
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
await onOpen()
}
})
</script>
<template>
<v-dialog
v-model="dialogOpen"
max-width="500"
persistent
>
<v-card>
<v-card-title>
<v-icon icon="mdi-book-plus" class="mr-2" />
{{ mode === 'create' ? 'Create New Address Book' : 'Edit Address Book' }}
</v-card-title>
<v-divider />
<v-card-text v-if="editingCollection" class="pt-4">
<v-form v-model="editingCollectionValidated" ref="collectionEditor">
<v-combobox v-show="mode === 'create' && services.length > 1"
v-model="editingCollectionService"
label="Service"
:items="services.filter(s => s.capable('CollectionCreate'))"
item-title="label"
item-value="identifier"
required
:rules="[(v: ServiceObject) => !!v || 'Service is required']"
/>
<div class="mb-4"><strong>Service </strong> {{ editingCollection.service }}</div>
<v-text-field
v-model="editingCollection.properties.label"
label="Label"
required
:rules="[(v: string) => !!v || 'Label is required']"
>
<template #append-inner>
<v-menu
v-model="colorMenuOpen"
:close-on-content-click="false"
location="bottom end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon
variant="text"
size="small"
:style="{ color: editingCollection.properties.color || 'var(--v-theme-on-surface)' }"
aria-label="Select book color"
title="Select color"
>
<v-icon icon="mdi-palette" />
</v-btn>
</template>
<v-card class="pa-2 color-menu" elevation="4">
<div class="color-menu-header">
<v-btn
variant="text"
size="small"
class="color-menu-header__close"
aria-label="Close"
title="Close"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
<div class="color-menu-body__presets">
<div
v-for="(rowColors, rowIndex) in colorRows"
:key="`color-row-${rowIndex}`"
class="color-menu-body__row"
>
<v-btn
v-for="color in rowColors"
:key="color"
variant="flat"
size="small"
class="color-menu-body__presets--swatch"
:class="{ 'color-menu-body__presets--swatch--active': editingCollection.properties.color === color }"
:style="{ backgroundColor: color }"
@click="onColorSelect(color)">
<v-icon
v-if="editingCollection.properties.color === color"
icon="mdi-check"
size="x-small"
color="white"
/>
</v-btn>
</div>
</div>
<div class="color-menu-body__picker">
<v-color-picker
v-model="editingCollection.properties.color"
mode="hex"
hide-canvas
width="100%"
elevation="0"
@update:model-value="onCustomColorChange"
/>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
<v-textarea
v-model="editingCollection.properties.description"
label="Description"
rows="2"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="justify-space-between align-center">
<div>
<v-btn
v-if="mode === 'edit' && editingCollectionService?.capable('CollectionDestroy')"
color="error"
variant="text"
@click="onDelete"
>
Delete
</v-btn>
</div>
<div class="d-flex align-center">
<v-btn
variant="text"
@click="dialogOpen = false"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="!editingCollectionValidated"
@click="onSave"
>
{{ mode === 'create' ? 'Create' : 'Save' }}
</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.color-menu {
max-width: 320px;
}
.color-menu-header {
display: flex;
justify-content: flex-end;
width: 100%;
margin-bottom: 16px;
}
.color-menu-header__close {
margin-left: auto;
}
.color-menu-body__presets {
width: 100%;
margin-bottom: 20px;
}
.color-menu-body__row {
display: flex;
gap: 8px;
}
.color-menu-body__picker {
margin-top: 20px;
}
</style>