feat: homes dropup, db fix, composer fix

This commit is contained in:
2026-04-05 21:59:53 +03:00
parent 6c4d7e64a3
commit 6ba2999857
13 changed files with 982 additions and 489 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "nextcloud/pantry",
"description": "Nextcloud app for managing your household. Lists, photos and notes, all in one place.",
"name": "nextcloud/forum",
"description": "A community-driven forum built right into your Nextcloud instance",
"license": "AGPL-3.0-or-later",
"authors": [
{
@@ -15,11 +15,18 @@
}
},
"scripts": {
"post-install-cmd": [
"@php -r \"if (getenv('COMPOSER_DEV_MODE') !== '0') { passthru(getenv('COMPOSER_BINARY').' bin all install --ansi'); }\""
],
"post-update-cmd": [
"@php -r \"if (getenv('COMPOSER_DEV_MODE') !== '0') { passthru(getenv('COMPOSER_BINARY').' bin all update --ansi'); }\""
],
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './gen/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
"cs:check": "php-cs-fixer fix --dry-run --diff",
"cs:fix": "php-cs-fixer fix",
"psalm": "psalm --threads=1 --no-cache",
"test:unit": "phpunit tests -c tests/phpunit.xml --colors=always --fail-on-warning --fail-on-risky",
"test:integration": "phpunit tests -c tests/phpunit.integration.xml --colors=always --fail-on-warning --fail-on-risky",
"openapi": "generate-spec"
},
"require": {

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "502959d88450c4cc20089ae1b1673125",
"content-hash": "899d5eaf730a04eeedf3a31c6a28d2fb",
"packages": [
{
"name": "sabre/uri",

View File

@@ -150,7 +150,7 @@ class Version1Date20260405000000 extends SimpleMigrationStep {
]);
$table->addColumn('bought', Types::BOOLEAN, [
'notnull' => true,
'default' => false,
'default' => 0,
]);
$table->addColumn('bought_at', Types::BIGINT, [
'notnull' => false,

View File

@@ -1,23 +1,133 @@
<template>
<NcDialog :name="strings.title" :open="open" @update:open="$emit('update:open', $event)">
<NcDialog
:name="strings.title"
:open="open"
size="normal"
@update:open="$emit('update:open', $event)"
>
<div class="pantry-recurrence">
<NcSelect
v-model="selectedPreset"
:options="presetOptions"
:input-label="strings.repeatLabel"
/>
<!-- Quick presets -->
<section class="pantry-recurrence__section">
<label class="pantry-recurrence__label">{{ strings.presetsLabel }}</label>
<div class="pantry-recurrence__presets">
<NcButton
v-for="preset in presetButtons"
:key="preset.key"
:variant="activePreset === preset.key ? 'primary' : 'secondary'"
@click="applyPreset(preset.key)"
>
{{ preset.label }}
</NcButton>
</div>
</section>
<div v-if="selectedPreset?.value === 'custom'" class="pantry-recurrence__custom">
<NcTextField
v-model="customRrule"
:label="strings.customLabel"
:placeholder="strings.customPlaceholder"
/>
<p class="pantry-recurrence__hint">{{ strings.customHint }}</p>
</div>
<hr class="pantry-recurrence__divider" />
<p v-if="error" class="pantry-recurrence__error">{{ error }}</p>
<!-- Frequency + interval -->
<section class="pantry-recurrence__section pantry-recurrence__row">
<div class="pantry-recurrence__field">
<label :for="intervalId" class="pantry-recurrence__label">{{ strings.everyLabel }}</label>
<input
:id="intervalId"
v-model.number="interval"
type="number"
min="1"
max="999"
class="pantry-recurrence__number"
/>
</div>
<div class="pantry-recurrence__field pantry-recurrence__field--grow">
<label class="pantry-recurrence__label">{{ strings.frequencyLabel }}</label>
<NcSelect
v-model="frequencyOption"
:options="frequencyOptions"
:clearable="false"
:input-label="''"
/>
</div>
</section>
<!-- Weekly: weekday picker -->
<section v-if="frequencyOption?.value === 'WEEKLY'" class="pantry-recurrence__section">
<label class="pantry-recurrence__label">{{ strings.weekdaysLabel }}</label>
<div class="pantry-recurrence__weekdays">
<button
v-for="day in weekdays"
:key="day.value"
type="button"
class="pantry-recurrence__weekday"
:class="{ 'pantry-recurrence__weekday--active': selectedWeekdays.includes(day.value) }"
@click="toggleWeekday(day.value)"
>
{{ day.short }}
</button>
</div>
</section>
<!-- Monthly: bymonthday -->
<section v-if="frequencyOption?.value === 'MONTHLY'" class="pantry-recurrence__section">
<label class="pantry-recurrence__label">{{ strings.monthDaysLabel }}</label>
<p class="pantry-recurrence__hint">{{ strings.monthDaysHint }}</p>
<div class="pantry-recurrence__month-grid">
<button
v-for="day in 31"
:key="day"
type="button"
class="pantry-recurrence__month-day"
:class="{ 'pantry-recurrence__month-day--active': selectedMonthDays.includes(day) }"
@click="toggleMonthDay(day)"
>
{{ day }}
</button>
</div>
</section>
<!-- End condition -->
<section class="pantry-recurrence__section">
<label class="pantry-recurrence__label">{{ strings.endsLabel }}</label>
<div class="pantry-recurrence__ends">
<label class="pantry-recurrence__radio">
<input v-model="endKind" type="radio" value="never" />
<span>{{ strings.endNever }}</span>
</label>
<label class="pantry-recurrence__radio">
<input v-model="endKind" type="radio" value="count" />
<span>{{ strings.endAfter }}</span>
<input
v-model.number="endCount"
type="number"
min="1"
max="9999"
class="pantry-recurrence__number pantry-recurrence__number--inline"
:disabled="endKind !== 'count'"
/>
<span>{{ strings.endAfterSuffix }}</span>
</label>
<label class="pantry-recurrence__radio">
<input v-model="endKind" type="radio" value="until" />
<span>{{ strings.endOn }}</span>
<input
v-model="endUntil"
type="date"
class="pantry-recurrence__date"
:disabled="endKind !== 'until'"
/>
</label>
</div>
</section>
<hr class="pantry-recurrence__divider" />
<section class="pantry-recurrence__section">
<p class="pantry-recurrence__summary">
<RepeatIcon :size="16" />
<strong>{{ strings.summaryLabel }}</strong>
<span>{{ summaryText }}</span>
</p>
<p v-if="error" class="pantry-recurrence__error">{{ error }}</p>
</section>
</div>
<template #actions>
<NcButton @click="$emit('update:open', false)">{{ strings.cancel }}</NcButton>
<NcButton v-if="hasExisting" variant="tertiary" @click="clear">
@@ -32,130 +142,469 @@
import { computed, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { RRule } from 'rrule'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import RepeatIcon from '@icons/Repeat.vue'
import { Frequency, RRule, Weekday } from 'rrule'
type PresetValue = 'none' | 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'
interface PresetOption {
// ---------- Types ----------
type Freq = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
type EndKind = 'never' | 'count' | 'until'
type PresetKey = 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'
interface FreqOption {
label: string
value: PresetValue
rrule: string | null
value: Freq
}
const props = defineProps<{
open: boolean
modelValue: string | null
}>()
// ---------- Props / emits ----------
const props = defineProps<{ open: boolean; modelValue: string | null }>()
const emit = defineEmits<{
(e: 'update:open', open: boolean): void
(e: 'update:modelValue', value: string | null): void
(e: 'update:open', v: boolean): void
(e: 'update:modelValue', v: string | null): void
}>()
const presetOptions = computed<PresetOption[]>(() => [
{ label: t('pantry', 'No repeat'), value: 'none', rrule: null },
{ label: t('pantry', 'Daily'), value: 'daily', rrule: 'FREQ=DAILY' },
{ label: t('pantry', 'Weekly'), value: 'weekly', rrule: 'FREQ=WEEKLY' },
{ label: t('pantry', 'Every two weeks'), value: 'biweekly', rrule: 'FREQ=WEEKLY;INTERVAL=2' },
{ label: t('pantry', 'Monthly'), value: 'monthly', rrule: 'FREQ=MONTHLY' },
{ label: t('pantry', 'Custom …'), value: 'custom', rrule: null },
// ---------- Form state ----------
const frequencyOptions = computed<FreqOption[]>(() => [
{ label: t('pantry', 'days'), value: 'DAILY' },
{ label: t('pantry', 'weeks'), value: 'WEEKLY' },
{ label: t('pantry', 'months'), value: 'MONTHLY' },
{ label: t('pantry', 'years'), value: 'YEARLY' },
])
const selectedPreset = ref<PresetOption | null>(presetOptions.value[0] ?? null)
const customRrule = ref('')
const frequencyOption = ref<FreqOption>(frequencyOptions.value[1]!) // weekly default
const interval = ref<number>(1)
const selectedWeekdays = ref<number[]>([]) // 0 = Monday … 6 = Sunday (rrule.js convention)
const selectedMonthDays = ref<number[]>([])
const endKind = ref<EndKind>('never')
const endCount = ref<number>(10)
const endUntil = ref<string>('')
const error = ref<string | null>(null)
const intervalId = `pantry-interval-${Math.random().toString(36).slice(2, 8)}`
const weekdays = computed(() => [
{ value: 0, short: t('pantry', 'Mo') },
{ value: 1, short: t('pantry', 'Tu') },
{ value: 2, short: t('pantry', 'We') },
{ value: 3, short: t('pantry', 'Th') },
{ value: 4, short: t('pantry', 'Fr') },
{ value: 5, short: t('pantry', 'Sa') },
{ value: 6, short: t('pantry', 'Su') },
])
const hasExisting = computed(() => !!props.modelValue)
function matchPreset(rrule: string | null): PresetOption {
const all = presetOptions.value
if (!rrule) return all[0]!
const normalized = rrule.trim().replace(/^RRULE:/i, '')
const found = all.find((p) => p.rrule === normalized)
return found ?? all[all.length - 1]! // custom
// ---------- Presets ----------
const presetButtons = computed(() => [
{ key: 'daily' as PresetKey, label: t('pantry', 'Daily') },
{ key: 'weekly' as PresetKey, label: t('pantry', 'Weekly') },
{ key: 'biweekly' as PresetKey, label: t('pantry', 'Every 2 weeks') },
{ key: 'monthly' as PresetKey, label: t('pantry', 'Monthly') },
])
const activePreset = computed<PresetKey>(() => {
if (endKind.value !== 'never') return 'custom'
const freq = frequencyOption.value?.value
if (freq === 'DAILY' && interval.value === 1) return 'daily'
if (freq === 'WEEKLY' && interval.value === 1 && selectedWeekdays.value.length === 0)
return 'weekly'
if (freq === 'WEEKLY' && interval.value === 2 && selectedWeekdays.value.length === 0)
return 'biweekly'
if (freq === 'MONTHLY' && interval.value === 1 && selectedMonthDays.value.length === 0)
return 'monthly'
return 'custom'
})
function applyPreset(key: PresetKey): void {
error.value = null
endKind.value = 'never'
endCount.value = 10
endUntil.value = ''
selectedWeekdays.value = []
selectedMonthDays.value = []
switch (key) {
case 'daily':
frequencyOption.value = frequencyOptions.value[0]!
interval.value = 1
break
case 'weekly':
frequencyOption.value = frequencyOptions.value[1]!
interval.value = 1
break
case 'biweekly':
frequencyOption.value = frequencyOptions.value[1]!
interval.value = 2
break
case 'monthly':
frequencyOption.value = frequencyOptions.value[2]!
interval.value = 1
break
default:
break
}
}
watch(
() => [props.open, props.modelValue] as const,
([isOpen, value]) => {
if (isOpen) {
error.value = null
selectedPreset.value = matchPreset(value)
customRrule.value = selectedPreset.value.value === 'custom' ? (value ?? '') : ''
// ---------- Toggles ----------
function toggleWeekday(value: number): void {
const idx = selectedWeekdays.value.indexOf(value)
if (idx === -1) {
selectedWeekdays.value = [...selectedWeekdays.value, value].sort((a, b) => a - b)
} else {
selectedWeekdays.value = selectedWeekdays.value.filter((v) => v !== value)
}
}
function toggleMonthDay(day: number): void {
const idx = selectedMonthDays.value.indexOf(day)
if (idx === -1) {
selectedMonthDays.value = [...selectedMonthDays.value, day].sort((a, b) => a - b)
} else {
selectedMonthDays.value = selectedMonthDays.value.filter((v) => v !== day)
}
}
// ---------- RRULE build / parse ----------
function freqToRrule(freq: Freq): Frequency {
switch (freq) {
case 'DAILY':
return RRule.DAILY
case 'WEEKLY':
return RRule.WEEKLY
case 'MONTHLY':
return RRule.MONTHLY
case 'YEARLY':
return RRule.YEARLY
}
}
function rruleToFreq(freq: Frequency): Freq {
switch (freq) {
case RRule.DAILY:
return 'DAILY'
case RRule.WEEKLY:
return 'WEEKLY'
case RRule.MONTHLY:
return 'MONTHLY'
case RRule.YEARLY:
return 'YEARLY'
default:
return 'WEEKLY'
}
}
function buildRrule(): string | null {
const freq = frequencyOption.value?.value ?? 'WEEKLY'
const options: Record<string, unknown> = {
freq: freqToRrule(freq),
interval: Math.max(1, Math.floor(Number(interval.value) || 1)),
}
if (freq === 'WEEKLY' && selectedWeekdays.value.length > 0) {
options.byweekday = selectedWeekdays.value.map((n) => new Weekday(n))
}
if (freq === 'MONTHLY' && selectedMonthDays.value.length > 0) {
options.bymonthday = [...selectedMonthDays.value]
}
if (endKind.value === 'count') {
const n = Math.max(1, Math.floor(Number(endCount.value) || 1))
options.count = n
} else if (endKind.value === 'until') {
if (!endUntil.value) {
throw new Error(t('pantry', 'Please pick an end date.'))
}
const d = new Date(endUntil.value + 'T23:59:59Z')
if (Number.isNaN(d.getTime())) {
throw new Error(t('pantry', 'Invalid end date.'))
}
options.until = d
}
const rule = new RRule(options as ConstructorParameters<typeof RRule>[0])
// rrule.js returns a string like "DTSTART:...\nRRULE:FREQ=..." or "RRULE:..."
const full = rule.toString()
const rruleLine = full
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('RRULE:'))
if (!rruleLine) return null
return rruleLine.slice('RRULE:'.length)
}
function loadFromRrule(raw: string | null): void {
error.value = null
selectedWeekdays.value = []
selectedMonthDays.value = []
endKind.value = 'never'
endCount.value = 10
endUntil.value = ''
if (!raw) {
frequencyOption.value = frequencyOptions.value[1]!
interval.value = 1
return
}
try {
const rule = RRule.fromString('RRULE:' + raw.replace(/^RRULE:/i, ''))
const opts = rule.origOptions
const freq = rruleToFreq(rule.options.freq)
frequencyOption.value =
frequencyOptions.value.find((o) => o.value === freq) ?? frequencyOptions.value[1]!
interval.value = opts.interval ?? 1
if (opts.byweekday) {
const list = Array.isArray(opts.byweekday) ? opts.byweekday : [opts.byweekday]
selectedWeekdays.value = list
.map((w) => {
if (typeof w === 'number') return w
if (w instanceof Weekday) return w.weekday
return null
})
.filter((v): v is number => v !== null)
}
if (opts.bymonthday) {
const list = Array.isArray(opts.bymonthday) ? opts.bymonthday : [opts.bymonthday]
selectedMonthDays.value = list.filter((n): n is number => typeof n === 'number')
}
if (opts.count != null) {
endKind.value = 'count'
endCount.value = opts.count
} else if (opts.until) {
endKind.value = 'until'
const d = new Date(opts.until)
endUntil.value = d.toISOString().slice(0, 10)
}
} catch (e) {
error.value = (e as Error).message || t('pantry', 'Could not read the existing rule.')
}
}
// ---------- Human-readable summary ----------
const summaryText = computed<string>(() => {
try {
const raw = buildRrule()
if (!raw) return t('pantry', 'No repeat')
const rule = RRule.fromString('RRULE:' + raw)
return rule.toText()
} catch {
return t('pantry', '—')
}
})
// ---------- Dialog open lifecycle ----------
watch(
() => props.open,
(isOpen) => {
if (isOpen) loadFromRrule(props.modelValue)
},
{ immediate: true },
)
function submit() {
// ---------- Submit / clear ----------
function submit(): void {
try {
const preset = selectedPreset.value
if (!preset || preset.value === 'none') {
emit('update:modelValue', null)
emit('update:open', false)
return
}
if (preset.value === 'custom') {
const raw = customRrule.value.trim().replace(/^RRULE:/i, '')
if (!raw) {
error.value = t('pantry', 'Please enter a rule.')
return
}
// Validate on the client via rrule library.
RRule.fromString('RRULE:' + raw)
emit('update:modelValue', raw)
} else if (preset.rrule) {
emit('update:modelValue', preset.rrule)
}
const raw = buildRrule()
emit('update:modelValue', raw)
emit('update:open', false)
} catch (e) {
error.value = (e as Error).message || t('pantry', 'Invalid recurrence rule.')
}
}
function clear() {
function clear(): void {
emit('update:modelValue', null)
emit('update:open', false)
}
const strings = {
title: t('pantry', 'Recurrence'),
repeatLabel: t('pantry', 'Repeat:'),
customLabel: t('pantry', 'Custom rule (RFC 5545):'),
customPlaceholder: t('pantry', 'e.g. FREQ=WEEKLY;BYDAY=MO,FR'),
customHint: t(
'pantry',
'Specify a standard iCalendar RRULE value. Leave off the "RRULE:" prefix.',
),
presetsLabel: t('pantry', 'Presets:'),
frequencyLabel: t('pantry', 'Unit:'),
everyLabel: t('pantry', 'Every:'),
weekdaysLabel: t('pantry', 'Repeat on:'),
monthDaysLabel: t('pantry', 'Days of the month:'),
monthDaysHint: t('pantry', 'Leave empty to repeat on the same day each month.'),
endsLabel: t('pantry', 'Ends:'),
endNever: t('pantry', 'Never'),
endAfter: t('pantry', 'After'),
endAfterSuffix: t('pantry', 'occurrences'),
endOn: t('pantry', 'On date'),
summaryLabel: t('pantry', 'Summary:'),
cancel: t('pantry', 'Cancel'),
save: t('pantry', 'Save'),
clearButton: t('pantry', 'Remove recurrence'),
}
</script>
<style scoped>
<style scoped lang="scss">
.pantry-recurrence {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
padding: 0.25rem 0;
min-width: 420px;
&__section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__row {
flex-direction: row;
align-items: flex-end;
gap: 1rem;
}
&__field {
display: flex;
flex-direction: column;
gap: 0.25rem;
&--grow {
flex: 1;
}
}
&__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
}
&__hint {
margin: 0;
color: var(--color-text-maxcontrast);
font-size: 0.8rem;
}
&__presets {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__number {
width: 80px;
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
color: var(--color-main-text);
font-size: 0.95rem;
&--inline {
width: 64px;
}
}
&__date {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
color: var(--color-main-text);
font-size: 0.95rem;
}
&__weekdays {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
&__weekday {
min-width: 38px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-main-background);
color: var(--color-main-text);
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
background: var(--color-primary-element);
color: var(--color-primary-element-text);
border-color: var(--color-primary-element);
}
}
&__month-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.25rem;
}
&__month-day {
padding: 6px 0;
border-radius: var(--border-radius, 8px);
border: 1px solid var(--color-border);
background: var(--color-main-background);
color: var(--color-main-text);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
background: var(--color-primary-element);
color: var(--color-primary-element-text);
border-color: var(--color-primary-element);
}
}
&__ends {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__radio {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
&__divider {
border: none;
border-top: 1px solid var(--color-border);
margin: 0;
}
&__summary {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
color: var(--color-main-text);
font-size: 0.95rem;
flex-wrap: wrap;
}
&__error {
margin: 0;
color: var(--color-error);
}
}
.pantry-recurrence__custom {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pantry-recurrence__hint {
margin: 0;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
}
.pantry-recurrence__error {
margin: 0;
color: var(--color-error);
@media (max-width: 600px) {
.pantry-recurrence {
min-width: 0;
}
}
</style>

View File

@@ -1,9 +1,8 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { generateUrl } from '@nextcloud/router'
const SideNavigation = () => import('@/views/SideNavigation.vue')
const HouseLayout = () => import('@/views/HouseLayout.vue')
const HousesNavigation = () => import('@/views/HousesNavigation.vue')
const HouseNavigation = () => import('@/views/HouseNavigation.vue')
const routes: RouteRecordRaw[] = [
{
@@ -11,24 +10,24 @@ const routes: RouteRecordRaw[] = [
name: 'home',
components: {
default: () => import('@/views/HomeRedirect.vue'),
navigation: HousesNavigation,
navigation: SideNavigation,
},
},
{
path: '/houses',
name: 'houses',
path: '/welcome',
name: 'welcome',
components: {
default: () => import('@/views/HousesList.vue'),
navigation: HousesNavigation,
default: () => import('@/views/WelcomeView.vue'),
navigation: SideNavigation,
},
},
{
path: '/houses/:houseId',
components: {
default: HouseLayout,
navigation: HouseNavigation,
navigation: SideNavigation,
},
props: { default: true, navigation: true },
props: { default: true, navigation: false },
children: [
{ path: '', redirect: (to) => ({ name: 'lists', params: to.params }) },
{

View File

@@ -18,13 +18,13 @@ const lastHouse = useLastHouse()
onMounted(async () => {
await load()
if (houses.value.length === 0) {
await router.replace({ name: 'houses' })
await router.replace({ name: 'welcome' })
return
}
const lastId = await lastHouse.get()
const first = houses.value[0]
if (!first) {
await router.replace({ name: 'houses' })
await router.replace({ name: 'welcome' })
return
}
const target = lastId !== null && houses.value.some((h) => h.id === lastId) ? lastId : first.id

View File

@@ -1,99 +0,0 @@
<template>
<NcAppNavigation>
<template #list>
<NcAppNavigationItem :name="strings.allHouses" :to="{ name: 'houses' }">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
</NcAppNavigationItem>
<li v-if="house" class="pantry-nav-house-name" :title="house.name">
{{ house.name }}
</li>
<NcAppNavigationItem
:name="strings.lists"
:to="{ name: 'lists', params: { houseId: String(houseId) } }"
>
<template #icon>
<CartIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.photos"
:to="{ name: 'photos', params: { houseId: String(houseId) } }"
>
<template #icon>
<ImageIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.notes"
:to="{ name: 'notes', params: { houseId: String(houseId) } }"
>
<template #icon>
<NoteIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.members"
:to="{ name: 'members', params: { houseId: String(houseId) } }"
>
<template #icon>
<AccountGroupIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAdmin"
:name="strings.houseSettings"
:to="{ name: 'house-settings', params: { houseId: String(houseId) } }"
>
<template #icon>
<CogIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import CartIcon from '@icons/Cart.vue'
import ImageIcon from '@icons/Image.vue'
import NoteIcon from '@icons/Note.vue'
import AccountGroupIcon from '@icons/AccountGroup.vue'
import CogIcon from '@icons/Cog.vue'
import { useCurrentHouse } from '@/composables/useCurrentHouse'
const { house, houseId, canAdmin } = useCurrentHouse()
const strings = {
allHouses: t('pantry', 'All houses'),
lists: t('pantry', 'Shopping lists'),
photos: t('pantry', 'Photo board'),
notes: t('pantry', 'Notes wall'),
members: t('pantry', 'Members'),
houseSettings: t('pantry', 'House settings'),
}
</script>
<style scoped>
.pantry-nav-house-name {
padding: 8px 16px 4px;
font-weight: 600;
color: var(--color-text-maxcontrast);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -93,7 +93,7 @@ async function deleteHouse() {
if (!house.value) return
await remove(house.value.id)
confirmingDelete.value = false
await router.push({ name: 'houses' })
await router.push({ name: 'home' })
}
const strings = {

View File

@@ -1,253 +0,0 @@
<template>
<div class="pantry-houses">
<header class="pantry-houses__header">
<h2>{{ strings.title }}</h2>
<NcButton variant="primary" @click="showCreate = true">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createButton }}
</NcButton>
</header>
<div v-if="loading && !loaded" class="pantry-houses__loading">
<NcLoadingIcon :size="48" />
</div>
<NcEmptyContent
v-else-if="loaded && houses.length === 0"
:name="strings.emptyTitle"
:description="strings.emptyBody"
>
<template #icon>
<HomeIcon />
</template>
<template #action>
<NcButton variant="primary" @click="showCreate = true">
{{ strings.createButton }}
</NcButton>
</template>
</NcEmptyContent>
<ul v-else class="pantry-houses__grid">
<li v-for="house in houses" :key="house.id">
<router-link
class="pantry-house-card"
:to="{ name: 'lists', params: { houseId: String(house.id) } }"
>
<div class="pantry-house-card__icon">
<HomeIcon :size="32" />
</div>
<div class="pantry-house-card__body">
<h3 class="pantry-house-card__name">{{ house.name }}</h3>
<p v-if="house.description" class="pantry-house-card__desc">
{{ house.description }}
</p>
<span class="pantry-house-card__role">{{ roleLabel(house.role) }}</span>
</div>
</router-link>
</li>
</ul>
<NcDialog
v-if="showCreate"
:name="strings.createDialogTitle"
:open="showCreate"
@update:open="showCreate = $event"
>
<form class="pantry-create-form" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<NcTextField
v-model="newDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
/>
<p v-if="createError" class="pantry-form-error">{{ createError }}</p>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="creating || !newName.trim()" @click="submitCreate">
{{ creating ? strings.creatingLabel : strings.createButton }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { 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'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import PlusIcon from '@icons/Plus.vue'
import HomeIcon from '@icons/Home.vue'
import { useHouses } from '@/composables/useHouses'
import type { HouseRole } from '@/api/types'
const router = useRouter()
const { houses, loaded, loading, load, create } = useHouses()
const showCreate = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const createError = ref<string | null>(null)
onMounted(() => {
void load()
})
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
creating.value = true
createError.value = null
try {
const house = await create(name, newDescription.value.trim() || null)
showCreate.value = false
newName.value = ''
newDescription.value = ''
await router.push({ name: 'lists', params: { houseId: String(house.id) } })
} catch (e) {
createError.value = (e as Error).message || t('pantry', 'Could not create house.')
} finally {
creating.value = false
}
}
function roleLabel(role: HouseRole): string {
switch (role) {
case 'owner':
return t('pantry', 'Owner')
case 'admin':
return t('pantry', 'Administrator')
default:
return t('pantry', 'Member')
}
}
const strings = {
title: t('pantry', 'Your houses'),
createButton: t('pantry', 'New house'),
creatingLabel: t('pantry', 'Creating …'),
createDialogTitle: t('pantry', 'Create a house'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'e.g. Home, Beach house'),
descriptionLabel: t('pantry', 'Description (optional):'),
descriptionPlaceholder: t('pantry', 'A short description'),
cancel: t('pantry', 'Cancel'),
emptyTitle: t('pantry', 'No houses yet'),
emptyBody: t(
'pantry',
'Create a house to start organizing your shopping lists, photos and notes.',
),
}
</script>
<style scoped lang="scss">
.pantry-houses {
max-width: 1100px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
h2 {
margin: 0;
}
}
&__loading {
display: flex;
justify-content: center;
padding: 2rem;
}
&__grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
}
.pantry-house-card {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
background: var(--color-main-background);
color: inherit;
text-decoration: none;
transition: background-color 0.15s ease;
&:hover,
&:focus-visible {
background: var(--color-background-hover);
}
&__icon {
color: var(--color-primary-element);
display: flex;
align-items: flex-start;
}
&__body {
flex: 1;
min-width: 0;
}
&__name {
margin: 0 0 4px 0;
font-size: 1.1rem;
}
&__desc {
margin: 0 0 6px 0;
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__role {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary-element-light);
color: var(--color-primary-element-light-text);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.pantry-create-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
}
.pantry-form-error {
color: var(--color-error);
margin: 0;
}
</style>

View File

@@ -1,26 +0,0 @@
<template>
<NcAppNavigation>
<template #list>
<NcAppNavigationItem
:name="strings.allHouses"
:to="{ name: 'houses' }"
:active="$route.name === 'houses' || $route.name === 'home'"
>
<template #icon>
<HomeIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigation>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import HomeIcon from '@icons/Home.vue'
const strings = {
allHouses: t('pantry', 'All houses'),
}
</script>

View File

@@ -163,7 +163,7 @@ async function removeExisting(memberId: number) {
async function leave() {
await api.leaveHouse(houseIdNum.value)
await router.push({ name: 'houses' })
await router.push({ name: 'home' })
}
function roleLabel(role: HouseRole): string {

View File

@@ -0,0 +1,387 @@
<template>
<NcAppNavigation>
<template #list>
<template v-if="currentHouseId !== null">
<li class="pantry-nav__house-label" :title="house?.name">
{{ house?.name ?? '' }}
</li>
<NcAppNavigationItem
:name="strings.lists"
:to="{ name: 'lists', params: { houseId: String(currentHouseId) } }"
>
<template #icon>
<CartIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.photos"
:to="{ name: 'photos', params: { houseId: String(currentHouseId) } }"
>
<template #icon>
<ImageIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.notes"
:to="{ name: 'notes', params: { houseId: String(currentHouseId) } }"
>
<template #icon>
<NoteIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
:name="strings.members"
:to="{ name: 'members', params: { houseId: String(currentHouseId) } }"
>
<template #icon>
<AccountGroupIcon :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAdmin"
:name="strings.houseSettings"
:to="{ name: 'house-settings', params: { houseId: String(currentHouseId) } }"
>
<template #icon>
<CogIcon :size="20" />
</template>
</NcAppNavigationItem>
</template>
<li v-else class="pantry-nav__welcome">
{{ strings.welcomeHint }}
</li>
</template>
<template #footer>
<div class="pantry-switcher">
<button
ref="triggerRef"
type="button"
class="pantry-switcher__trigger"
:aria-expanded="menuOpen"
aria-haspopup="menu"
@click="toggleMenu"
>
<HomeIcon :size="20" class="pantry-switcher__icon" />
<span class="pantry-switcher__label">
{{ house?.name ?? strings.pickHouse }}
</span>
<ChevronUpIcon v-if="menuOpen" :size="18" />
<ChevronDownIcon v-else :size="18" />
</button>
<ul v-if="menuOpen" class="pantry-switcher__menu" role="menu">
<li
v-for="h in houses"
:key="h.id"
role="menuitem"
class="pantry-switcher__item"
:class="{ 'pantry-switcher__item--active': h.id === currentHouseId }"
@click="pickHouse(h.id)"
>
<HomeIcon :size="18" />
<span class="pantry-switcher__item-name">{{ h.name }}</span>
<CheckIcon v-if="h.id === currentHouseId" :size="16" />
</li>
<li v-if="houses.length > 0" class="pantry-switcher__separator" role="separator"></li>
<li
role="menuitem"
class="pantry-switcher__item pantry-switcher__item--action"
@click="openCreate"
>
<PlusIcon :size="18" />
<span>{{ strings.createHouse }}</span>
</li>
</ul>
</div>
</template>
</NcAppNavigation>
<NcDialog
v-if="showCreate"
:name="strings.createDialogTitle"
:open="showCreate"
@update:open="showCreate = $event"
>
<form class="pantry-create-form" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<NcTextField
v-model="newDescription"
:label="strings.descriptionLabel"
:placeholder="strings.descriptionPlaceholder"
/>
<p v-if="createError" class="pantry-create-form__error">{{ createError }}</p>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="creating || !newName.trim()" @click="submitCreate">
{{ creating ? strings.creating : strings.create }}
</NcButton>
</template>
</NcDialog>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import HomeIcon from '@icons/Home.vue'
import CartIcon from '@icons/Cart.vue'
import ImageIcon from '@icons/Image.vue'
import NoteIcon from '@icons/Note.vue'
import AccountGroupIcon from '@icons/AccountGroup.vue'
import CogIcon from '@icons/Cog.vue'
import ChevronUpIcon from '@icons/ChevronUp.vue'
import ChevronDownIcon from '@icons/ChevronDown.vue'
import CheckIcon from '@icons/Check.vue'
import PlusIcon from '@icons/Plus.vue'
import { useHouses } from '@/composables/useHouses'
const route = useRoute()
const router = useRouter()
const { houses, load, create, findById } = useHouses()
const currentHouseId = computed<number | null>(() => {
const raw = route.params.houseId
if (!raw) return null
const id = Number(Array.isArray(raw) ? raw[0] : raw)
return Number.isFinite(id) ? id : null
})
const house = computed(() =>
currentHouseId.value !== null ? findById(currentHouseId.value) : undefined,
)
const canAdmin = computed(() => {
const role = house.value?.role
return role === 'owner' || role === 'admin'
})
onMounted(() => {
void load()
})
// -------- Dropup menu --------
const menuOpen = ref(false)
const triggerRef = ref<HTMLButtonElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
function onDocumentClick(e: MouseEvent) {
if (!menuOpen.value) return
const target = e.target as Node | null
if (triggerRef.value && target && triggerRef.value.parentElement?.contains(target)) return
closeMenu()
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onUnmounted(() => document.removeEventListener('click', onDocumentClick))
async function pickHouse(id: number) {
closeMenu()
if (id === currentHouseId.value) return
await router.push({ name: 'lists', params: { houseId: String(id) } })
}
// -------- Create house dialog --------
const showCreate = ref(false)
const newName = ref('')
const newDescription = ref('')
const creating = ref(false)
const createError = ref<string | null>(null)
function openCreate() {
closeMenu()
showCreate.value = true
}
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
creating.value = true
createError.value = null
try {
const h = await create(name, newDescription.value.trim() || null)
showCreate.value = false
newName.value = ''
newDescription.value = ''
await router.push({ name: 'lists', params: { houseId: String(h.id) } })
} catch (e) {
createError.value = (e as Error).message || t('pantry', 'Could not create house.')
} finally {
creating.value = false
}
}
// Close the menu on route change so it does not linger after navigation.
watch(currentHouseId, closeMenu)
const strings = {
lists: t('pantry', 'Shopping lists'),
photos: t('pantry', 'Photo board'),
notes: t('pantry', 'Notes wall'),
members: t('pantry', 'Members'),
houseSettings: t('pantry', 'House settings'),
pickHouse: t('pantry', 'Pick a house'),
createHouse: t('pantry', 'New house …'),
welcomeHint: t('pantry', 'Pick or create a house to get started.'),
createDialogTitle: t('pantry', 'Create a house'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'e.g. Home, Beach house'),
descriptionLabel: t('pantry', 'Description (optional):'),
descriptionPlaceholder: t('pantry', 'A short description'),
create: t('pantry', 'Create'),
creating: t('pantry', 'Creating …'),
cancel: t('pantry', 'Cancel'),
}
</script>
<style scoped lang="scss">
.pantry-nav__house-label {
padding: 8px 16px 4px;
font-weight: 600;
color: var(--color-text-maxcontrast);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pantry-nav__welcome {
padding: 16px;
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
line-height: 1.4;
}
.pantry-switcher {
position: relative;
padding: 8px;
border-top: 1px solid var(--color-border);
&__trigger {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: transparent;
color: var(--color-main-text);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
cursor: pointer;
font-size: 0.95rem;
text-align: left;
&:hover,
&:focus-visible {
background: var(--color-background-hover);
}
}
&__icon {
color: var(--color-primary-element);
flex-shrink: 0;
}
&__label {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
&__menu {
position: absolute;
bottom: calc(100% + 4px);
left: 8px;
right: 8px;
list-style: none;
padding: 6px;
margin: 0;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large, 12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 60vh;
overflow-y: auto;
z-index: 20;
}
&__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius, 8px);
cursor: pointer;
color: var(--color-main-text);
font-size: 0.9rem;
&:hover,
&:focus-visible {
background: var(--color-background-hover);
}
&--active {
background: var(--color-primary-element-light);
color: var(--color-primary-element-light-text);
}
&--action {
color: var(--color-primary-element);
font-weight: 500;
}
}
&__item-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__separator {
height: 1px;
background: var(--color-border);
margin: 4px 2px;
}
}
.pantry-create-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
&__error {
color: var(--color-error);
margin: 0;
}
}
</style>

29
src/views/WelcomeView.vue Normal file
View File

@@ -0,0 +1,29 @@
<template>
<NcEmptyContent :name="strings.title" :description="strings.body">
<template #icon>
<HomeIcon />
</template>
<template #action>
<p class="pantry-welcome__hint">{{ strings.hint }}</p>
</template>
</NcEmptyContent>
</template>
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import HomeIcon from '@icons/Home.vue'
const strings = {
title: t('pantry', 'Welcome to Pantry'),
body: t('pantry', 'Create a house to start organizing your shopping lists, photos and notes.'),
hint: t('pantry', 'Use the house picker at the bottom of the sidebar to create one.'),
}
</script>
<style scoped>
.pantry-welcome__hint {
color: var(--color-text-maxcontrast);
font-size: 0.9rem;
}
</style>