feat: checklist category management

This commit is contained in:
2026-04-07 00:54:29 +03:00
parent f78ec11621
commit efdc763ba9

View File

@@ -91,6 +91,47 @@
</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">
@@ -137,6 +178,132 @@
<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">
@@ -153,10 +320,18 @@ 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 { HouseMember, HouseRole } from '@/api/types'
import type { Category, 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] }>()
@@ -185,6 +360,7 @@ watch(
if (isOpen) {
syncFromHouse()
void loadMembers()
void loadCategories()
}
},
)
@@ -273,6 +449,96 @@ 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)
@@ -325,6 +591,19 @@ 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',
@@ -397,4 +676,120 @@ const strings = {
color: var(--color-error);
margin: 0;
}
.pantry-hint {
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>