feat: manage categories button/modal

This commit is contained in:
2026-04-10 19:31:43 +03:00
parent a4b93cb768
commit 26ef3dd888
9 changed files with 509 additions and 640 deletions

View File

@@ -12,7 +12,7 @@ module.exports = [
...tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended),
{
rules: {
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },

View File

@@ -0,0 +1,206 @@
<template>
<NcDialog
:name="dialogName"
:open="open"
close-on-click-outside
@update:open="$emit('update:open', $event)"
>
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submit">
<NcTextField
v-model="nameValue"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
autocomplete="off"
/>
<div>
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
<div class="pantry-cat-form__icon-grid">
<button
v-for="opt in CATEGORY_ICONS"
:key="opt.key"
type="button"
class="pantry-cat-form__icon-button"
:class="{ 'pantry-cat-form__icon-button--active': iconValue === opt.key }"
:title="opt.label"
:style="{ color: colorValue }"
@click="iconValue = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
<div>
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
<div class="pantry-cat-form__color-grid">
<button
v-for="c in CATEGORY_COLORS"
:key="c"
type="button"
class="pantry-cat-form__color-swatch"
:class="{ 'pantry-cat-form__color-swatch--active': colorValue === c }"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="colorValue = c"
/>
</div>
</div>
<p v-if="error" class="pantry-cat-form__error">{{ error }}</p>
</form>
<template #actions>
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="saving || !nameValue.trim()" @click="submit">
{{ saving ? strings.saving : category ? strings.save : strings.create }}
</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import type { Category } from '@/api/types'
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
DEFAULT_CATEGORY_ICON_KEY,
} from '@/components/CategoryPicker/categoryIcons'
const props = defineProps<{
open: boolean
/** Existing category to edit, or null/undefined to create a new one. */
category?: Category | null
saving?: boolean
error?: string | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
save: [data: { name: string; icon: string; color: string }]
}>()
const nameValue = ref('')
const iconValue = ref<string>(DEFAULT_CATEGORY_ICON_KEY)
const colorValue = ref<string>(CATEGORY_COLORS[3]!)
watch(
() => props.open,
(isOpen) => {
if (isOpen) {
if (props.category) {
nameValue.value = props.category.name
iconValue.value = props.category.icon
colorValue.value = props.category.color
} else {
nameValue.value = ''
iconValue.value = DEFAULT_CATEGORY_ICON_KEY
colorValue.value = CATEGORY_COLORS[3]!
}
}
},
{ immediate: true },
)
const dialogName = computed(() => (props.category ? strings.editTitle : strings.createTitle))
function submit() {
const name = nameValue.value.trim()
if (!name) return
emit('save', { name, icon: iconValue.value, color: colorValue.value })
}
const strings = {
createTitle: t('pantry', 'New category'),
editTitle: t('pantry', 'Edit category'),
nameLabel: t('pantry', 'Name'),
namePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
iconLabel: t('pantry', 'Icon:'),
colorLabel: t('pantry', 'Color:'),
cancel: t('pantry', 'Cancel'),
create: t('pantry', 'Create'),
save: t('pantry', 'Save'),
saving: t('pantry', 'Saving …'),
}
</script>
<style scoped lang="scss">
.pantry-cat-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
min-width: 340px;
&__sub {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
margin-bottom: 0.35rem;
}
&__icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
gap: 0.35rem;
}
&__icon-button {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
border-color: currentColor;
box-shadow: 0 0 0 2px currentColor;
}
}
&__color-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
&__color-swatch {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: scale(1.08);
}
&--active {
border-color: var(--color-main-text);
transform: scale(1.1);
}
}
&__error {
color: var(--color-error);
margin: 0;
}
}
@media (max-width: 500px) {
.pantry-cat-form {
min-width: 0;
}
}
</style>

View File

@@ -0,0 +1,226 @@
<template>
<NcDialog
:name="strings.title"
:open="open"
size="normal"
close-on-click-outside
@update:open="$emit('update:open', $event)"
>
<div v-if="catLoading" class="pantry-center">
<NcLoadingIcon :size="28" />
</div>
<template v-else>
<p v-if="catItems.length === 0" class="pantry-cat-hint">
{{ strings.noCategoriesHint }}
</p>
<ul v-else class="pantry-cat-list">
<li v-for="cat in catItems" :key="cat.id" class="pantry-cat-list__item">
<span class="pantry-cat-list__icon" :style="{ color: cat.color }">
<component :is="categoryIconComponent(cat.icon)" :size="20" />
</span>
<span class="pantry-cat-list__name">{{ cat.name }}</span>
<div class="pantry-cat-list__actions">
<NcButton
variant="tertiary"
:aria-label="strings.editCategory"
@click="startEditCat(cat)"
>
<template #icon><PencilIcon :size="18" /></template>
</NcButton>
<NcButton
variant="tertiary"
:aria-label="strings.deleteCategory"
@click="confirmDeleteCat(cat)"
>
<template #icon><DeleteIcon :size="18" /></template>
</NcButton>
</div>
</li>
</ul>
</template>
<template #actions>
<NcButton variant="primary" @click="openCreateCat">
<template #icon><PlusIcon :size="20" /></template>
{{ strings.newCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Create/edit form -->
<CategoryFormDialog
:open="showForm"
:category="editingCat"
:saving="catSaving"
:error="catError"
@update:open="closeForm"
@save="submitForm"
/>
<!-- Delete category confirm -->
<NcDialog
v-if="deletingCat"
:name="strings.deleteCategoryTitle"
:open="!!deletingCat"
close-on-click-outside
@update:open="(v) => !v && (deletingCat = null)"
>
<p>{{ deleteCatConfirmBody }}</p>
<template #actions>
<NcButton @click="deletingCat = null">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="submitDeleteCat">{{ strings.delete }}</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import PlusIcon from '@icons/Plus.vue'
import DeleteIcon from '@icons/Delete.vue'
import PencilIcon from '@icons/Pencil.vue'
import type { Category } from '@/api/types'
import { useCategories } from '@/composables/useCategories'
import { categoryIconComponent } from '@/components/CategoryPicker/categoryIcons'
import CategoryFormDialog from './CategoryFormDialog.vue'
const props = defineProps<{ open: boolean; houseId: number }>()
defineEmits<{ 'update:open': [value: boolean] }>()
const categories = useCategories(props.houseId)
const catItems = computed(() => categories.items.value)
const catLoading = computed(() => categories.loading.value)
watch(
() => props.open,
(isOpen) => {
if (isOpen) categories.load()
},
{ immediate: true },
)
// -------- Form state --------
const showForm = ref(false)
const editingCat = ref<Category | null>(null)
const deletingCat = ref<Category | null>(null)
const catSaving = ref(false)
const catError = ref<string | null>(null)
function openCreateCat() {
editingCat.value = null
catError.value = null
showForm.value = true
}
function startEditCat(cat: Category) {
editingCat.value = cat
catError.value = null
showForm.value = true
}
function closeForm(v: boolean) {
if (!v) {
showForm.value = false
editingCat.value = null
}
}
function confirmDeleteCat(cat: Category) {
deletingCat.value = cat
}
const deleteCatConfirmBody = computed(() =>
t('pantry', 'Are you sure you want to delete the category "{name}"?', {
name: deletingCat.value?.name ?? '',
}),
)
async function submitForm(data: { name: string; icon: string; color: string }) {
catSaving.value = true
catError.value = null
try {
if (editingCat.value) {
await categories.update(editingCat.value.id, data)
} else {
await categories.create(data)
}
showForm.value = false
editingCat.value = null
} catch (e) {
catError.value =
(e as Error).message ||
(editingCat.value
? t('pantry', 'Could not update category.')
: t('pantry', 'Could not create category.'))
} finally {
catSaving.value = false
}
}
async function submitDeleteCat() {
const target = deletingCat.value
if (!target) return
await categories.remove(target.id)
deletingCat.value = null
}
const strings = {
title: t('pantry', 'Manage categories'),
noCategoriesHint: t('pantry', 'No categories yet. Categories help organize checklist items.'),
newCategory: t('pantry', 'New category'),
cancel: t('pantry', 'Cancel'),
delete: t('pantry', 'Delete'),
editCategory: t('pantry', 'Edit'),
deleteCategory: t('pantry', 'Delete'),
deleteCategoryTitle: t('pantry', 'Delete category'),
}
</script>
<style scoped lang="scss">
.pantry-center {
display: flex;
justify-content: center;
padding: 1rem;
}
.pantry-cat-hint {
color: var(--color-text-maxcontrast);
margin: 0 0 0.75rem 0;
}
.pantry-cat-list {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 6px 0;
border-bottom: 1px solid var(--color-border);
}
&__icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
gap: 0;
flex-shrink: 0;
}
}
</style>

View File

@@ -0,0 +1,2 @@
export { default as CategoryManagerDialog } from './CategoryManagerDialog.vue'
export { default as CategoryFormDialog } from './CategoryFormDialog.vue'

View File

@@ -60,6 +60,17 @@ vi.mock('@nextcloud/vue/components/NcButton', () => ({
props: ['variant', 'disabled', 'type'],
},
}))
// Mock the shared CategoryFormDialog with a minimal stub — tests verify
// interaction via its props/events, not its internal markup.
vi.mock('@/components/CategoryManager/CategoryFormDialog.vue', () => ({
default: {
name: 'CategoryFormDialog',
template:
'<div v-if="open" class="mock-cat-form-dialog"><slot /><span class="error" v-if="error">{{ error }}</span></div>',
props: ['open', 'category', 'saving', 'error'],
emits: ['update:open', 'save'],
},
}))
// Mock useCategories composable
const mockItems = ref<Category[]>([])
@@ -223,63 +234,17 @@ describe('CategoryPicker', () => {
global: {},
})
// Dialog should not be visible initially
expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(false)
// Simulate selecting the create option
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
expect(wrapper.findComponent({ name: 'NcDialog' }).exists()).toBe(true)
})
it('create form has name field, icon grid, and color swatches', async () => {
const wrapper = mount(CategoryPicker, {
props: { houseId: 10, modelValue: null },
global: {},
})
// Open the create dialog
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
// Name text field
expect(wrapper.findComponent({ name: 'NcTextField' }).exists()).toBe(true)
// Icon grid
const iconGrid = wrapper.find('.pantry-create-cat__icon-grid')
expect(iconGrid.exists()).toBe(true)
const iconButtons = wrapper.findAll('.pantry-create-cat__icon-button')
expect(iconButtons.length).toBe(19) // 19 icons in CATEGORY_ICONS
// Color swatches
const colorGrid = wrapper.find('.pantry-create-cat__color-grid')
expect(colorGrid.exists()).toBe(true)
const colorSwatches = wrapper.findAll('.pantry-create-cat__color-swatch')
expect(colorSwatches.length).toBe(10) // 10 colors in CATEGORY_COLORS
})
it('shows create and cancel buttons in dialog actions', async () => {
const wrapper = mount(CategoryPicker, {
props: { houseId: 10, modelValue: null },
global: {},
})
// Dialog not visible initially
expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('open')).toBe(false)
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
const buttons = wrapper.findAllComponents({ name: 'NcButton' })
// Cancel button + Create button (inside dialog actions)
expect(buttons.length).toBeGreaterThanOrEqual(2)
const buttonTexts = buttons.map((b) => b.text())
expect(buttonTexts).toContain('Cancel')
expect(buttonTexts).toContain('Create')
expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('open')).toBe(true)
})
it('emits update:modelValue with created category id after creation', async () => {
it('emits update:modelValue with created category id after save event', async () => {
const createdCategory = makeCategory({ id: 42, name: 'New Cat' })
mockCreate.mockResolvedValueOnce(createdCategory)
@@ -288,21 +253,15 @@ describe('CategoryPicker', () => {
global: {},
})
// Open create dialog
// Open dialog
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
// Set name via the component's internal state
const vm = wrapper.vm as unknown as { newName: string }
vm.newName = 'New Cat'
await wrapper.vm.$nextTick()
// Trigger save from the CategoryFormDialog
const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' })
formDialog.vm.$emit('save', { name: 'New Cat', icon: 'tag', color: '#ef4444' })
// Submit the form
const form = wrapper.find('.pantry-create-cat')
await form.trigger('submit')
// Wait for async create to resolve
await vi.waitFor(() => {
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
})
@@ -311,7 +270,7 @@ describe('CategoryPicker', () => {
expect(emitted[emitted.length - 1]).toEqual([42])
})
it('calls create with name, icon, and color', async () => {
it('calls create with name, icon, and color from the save event', async () => {
const createdCategory = makeCategory({ id: 50, name: 'Snacks' })
mockCreate.mockResolvedValueOnce(createdCategory)
@@ -320,21 +279,12 @@ describe('CategoryPicker', () => {
global: {},
})
// Open create dialog
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
// Set the name
const vm = wrapper.vm as unknown as { newName: string; newIcon: string; newColor: string }
vm.newName = 'Snacks'
vm.newIcon = 'snacks'
vm.newColor = '#ef4444'
await wrapper.vm.$nextTick()
// Submit
const form = wrapper.find('.pantry-create-cat')
await form.trigger('submit')
const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' })
formDialog.vm.$emit('save', { name: 'Snacks', icon: 'snacks', color: '#ef4444' })
await vi.waitFor(() => {
expect(mockCreate).toHaveBeenCalled()
@@ -347,7 +297,7 @@ describe('CategoryPicker', () => {
})
})
it('shows error when create fails', async () => {
it('passes error prop to CategoryFormDialog when create fails', async () => {
mockCreate.mockRejectedValueOnce(new Error('Server error'))
const wrapper = mount(CategoryPicker, {
@@ -355,23 +305,18 @@ describe('CategoryPicker', () => {
global: {},
})
// Open create dialog
const select = wrapper.findComponent({ name: 'NcSelect' })
select.vm.$emit('option:selected', { label: 'Create new category …', create: true })
await wrapper.vm.$nextTick()
const vm = wrapper.vm as unknown as { newName: string }
vm.newName = 'Test'
await wrapper.vm.$nextTick()
const form = wrapper.find('.pantry-create-cat')
await form.trigger('submit')
const formDialog = wrapper.findComponent({ name: 'CategoryFormDialog' })
formDialog.vm.$emit('save', { name: 'Test', icon: 'tag', color: '#ef4444' })
await vi.waitFor(() => {
expect(wrapper.find('.pantry-create-cat__error').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'CategoryFormDialog' }).props('error')).toBe(
'Server error',
)
})
expect(wrapper.find('.pantry-create-cat__error').text()).toBe('Server error')
})
})

View File

@@ -43,64 +43,13 @@
</template>
</NcSelect>
<NcDialog
v-if="showCreate"
:name="strings.createTitle"
<CategoryFormDialog
:open="showCreate"
close-on-click-outside
:saving="saving"
:error="createError"
@update:open="showCreate = $event"
>
<form class="pantry-create-cat" autocomplete="off" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
autocomplete="off"
/>
<div>
<label class="pantry-create-cat__sub">{{ strings.iconLabel }}</label>
<div class="pantry-create-cat__icon-grid">
<button
v-for="opt in CATEGORY_ICONS"
:key="opt.key"
type="button"
class="pantry-create-cat__icon-button"
:class="{ 'pantry-create-cat__icon-button--active': newIcon === opt.key }"
:title="opt.label"
:style="{ color: newColor }"
@click="newIcon = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
<div>
<label class="pantry-create-cat__sub">{{ strings.colorLabel }}</label>
<div class="pantry-create-cat__color-grid">
<button
v-for="c in CATEGORY_COLORS"
:key="c"
type="button"
class="pantry-create-cat__color-swatch"
:class="{ 'pantry-create-cat__color-swatch--active': newColor === c }"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="newColor = c"
/>
</div>
</div>
<p v-if="createError" class="pantry-create-cat__error">{{ createError }}</p>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="saving || !newName.trim()" @click="submitCreate">
{{ saving ? strings.saving : strings.create }}
</NcButton>
</template>
</NcDialog>
@save="submitCreate"
/>
</div>
</template>
@@ -108,17 +57,10 @@
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcButton from '@nextcloud/vue/components/NcButton'
import PlusIcon from '@icons/Plus.vue'
import { useCategories } from '@/composables/useCategories'
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
DEFAULT_CATEGORY_ICON_KEY,
categoryIconComponent,
} from './categoryIcons'
import { categoryIconComponent } from './categoryIcons'
import CategoryFormDialog from '@/components/CategoryManager/CategoryFormDialog.vue'
import type { Category } from '@/api/types'
const props = defineProps<{
@@ -190,33 +132,21 @@ function onSelect(opt: SelectOption | SelectOption[] | null): void {
// ----- create dialog -----
const showCreate = ref(false)
const newName = ref('')
const newIcon = ref<string>(DEFAULT_CATEGORY_ICON_KEY)
const newColor = ref<string>(CATEGORY_COLORS[3]!)
const saving = ref(false)
const createError = ref<string | null>(null)
function openCreate() {
// Reset the NcSelect so it doesn't stay on the "Create new …" ghost option.
selected.value = selected.value?.category ? selected.value : null
newName.value = ''
newIcon.value = DEFAULT_CATEGORY_ICON_KEY
newColor.value = CATEGORY_COLORS[3]!
createError.value = null
showCreate.value = true
}
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
async function submitCreate(data: { name: string; icon: string; color: string }) {
saving.value = true
createError.value = null
try {
const created = await create({
name,
icon: newIcon.value,
color: newColor.value,
})
const created = await create(data)
emit('update:modelValue', created.id)
showCreate.value = false
} catch (e) {
@@ -232,14 +162,6 @@ function iconFor(key: string) {
const strings = {
placeholder: t('pantry', 'Pick a category'),
createTitle: t('pantry', 'New category'),
nameLabel: t('pantry', 'Name'),
namePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
iconLabel: t('pantry', 'Icon'),
colorLabel: t('pantry', 'Color'),
create: t('pantry', 'Create'),
saving: t('pantry', 'Saving …'),
cancel: t('pantry', 'Cancel'),
}
</script>
@@ -279,82 +201,4 @@ const strings = {
text-overflow: ellipsis;
}
}
.pantry-create-cat {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
min-width: 340px;
&__sub {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
margin-bottom: 0.35rem;
}
&__icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
gap: 0.35rem;
}
&__icon-button {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
border-color: currentColor;
box-shadow: 0 0 0 2px currentColor;
}
}
&__color-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
&__color-swatch {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: scale(1.08);
}
&--active {
border-color: var(--color-main-text);
transform: scale(1.1);
}
}
&__error {
margin: 0;
color: var(--color-error);
}
}
@media (max-width: 500px) {
.pantry-create-cat {
min-width: 0;
}
}
</style>

View File

@@ -92,47 +92,6 @@
</template>
</NcAppSettingsSection>
<NcAppSettingsSection id="house-categories" :name="strings.categoriesSection">
<div v-if="catLoading" class="pantry-center">
<NcLoadingIcon :size="28" />
</div>
<template v-else>
<p v-if="catItems.length === 0" class="pantry-hint">
{{ strings.noCategoriesHint }}
</p>
<ul v-else class="pantry-cat-list">
<li v-for="cat in catItems" :key="cat.id" class="pantry-cat-list__item">
<span class="pantry-cat-list__icon" :style="{ color: cat.color }">
<component :is="categoryIconComponent(cat.icon)" :size="20" />
</span>
<span class="pantry-cat-list__name">{{ cat.name }}</span>
<div class="pantry-cat-list__actions">
<NcButton
variant="tertiary"
:aria-label="strings.editCategory"
@click="startEditCat(cat)"
>
<template #icon><PencilIcon :size="18" /></template>
</NcButton>
<NcButton
variant="tertiary"
:aria-label="strings.deleteCategory"
@click="confirmDeleteCat(cat)"
>
<template #icon><DeleteIcon :size="18" /></template>
</NcButton>
</div>
</li>
</ul>
<div class="pantry-cat-add">
<NcButton variant="primary" @click="openCreateCat">
<template #icon><PlusIcon :size="20" /></template>
{{ strings.newCategory }}
</NcButton>
</div>
</template>
</NcAppSettingsSection>
<NcAppSettingsSection v-if="isOwner" id="house-danger" :name="strings.dangerSection">
<p class="pantry-danger-hint">{{ strings.dangerBody }}</p>
<NcButton variant="error" @click="confirmingDelete = true">
@@ -184,132 +143,6 @@
<NcButton variant="error" @click="deleteHouse">{{ strings.deleteButton }}</NcButton>
</template>
</NcDialog>
<NcDialog
v-if="showCreateCat"
:name="strings.createCategoryTitle"
:open="showCreateCat"
close-on-click-outside
@update:open="showCreateCat = $event"
>
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submitCreateCat">
<NcTextField
v-model="catName"
:label="strings.catNameLabel"
:placeholder="strings.catNamePlaceholder"
autocomplete="off"
/>
<div>
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
<div class="pantry-cat-form__icon-grid">
<button
v-for="opt in CATEGORY_ICONS"
:key="opt.key"
type="button"
class="pantry-cat-form__icon-button"
:class="{ 'pantry-cat-form__icon-button--active': catIcon === opt.key }"
:title="opt.label"
:style="{ color: catColor }"
@click="catIcon = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
<div>
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
<div class="pantry-cat-form__color-grid">
<button
v-for="c in CATEGORY_COLORS"
:key="c"
type="button"
class="pantry-cat-form__color-swatch"
:class="{ 'pantry-cat-form__color-swatch--active': catColor === c }"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="catColor = c"
/>
</div>
</div>
<p v-if="catError" class="pantry-form-error">{{ catError }}</p>
</form>
<template #actions>
<NcButton @click="showCreateCat = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="catSaving || !catName.trim()" @click="submitCreateCat">
{{ catSaving ? strings.saving : strings.createCategory }}
</NcButton>
</template>
</NcDialog>
<NcDialog
v-if="editingCat"
:name="strings.editCategoryTitle"
:open="!!editingCat"
close-on-click-outside
@update:open="(v) => !v && (editingCat = null)"
>
<form class="pantry-cat-form" autocomplete="off" @submit.prevent="submitEditCat">
<NcTextField
v-model="catName"
:label="strings.catNameLabel"
:placeholder="strings.catNamePlaceholder"
autocomplete="off"
/>
<div>
<label class="pantry-cat-form__sub">{{ strings.iconLabel }}</label>
<div class="pantry-cat-form__icon-grid">
<button
v-for="opt in CATEGORY_ICONS"
:key="opt.key"
type="button"
class="pantry-cat-form__icon-button"
:class="{ 'pantry-cat-form__icon-button--active': catIcon === opt.key }"
:title="opt.label"
:style="{ color: catColor }"
@click="catIcon = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
<div>
<label class="pantry-cat-form__sub">{{ strings.colorLabel }}</label>
<div class="pantry-cat-form__color-grid">
<button
v-for="c in CATEGORY_COLORS"
:key="c"
type="button"
class="pantry-cat-form__color-swatch"
:class="{ 'pantry-cat-form__color-swatch--active': catColor === c }"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="catColor = c"
/>
</div>
</div>
<p v-if="catError" class="pantry-form-error">{{ catError }}</p>
</form>
<template #actions>
<NcButton @click="editingCat = null">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="catSaving || !catName.trim()" @click="submitEditCat">
{{ catSaving ? strings.saving : strings.save }}
</NcButton>
</template>
</NcDialog>
<NcDialog
v-if="deletingCat"
:name="strings.deleteCategoryTitle"
:open="!!deletingCat"
close-on-click-outside
@update:open="(v) => !v && (deletingCat = null)"
>
<p>{{ deleteCatConfirmBody }}</p>
<template #actions>
<NcButton @click="deletingCat = null">{{ strings.cancel }}</NcButton>
<NcButton variant="error" @click="submitDeleteCat">{{ strings.deleteCategory }}</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
@@ -326,18 +159,10 @@ import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import PlusIcon from '@icons/Plus.vue'
import DeleteIcon from '@icons/Delete.vue'
import PencilIcon from '@icons/Pencil.vue'
import * as houseApi from '@/api/houses'
import type { Category, HouseMember, HouseRole } from '@/api/types'
import type { HouseMember, HouseRole } from '@/api/types'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
import { useHouses } from '@/composables/useHouses'
import { useCategories } from '@/composables/useCategories'
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
DEFAULT_CATEGORY_ICON_KEY,
categoryIconComponent,
} from '@/components/CategoryPicker/categoryIcons'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
@@ -366,7 +191,6 @@ watch(
if (isOpen) {
syncFromHouse()
void loadMembers()
void loadCategories()
}
},
)
@@ -455,96 +279,6 @@ async function leave() {
await router.push({ name: 'home' })
}
// -------- Categories --------
const catComposable = computed(() => {
const id = houseIdNum.value
return id !== null ? useCategories(id) : null
})
const catItems = computed(() => catComposable.value?.items.value ?? [])
const catLoading = computed(() => catComposable.value?.loading.value ?? false)
async function loadCategories() {
await catComposable.value?.load(true)
}
const showCreateCat = ref(false)
const editingCat = ref<Category | null>(null)
const deletingCat = ref<Category | null>(null)
const catName = ref('')
const catIcon = ref(DEFAULT_CATEGORY_ICON_KEY)
const catColor = ref(CATEGORY_COLORS[3]!)
const catSaving = ref(false)
const catError = ref<string | null>(null)
function openCreateCat() {
catName.value = ''
catIcon.value = DEFAULT_CATEGORY_ICON_KEY
catColor.value = CATEGORY_COLORS[3]!
catError.value = null
showCreateCat.value = true
}
function startEditCat(cat: Category) {
editingCat.value = cat
catName.value = cat.name
catIcon.value = cat.icon
catColor.value = cat.color
catError.value = null
}
function confirmDeleteCat(cat: Category) {
deletingCat.value = cat
}
const deleteCatConfirmBody = computed(() =>
t('pantry', 'Are you sure you want to delete the category "{name}"?', {
name: deletingCat.value?.name ?? '',
}),
)
async function submitCreateCat() {
const name = catName.value.trim()
if (!name) return
catSaving.value = true
catError.value = null
try {
await catComposable.value?.create({ name, icon: catIcon.value, color: catColor.value })
showCreateCat.value = false
} catch (e) {
catError.value = (e as Error).message || t('pantry', 'Could not create category.')
} finally {
catSaving.value = false
}
}
async function submitEditCat() {
const target = editingCat.value
if (!target) return
const name = catName.value.trim()
if (!name) return
catSaving.value = true
catError.value = null
try {
await catComposable.value?.update(target.id, {
name,
icon: catIcon.value,
color: catColor.value,
})
editingCat.value = null
} catch (e) {
catError.value = (e as Error).message || t('pantry', 'Could not update category.')
} finally {
catSaving.value = false
}
}
async function submitDeleteCat() {
const target = deletingCat.value
if (!target) return
await catComposable.value?.remove(target.id)
deletingCat.value = null
}
// -------- Danger zone --------
const confirmingDelete = ref(false)
@@ -601,19 +335,6 @@ const strings = {
userIdLabel: t('pantry', 'Account ID'),
userIdPlaceholder: t('pantry', 'The Nextcloud username'),
roleLabel: t('pantry', 'Role'),
categoriesSection: t('pantry', 'Categories'),
noCategoriesHint: t('pantry', 'No categories yet. Categories help organize checklist items.'),
newCategory: t('pantry', 'New category'),
createCategory: t('pantry', 'Create'),
createCategoryTitle: t('pantry', 'New category'),
editCategory: t('pantry', 'Edit'),
editCategoryTitle: t('pantry', 'Edit category'),
deleteCategory: t('pantry', 'Delete'),
deleteCategoryTitle: t('pantry', 'Delete category'),
catNameLabel: t('pantry', 'Name'),
catNamePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
iconLabel: t('pantry', 'Icon:'),
colorLabel: t('pantry', 'Color:'),
dangerSection: t('pantry', 'Danger zone'),
dangerBody: t(
'pantry',
@@ -691,115 +412,4 @@ const strings = {
color: var(--color-text-maxcontrast);
margin: 0 0 0.75rem 0;
}
.pantry-cat-list {
list-style: none;
padding: 0;
margin: 0;
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 6px 0;
border-bottom: 1px solid var(--color-border);
}
&__icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
&__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
gap: 0;
flex-shrink: 0;
}
}
.pantry-cat-add {
margin-top: 1rem;
}
.pantry-cat-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
min-width: 340px;
&__sub {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
margin-bottom: 0.35rem;
}
&__icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
gap: 0.35rem;
}
&__icon-button {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
border-color: currentColor;
box-shadow: 0 0 0 2px currentColor;
}
}
&__color-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
&__color-swatch {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: scale(1.08);
}
&--active {
border-color: var(--color-main-text);
transform: scale(1.1);
}
}
}
@media (max-width: 500px) {
.pantry-cat-form {
min-width: 0;
}
}
</style>

View File

@@ -30,6 +30,12 @@
{{ opt.label }}
</NcActionButton>
</NcActions>
<NcButton variant="primary" @click="showCategoryManager = true">
<template #icon>
<TagIcon :size="20" />
</template>
{{ strings.manageCategories }}
</NcButton>
</template>
</PageToolbar>
@@ -123,6 +129,12 @@
:house-id="houseIdNum"
@update:open="(v) => !v && (previewing = null)"
/>
<CategoryManagerDialog
:open="showCategoryManager"
:house-id="houseIdNum"
@update:open="showCategoryManager = $event"
/>
</div>
</template>
@@ -138,12 +150,14 @@ import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import SortIcon from '@icons/Sort.vue'
import RadioboxBlankIcon from '@icons/RadioboxBlank.vue'
import RadioboxMarkedIcon from '@icons/RadioboxMarked.vue'
import TagIcon from '@icons/Tag.vue'
import PageToolbar from '@/components/PageToolbar'
import { ChecklistAddForm } from '@/components/ChecklistAddForm'
import { ChecklistItemRow } from '@/components/ChecklistItemRow'
import { ChecklistItemEditDialog } from '@/components/ChecklistItemEditDialog'
import { ChecklistItemViewDialog } from '@/components/ChecklistItemViewDialog'
import { ChecklistImagePreview } from '@/components/ChecklistImagePreview'
import { CategoryManagerDialog } from '@/components/CategoryManager'
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
import { useChecklistItems } from '@/composables/useChecklist'
import { useCategories } from '@/composables/useCategories'
@@ -419,12 +433,17 @@ function openPreview(item: ChecklistItem) {
previewing.value = item
}
// ----- Category manager -----
const showCategoryManager = ref(false)
const strings = {
back: t('pantry', 'Back to lists'),
emptyTitle: t('pantry', 'No items yet'),
emptyBody: t('pantry', 'Add items using the form above.'),
sortLabel: t('pantry', 'Sort order'),
doneTitle: t('pantry', 'Done'),
manageCategories: t('pantry', 'Manage categories'),
}
</script>

View File

@@ -2,6 +2,12 @@
<div class="pantry-lists">
<PageToolbar :title="strings.title">
<template #actions>
<NcButton variant="primary" @click="showCategoryManager = true">
<template #icon>
<TagIcon :size="20" />
</template>
{{ strings.manageCategories }}
</NcButton>
<NcButton variant="primary" @click="showCreate = true">
<template #icon>
<PlusIcon :size="20" />
@@ -177,6 +183,12 @@
<NcButton variant="error" @click="submitDelete">{{ strings.delete }}</NcButton>
</template>
</NcDialog>
<CategoryManagerDialog
:open="showCategoryManager"
:house-id="houseIdNum"
@update:open="showCategoryManager = $event"
/>
</div>
</template>
@@ -192,7 +204,9 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import PageToolbar from '@/components/PageToolbar'
import { CategoryManagerDialog } from '@/components/CategoryManager'
import PlusIcon from '@icons/Plus.vue'
import TagIcon from '@icons/Tag.vue'
import ClipboardCheckIcon from '@icons/ClipboardCheck.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
@@ -216,6 +230,8 @@ watch(
() => load(),
)
const showCategoryManager = ref(false)
const showCreate = ref(false)
const newName = ref('')
const newDescription = ref('')
@@ -283,6 +299,7 @@ async function submitDelete() {
const strings = {
title: t('pantry', 'Checklists'),
newList: t('pantry', 'New list'),
manageCategories: t('pantry', 'Manage categories'),
create: t('pantry', 'Create'),
save: t('pantry', 'Save'),
cancel: t('pantry', 'Cancel'),