mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: homes dropup, db fix, composer fix
This commit is contained in:
@@ -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
2
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) },
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
387
src/views/SideNavigation.vue
Normal file
387
src/views/SideNavigation.vue
Normal 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
29
src/views/WelcomeView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user