feat: add packer
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ category: PackerCategory, checked: Set<number> }>()
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: number, value: boolean]
|
||||
add: [name: string, weightG: number | null]
|
||||
remove: [id: number]
|
||||
weight: [id: number, weightG: number | null]
|
||||
}>()
|
||||
|
||||
/** Grams typed into a field; empty or not a positive number is no weight */
|
||||
function grams(text: string | number | null | undefined): number | null {
|
||||
const value = Number(String(text ?? '').replace(',', '.'))
|
||||
return value > 0 ? Math.round(value) : null
|
||||
}
|
||||
|
||||
const newItem = ref('')
|
||||
const newWeight = ref('')
|
||||
|
||||
function add() {
|
||||
const name = newItem.value.trim()
|
||||
if (!name) return
|
||||
emit('add', name, grams(newWeight.value))
|
||||
newItem.value = ''
|
||||
newWeight.value = ''
|
||||
}
|
||||
|
||||
// Weight of one piece, edited in place; an empty field goes back to the table's weight
|
||||
const editing = ref<number | null>(null)
|
||||
const draft = ref('')
|
||||
|
||||
function startEdit(entry: PackerEntry) {
|
||||
editing.value = entry.id
|
||||
draft.value = entry.weightG ? String(entry.weightG) : ''
|
||||
}
|
||||
|
||||
function commit() {
|
||||
if (editing.value === null) return
|
||||
emit('weight', editing.value, grams(draft.value))
|
||||
editing.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mb-8 break-inside-avoid print:mb-5">
|
||||
<h2 class="mb-2 text-sm font-semibold text-highlighted">
|
||||
{{ props.category.name }}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="entry in props.category.entries" :key="entry.id" class="group flex min-h-6 items-center gap-2">
|
||||
<!-- print-color-adjust: browsers leave out the ticked box's fill when printing -->
|
||||
<UCheckbox
|
||||
:model-value="props.checked.has(entry.id)"
|
||||
:ui="{ base: 'print:[print-color-adjust:exact]', label: props.checked.has(entry.id) ? 'text-muted line-through' : '' }"
|
||||
@update:model-value="emit('toggle', entry.id, !!$event)"
|
||||
>
|
||||
<template #label>
|
||||
<span v-if="entry.count > 1" class="tabular-nums">{{ entry.count }} × </span>{{ entry.name }}
|
||||
</template>
|
||||
</UCheckbox>
|
||||
<UBadge v-if="entry.added" label="neu" color="neutral" variant="outline" size="sm" />
|
||||
|
||||
<UInput
|
||||
v-if="editing === entry.id"
|
||||
v-model="draft"
|
||||
inputmode="numeric"
|
||||
size="xs"
|
||||
autofocus
|
||||
:placeholder="entry.count > 1 ? 'g pro Stück' : 'g'"
|
||||
:aria-label="`Gewicht von ${entry.name} in Gramm${entry.count > 1 ? ' pro Stück' : ''}`"
|
||||
class="ms-auto w-24"
|
||||
:ui="{ trailing: 'pe-2' }"
|
||||
@keydown.enter="commit"
|
||||
@keydown.esc="editing = null"
|
||||
@blur="commit"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="text-xs text-muted">g</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<!-- Click to change the weight for this session; changed weights are in the primary color -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="ms-auto rounded text-xs tabular-nums hover:text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
:class="[
|
||||
entry.weightEdited ? 'text-primary' : 'text-muted',
|
||||
{ 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 print:hidden': !entry.weightG }
|
||||
]"
|
||||
:title="entry.weightEdited ? 'Gewicht geändert (nur in dieser Sitzung)' : 'Gewicht ändern'"
|
||||
:aria-label="`Gewicht von ${entry.name} ändern`"
|
||||
@click="startEdit(entry)"
|
||||
>
|
||||
{{ entry.weightG ? packerWeight(entry.weightG * entry.count) : '+ g' }}
|
||||
</button>
|
||||
|
||||
<!-- Always visible on touch screens, on hover with a mouse -->
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
size="xs"
|
||||
:aria-label="`${entry.name} entfernen`"
|
||||
class="sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100 print:hidden"
|
||||
@click="emit('remove', entry.id)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="mt-2 flex gap-2 print:hidden" @submit.prevent="add">
|
||||
<!-- The plus lines up with the checkboxes, the text with the item names -->
|
||||
<UInput
|
||||
v-model="newItem"
|
||||
icon="i-lucide-plus"
|
||||
placeholder="Hinzufügen"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Zu ${props.category.name} hinzufügen`"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ leading: 'ps-0', base: 'ps-6' }"
|
||||
/>
|
||||
<!-- Only once a name is typed, to keep the list calm -->
|
||||
<UInput
|
||||
v-if="newItem"
|
||||
v-model="newWeight"
|
||||
inputmode="numeric"
|
||||
placeholder="Gewicht"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:aria-label="`Gewicht in Gramm`"
|
||||
class="w-24"
|
||||
:ui="{ trailing: 'pe-2' }"
|
||||
>
|
||||
<template #trailing>
|
||||
<span class="text-xs text-muted">g</span>
|
||||
</template>
|
||||
</UInput>
|
||||
<!-- Enter in either field adds the item -->
|
||||
<button type="submit" class="hidden" />
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ activities: string[], stays: string[], weathers: string[], weatherExtras: string[], hasSeasons: boolean }>()
|
||||
|
||||
const settings = defineModel<PackerSettings>({ required: true })
|
||||
const { packer } = useAppConfig()
|
||||
|
||||
const toggled = (list: string[], label: string) => list.includes(label) ? list.filter(item => item !== label) : [...list, label]
|
||||
|
||||
function toggleActivity(label: string) {
|
||||
settings.value.activities = toggled(settings.value.activities, label)
|
||||
}
|
||||
|
||||
function toggleWeather(label: string) {
|
||||
settings.value.weatherExtras = toggled(settings.value.weatherExtras, label)
|
||||
}
|
||||
|
||||
const weatherIcon = (weather: string) => packer.weatherIcons[weather] ?? 'i-lucide-cloud'
|
||||
|
||||
const activityIcon = (activity: string) => packer.activityIcons[activity] ?? 'i-lucide-tag'
|
||||
|
||||
const SEASON_ICONS: Record<PackerSeason, string> = {
|
||||
Frühling: 'i-lucide-flower',
|
||||
Sommer: 'i-lucide-sun',
|
||||
Herbst: 'i-lucide-leaf',
|
||||
Winter: 'i-lucide-snowflake'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 rounded-lg p-4 ring-1 ring-default sm:p-5">
|
||||
<!-- Side by side once there is room; Reise only takes the width it needs, so the four seasons fit next to it -->
|
||||
<div class="grid gap-x-8 gap-y-6 md:grid-cols-[auto_1fr]">
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
|
||||
Reise
|
||||
</h2>
|
||||
<UFormField label="Personen">
|
||||
<UInputNumber v-model="settings.people" :min="1" :max="PACKER_MAX_PEOPLE" class="w-32" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Tage">
|
||||
<UInputNumber v-model="settings.days" :min="1" :max="PACKER_MAX_DAYS" class="w-32" />
|
||||
</UFormField>
|
||||
|
||||
<!-- Only once the trip has a night -->
|
||||
<UFormField v-if="settings.days > 1 && stays.length > 1" label="Schlafen">
|
||||
<UFieldGroup>
|
||||
<UButton
|
||||
v-for="stay in stays"
|
||||
:key="stay"
|
||||
:label="stay"
|
||||
:icon="activityIcon(stay)"
|
||||
:color="settings.stay === stay ? 'primary' : 'neutral'"
|
||||
:variant="settings.stay === stay ? 'solid' : 'outline'"
|
||||
:aria-pressed="settings.stay === stay"
|
||||
@click="settings.stay = stay"
|
||||
/>
|
||||
</UFieldGroup>
|
||||
</UFormField>
|
||||
</section>
|
||||
|
||||
<section v-if="weathers.length > 1 || weatherExtras.length || hasSeasons" class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
|
||||
Bedingungen
|
||||
</h2>
|
||||
<UFormField v-if="weathers.length > 1 || weatherExtras.length" label="Wetter">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UFieldGroup v-if="weathers.length > 1">
|
||||
<UButton
|
||||
v-for="weather in weathers"
|
||||
:key="weather"
|
||||
:label="weather"
|
||||
:icon="weatherIcon(weather)"
|
||||
:ui="{ leadingIcon: 'max-sm:hidden' }"
|
||||
:color="settings.weather === weather ? 'primary' : 'neutral'"
|
||||
:variant="settings.weather === weather ? 'solid' : 'outline'"
|
||||
:aria-pressed="settings.weather === weather"
|
||||
@click="settings.weather = weather"
|
||||
/>
|
||||
</UFieldGroup>
|
||||
<!-- On top of the weather above, e.g. windy -->
|
||||
<UButton
|
||||
v-for="weather in weatherExtras"
|
||||
:key="weather"
|
||||
:label="weather"
|
||||
:icon="weatherIcon(weather)"
|
||||
:ui="{ leadingIcon: 'max-sm:hidden' }"
|
||||
:color="settings.weatherExtras.includes(weather) ? 'primary' : 'neutral'"
|
||||
:variant="settings.weatherExtras.includes(weather) ? 'solid' : 'outline'"
|
||||
:aria-pressed="settings.weatherExtras.includes(weather)"
|
||||
@click="toggleWeather(weather)"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
|
||||
<!-- Without icons on phones, so the four fit in one row -->
|
||||
<UFormField v-if="hasSeasons" label="Jahreszeit">
|
||||
<UFieldGroup>
|
||||
<UButton
|
||||
v-for="season in PACKER_SEASONS"
|
||||
:key="season"
|
||||
:label="season"
|
||||
:icon="SEASON_ICONS[season]"
|
||||
:ui="{ leadingIcon: 'max-sm:hidden' }"
|
||||
:color="settings.season === season ? 'primary' : 'neutral'"
|
||||
:variant="settings.season === season ? 'solid' : 'outline'"
|
||||
:aria-pressed="settings.season === season"
|
||||
@click="settings.season = season"
|
||||
/>
|
||||
</UFieldGroup>
|
||||
</UFormField>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="activities.length" class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-semibold tracking-wide text-muted uppercase">
|
||||
Extras
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
v-for="activity in activities"
|
||||
:key="activity"
|
||||
:label="activity"
|
||||
:icon="activityIcon(activity)"
|
||||
:color="settings.activities.includes(activity) ? 'primary' : 'neutral'"
|
||||
:variant="settings.activities.includes(activity) ? 'solid' : 'outline'"
|
||||
:aria-pressed="settings.activities.includes(activity)"
|
||||
@click="toggleActivity(activity)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user