feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { CalendarDate, parseDate } from '@internationalized/date'
|
||||
import { RANGE_KEYS, rangeLabel, type RangeKey } from '@/lib/activity'
|
||||
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
|
||||
|
||||
interface RangeChange {
|
||||
range: RangeKey
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
interface CalendarRange {
|
||||
start?: CalendarDate
|
||||
end?: CalendarDate
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
range: RangeKey
|
||||
from: string
|
||||
to: string
|
||||
block?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: RangeChange]
|
||||
}>()
|
||||
|
||||
const popoverOpen = ref(false)
|
||||
const customPending = ref(false)
|
||||
const draftFrom = ref(props.from)
|
||||
const draftTo = ref(props.to)
|
||||
|
||||
const presets = RANGE_KEYS.filter((key) => key !== 'custom')
|
||||
const customSelected = computed(
|
||||
() => props.range === 'custom' || popoverOpen.value || customPending.value,
|
||||
)
|
||||
const showFields = computed(() => props.block && customSelected.value)
|
||||
|
||||
function parse(value: string) {
|
||||
try {
|
||||
return parseDate(value)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const calendarValue = computed<CalendarRange>(() => ({
|
||||
start: parse(props.from),
|
||||
end: parse(props.to),
|
||||
}))
|
||||
|
||||
watch(
|
||||
() => [props.from, props.to],
|
||||
([from, to]) => {
|
||||
draftFrom.value = from ?? ''
|
||||
draftTo.value = to ?? ''
|
||||
},
|
||||
)
|
||||
|
||||
function choose(range: RangeKey) {
|
||||
customPending.value = false
|
||||
emit('change', { range, from: '', to: '' })
|
||||
}
|
||||
|
||||
function chooseCustom() {
|
||||
if (props.block) customPending.value = true
|
||||
}
|
||||
|
||||
function applyCustom(from: string, to: string) {
|
||||
customPending.value = false
|
||||
emit(
|
||||
'change',
|
||||
from <= to ? { range: 'custom', from, to } : { range: 'custom', from: to, to: from },
|
||||
)
|
||||
}
|
||||
|
||||
function onCalendar(value: CalendarRange | null) {
|
||||
if (!value?.start || !value.end) return
|
||||
popoverOpen.value = false
|
||||
applyCustom(value.start.toString(), value.end.toString())
|
||||
}
|
||||
|
||||
function onField(side: 'from' | 'to', value: string | number | null | undefined) {
|
||||
const next = typeof value === 'string' ? value : ''
|
||||
if (side === 'from') draftFrom.value = next
|
||||
else draftTo.value = next
|
||||
if (draftFrom.value && draftTo.value) applyCustom(draftFrom.value, draftTo.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2" :class="block ? 'w-full' : ''">
|
||||
<div role="group" :aria-label="t('Dates')" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
|
||||
<UButton
|
||||
v-for="key in presets"
|
||||
:key="key"
|
||||
color="neutral"
|
||||
:variant="range === key && !customSelected ? 'soft' : 'outline'"
|
||||
:aria-pressed="range === key && !customSelected"
|
||||
:class="segmentClass(range === key && !customSelected, block)"
|
||||
@click="choose(key)"
|
||||
>
|
||||
{{ rangeLabel(key) }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="block"
|
||||
color="neutral"
|
||||
:variant="customSelected ? 'soft' : 'outline'"
|
||||
icon="i-lucide-calendar"
|
||||
:aria-pressed="customSelected"
|
||||
:class="segmentClass(customSelected, block)"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="chooseCustom"
|
||||
>
|
||||
{{ rangeLabel('custom') }}
|
||||
</UButton>
|
||||
<UPopover v-else v-model:open="popoverOpen" :content="{ align: 'end' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
:variant="customSelected ? 'soft' : 'outline'"
|
||||
icon="i-lucide-calendar"
|
||||
:aria-pressed="customSelected"
|
||||
:class="segmentClass(customSelected)"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
>
|
||||
{{ rangeLabel('custom') }}
|
||||
</UButton>
|
||||
<template #content>
|
||||
<UCalendar
|
||||
range
|
||||
:model-value="calendarValue"
|
||||
:number-of-months="1"
|
||||
class="p-2"
|
||||
@update:model-value="onCalendar($event as CalendarRange | null)"
|
||||
/>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
|
||||
<div v-if="showFields" class="flex items-center gap-2">
|
||||
<UInput
|
||||
type="date"
|
||||
icon="i-lucide-calendar"
|
||||
:model-value="draftFrom"
|
||||
:aria-label="t('Start date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
|
||||
@update:model-value="onField('from', $event)"
|
||||
/>
|
||||
<UInput
|
||||
type="date"
|
||||
icon="i-lucide-calendar"
|
||||
:model-value="draftTo"
|
||||
:aria-label="t('End date')"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
|
||||
@update:model-value="onField('to', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user