refactor: migrate frontend calendar page
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { WEEK_NAMES } from '@/utils/i18n';
|
||||||
|
import type { CalendarCell } from './types';
|
||||||
|
import CalendarItem from './CalendarItem.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cells: {
|
||||||
|
type: Array as () => CalendarCell[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// split the flat 42-cell array into 6 rows of 7
|
||||||
|
const rows = computed<CalendarCell[][]>(() => {
|
||||||
|
const r: CalendarCell[][] = []
|
||||||
|
for (let i = 0; i < 6; i++) r.push(props.cells.slice(i * 7, i * 7 + 7))
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="calendarGrid card" style="padding: 1.25rem; display: flex; flex-flow: column;">
|
||||||
|
<div style="margin: 0 0 0.75em 0;">
|
||||||
|
<div v-for="(w, i) in WEEK_NAMES" :key="i">
|
||||||
|
<b :style="i >= 5 ? { color: 'red' } : null">{{ w }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(row, ri) in rows" :key="ri">
|
||||||
|
<div
|
||||||
|
v-for="(cell, ci) in row"
|
||||||
|
:key="ci"
|
||||||
|
:isCurrentMonth="cell.isCurrentMonth ? 'true' : 'false'"
|
||||||
|
>
|
||||||
|
<CalendarItem :cell="cell" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.calendarGrid > div:nth-child(n + 2) > div {
|
||||||
|
border-top: 0 solid black;
|
||||||
|
border-left: 0 solid black;
|
||||||
|
border-right: 1px solid black;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
|
||||||
|
padding: 0.75em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarGrid > div:nth-child(n + 2) > div:nth-child(1) {
|
||||||
|
border-left: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarGrid > div:nth-child(2) > div {
|
||||||
|
border-top: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarGrid > div > div {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarGrid > div > div[isCurrentMonth=false] {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendarGrid > div {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { CalendarCell } from './types';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
cell: {
|
||||||
|
type: Object as () => CalendarCell,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
interface EventBoxSlot {
|
||||||
|
filled: boolean
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The grid counts ALL events in the cell (visibility is ignored here, matching
|
||||||
|
// legacy behaviour). Render the (up to) 4 coloured event-box slots.
|
||||||
|
const slots = computed<EventBoxSlot[]>(() => {
|
||||||
|
const events = props.cell.events
|
||||||
|
const arr: EventBoxSlot[] = []
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (i < events.length) {
|
||||||
|
arr.push({ filled: true, color: events[i]!.color })
|
||||||
|
} else {
|
||||||
|
arr.push({ filled: false, color: '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
const moreLabel = computed<string | null>(() => {
|
||||||
|
const len = props.cell.events.length
|
||||||
|
return len > 4 ? `${len} items` : null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p><b>{{ cell.day }}</b><span></span></p>
|
||||||
|
<div
|
||||||
|
v-for="(slot, i) in slots"
|
||||||
|
:key="i"
|
||||||
|
class="calendarItem-eventBox"
|
||||||
|
:enableDisplay="slot.filled ? 'true' : 'false'"
|
||||||
|
:style="slot.filled ? { background: slot.color } : null"
|
||||||
|
></div>
|
||||||
|
<p v-if="moreLabel !== null">{{ moreLabel }}</p>
|
||||||
|
<p v-else> </p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
div.calendarItem-eventBox {
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
height: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.calendarItem-eventBox[enableDisplay=true] {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.calendarItem-eventBox[enableDisplay=false] {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggle'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
emit('toggle');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="paperbox-item card">
|
||||||
|
<div class="paperbox-item-words">
|
||||||
|
<p>{{ name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paperbox-item-icon control" v-show="isVisible" @click="toggle">
|
||||||
|
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-eye"></font-awesome-icon></span></a>
|
||||||
|
</div>
|
||||||
|
<div class="paperbox-item-icon control" v-show="!isVisible" @click="toggle">
|
||||||
|
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||||
|
icon="fas fa-eye-slash"></font-awesome-icon></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ScheduleEventItem } from './types';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
event: {
|
||||||
|
type: Object as () => ScheduleEventItem,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'edit'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
emit('edit');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="schedule-event-outter card" @click="onClick">
|
||||||
|
<div class="schedule-event-color" :style="{ background: event.color }"> </div>
|
||||||
|
<div class="schedule-event-inner">
|
||||||
|
<div class="schedule-event-words">
|
||||||
|
<p class="level-item"><b>{{ event.title }}</b></p>
|
||||||
|
<p class="level-item">{{ event.description }}</p>
|
||||||
|
<p class="level-item"><span>{{ event.start }}</span>-<span>{{ event.end }}</span></p>
|
||||||
|
<p v-if="event.loopText !== ''">
|
||||||
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
|
||||||
|
<span>{{ event.loopText }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-event-icon">
|
||||||
|
<span v-if="event.isLocked" class="icon is-small"><font-awesome-icon
|
||||||
|
icon="fas fa-lock"></font-awesome-icon></span>
|
||||||
|
<span v-if="event.timezoneWarning" class="icon is-small"><font-awesome-icon
|
||||||
|
icon="fas fa-globe"></font-awesome-icon></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.schedule-event-outter {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-event-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-event-words {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-event-icon {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-event-color {
|
||||||
|
width: 0.75rem;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { universalGetMonth, universalGetDayOfWeek } from '@/utils/i18n';
|
||||||
|
import type { CalendarCell } from './types';
|
||||||
|
import ScheduleItem from './ScheduleItem.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
cells: {
|
||||||
|
type: Array as () => CalendarCell[],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'edit', uuid: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const onEdit = (uuid: string) => {
|
||||||
|
emit('edit', uuid);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-for="(cell, i) in cells" :key="i" class="schedule-day container">
|
||||||
|
<div class="schedule-day-words">
|
||||||
|
<b>{{ universalGetMonth(cell.month - 1) }}</b> <b>{{ cell.day }}</b> <b>{{
|
||||||
|
universalGetDayOfWeek(cell.dayOfWeek - 1) }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-event-list">
|
||||||
|
<template v-for="(ev, j) in cell.events" :key="j">
|
||||||
|
<ScheduleItem v-if="ev.isVisible" :event="ev" @edit="onEdit(ev.uuid)" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.schedule-day {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day-words {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day:nth-child(n + 2) {
|
||||||
|
border-top: 1px solid rgba(219, 219, 219, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isVisible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'toggle'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
emit('toggle');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="paperbox-item card">
|
||||||
|
<div class="paperbox-item-words">
|
||||||
|
<b>{{ name }}</b>
|
||||||
|
<p>
|
||||||
|
<span>Shared by: </span>
|
||||||
|
<span>{{ username }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paperbox-item-icon control" v-show="isVisible" @click="toggle">
|
||||||
|
<a class="button"><span class="icon is-small"><font-awesome-icon icon="fas fa-eye"></font-awesome-icon></span></a>
|
||||||
|
</div>
|
||||||
|
<div class="paperbox-item-icon control" v-show="!isVisible" @click="toggle">
|
||||||
|
<a class="button"><span class="icon is-small"><font-awesome-icon
|
||||||
|
icon="fas fa-eye-slash"></font-awesome-icon></span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** A single expanded event occurrence placed into a calendar cell / schedule row. */
|
||||||
|
export interface ScheduleEventItem {
|
||||||
|
uuid: string
|
||||||
|
belongTo: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
isVisible: boolean
|
||||||
|
isLocked: boolean
|
||||||
|
loopText: string
|
||||||
|
timezoneWarning: boolean
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cell of the 6x7 month grid (also one day group in the schedule list). */
|
||||||
|
export interface CalendarCell {
|
||||||
|
/** 1-based month. */
|
||||||
|
month: number
|
||||||
|
day: number
|
||||||
|
/** 1-based day of week, Monday = 1 ... Sunday = 7. */
|
||||||
|
dayOfWeek: number
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
events: ScheduleEventItem[]
|
||||||
|
}
|
||||||
+335
-11
@@ -1,26 +1,350 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useTokenStore } from '@/stores/token';
|
||||||
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
||||||
|
import CalendarGrid from '@/components/calendar/CalendarGrid.vue';
|
||||||
|
import ScheduleList from '@/components/calendar/ScheduleList.vue';
|
||||||
|
import OwnedItem from '@/components/calendar/OwnedItem.vue';
|
||||||
|
import SharedItem from '@/components/calendar/SharedItem.vue';
|
||||||
|
import type { CalendarCell, ScheduleEventItem } from '@/components/calendar/types';
|
||||||
|
import {
|
||||||
|
getFull as apiCalendarGetFull,
|
||||||
|
deserializeDescription,
|
||||||
|
type CalendarRow,
|
||||||
|
} from '@/api/calendar';
|
||||||
|
import {
|
||||||
|
getFullOwn as apiCollectionGetFullOwn,
|
||||||
|
getShared as apiCollectionGetShared,
|
||||||
|
type CollectionRow,
|
||||||
|
type SharedCollectionRow,
|
||||||
|
} from '@/api/collection';
|
||||||
|
import { DAY1_SPAN, dayOfWeek, resolveLoopRules2Event, resolveLoopRules4Text } from '@/utils/datetime';
|
||||||
|
import { universalGetMonth, WEEK_NAMES } from '@/utils/i18n';
|
||||||
|
import { getWeekday } from '@/utils/utils';
|
||||||
|
|
||||||
|
enum CalendarTab {
|
||||||
|
Calendar = 1,
|
||||||
|
Collection = 2,
|
||||||
|
Display = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = useTokenStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// tab control state
|
||||||
|
const activeTab = ref<CalendarTab>(CalendarTab.Calendar);
|
||||||
|
|
||||||
|
// the month currently being viewed (drives the visible 6-week window)
|
||||||
|
const viewedMonth = ref<Date>(new Date());
|
||||||
|
|
||||||
|
// raw events for the current window (Refresh phase)
|
||||||
|
const rawEvents = ref<CalendarRow[]>([]);
|
||||||
|
|
||||||
|
// collection sidebar data + per-collection visibility toggles
|
||||||
|
const ownedCollections = ref<CollectionRow[]>([]);
|
||||||
|
const sharedCollections = ref<SharedCollectionRow[]>([]);
|
||||||
|
const ownedVisible = ref<Map<string, boolean>>(new Map());
|
||||||
|
const sharedVisible = ref<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
// NOTE: This is a temporary test harness for the DateTimePicker.
|
|
||||||
// It will be removed when the Calendar page is migrated for real.
|
|
||||||
const picker = ref<InstanceType<typeof DateTimePicker>>();
|
const picker = ref<InstanceType<typeof DateTimePicker>>();
|
||||||
const testDate = ref<Date>(new Date());
|
|
||||||
|
|
||||||
const openPicker = () => {
|
// the browser timezone offset in minutes (matches legacy -(getTimezoneOffset))
|
||||||
picker.value?.modal(testDate.value, TabType.Minute);
|
const myTimezone = computed<number>(() => -(new Date().getTimezoneOffset()));
|
||||||
|
|
||||||
|
// set of owned collection uuids (stable across visibility toggles)
|
||||||
|
const ownedUuids = computed<Set<string>>(() => new Set(ownedCollections.value.map(c => c[0])));
|
||||||
|
|
||||||
|
const monthLabel = computed<string>(() => {
|
||||||
|
return `${viewedMonth.value.getFullYear()} - ${universalGetMonth(viewedMonth.value.getMonth())}`;
|
||||||
|
})
|
||||||
|
|
||||||
|
// window covering the 6x7 grid: from the Monday of the week containing the 1st
|
||||||
|
interface WindowRange {
|
||||||
|
startTs: number
|
||||||
|
endTs: number
|
||||||
|
year: number
|
||||||
|
month1: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const onConfirm = (d: Date) => {
|
const windowRange = computed<WindowRange>(() => {
|
||||||
testDate.value = d;
|
const date = viewedMonth.value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month1 = date.getMonth() + 1
|
||||||
|
// The legacy picker stored only year+month for this slot (mode=month), and
|
||||||
|
// its Get() defaulted day/hour/minute to 1/0/0/0. Reproduce that here so the
|
||||||
|
// visible 6-week window aligns to the first of the month.
|
||||||
|
const firstOfMonth = new Date(year, date.getMonth(), 1, 0, 0, 0, 0)
|
||||||
|
const week = dayOfWeek(year, month1, 1)
|
||||||
|
const startTs = Math.floor(firstOfMonth.getTime() / 60000) - week * DAY1_SPAN
|
||||||
|
const endTs = startTs + DAY1_SPAN * 6 * 7 - 1
|
||||||
|
return { startTs, endTs, year, month1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Analyse phase: build the 42 cells and expand every event (loop expansion +
|
||||||
|
// multi-day splitting). Deliberately does NOT read the visibility maps so it
|
||||||
|
// stays cached across visibility toggles.
|
||||||
|
const expandedCells = computed<CalendarCell[]>(() => {
|
||||||
|
const { startTs, endTs, month1: gottenMonth } = windowRange.value
|
||||||
|
const owned = ownedUuids.value
|
||||||
|
const myTz = myTimezone.value
|
||||||
|
|
||||||
|
// build the 42 day cells
|
||||||
|
const cells: CalendarCell[] = []
|
||||||
|
const iter = new Date(startTs * 60000)
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
cells.push({
|
||||||
|
month: iter.getMonth() + 1,
|
||||||
|
day: iter.getDate(),
|
||||||
|
dayOfWeek: getWeekday(iter) + 1,
|
||||||
|
isCurrentMonth: (iter.getMonth() + 1) === gottenMonth,
|
||||||
|
events: [],
|
||||||
|
})
|
||||||
|
iter.setTime(iter.getTime() + DAY1_SPAN * 60000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expand each event into its occurrences and split across days
|
||||||
|
for (const item of rawEvents.value) {
|
||||||
|
const desc = deserializeDescription(item[3])
|
||||||
|
const minStartTs = startTs - (item[6] - item[5])
|
||||||
|
const result = resolveLoopRules2Event(
|
||||||
|
item[8],
|
||||||
|
item[9] < minStartTs ? minStartTs : item[9],
|
||||||
|
Math.min(item[10], endTs),
|
||||||
|
item[5],
|
||||||
|
item[6],
|
||||||
|
item[7],
|
||||||
|
startTs,
|
||||||
|
)
|
||||||
|
if (typeof result === 'undefined') continue
|
||||||
|
const loopText = resolveLoopRules4Text(item[8], item[5], item[7])
|
||||||
|
const tzWarn = myTz !== item[7]
|
||||||
|
// lock icon = event is in one of MY collections (belongTo). (Legacy
|
||||||
|
// mistakenly keyed this by the event uuid; fixed per migration decision.)
|
||||||
|
const isLocked = owned.has(item[1])
|
||||||
|
|
||||||
|
for (const range of result) {
|
||||||
|
const it0 = range[0]
|
||||||
|
const it1 = range[1]
|
||||||
|
const eventDateTime = new Date(it0 * 60000)
|
||||||
|
let count = Math.floor((it0 - startTs) / DAY1_SPAN)
|
||||||
|
let exitFlag = false
|
||||||
|
while (count < 42) {
|
||||||
|
const eventItem: ScheduleEventItem = {
|
||||||
|
uuid: item[0],
|
||||||
|
belongTo: item[1],
|
||||||
|
title: item[2],
|
||||||
|
description: desc.description,
|
||||||
|
color: desc.color,
|
||||||
|
isVisible: true,
|
||||||
|
isLocked,
|
||||||
|
loopText,
|
||||||
|
timezoneWarning: tzWarn,
|
||||||
|
start: eventDateTime.toLocaleTimeString(),
|
||||||
|
end: '',
|
||||||
|
}
|
||||||
|
eventDateTime.setHours(23, 59, 0, 0)
|
||||||
|
if (it1 <= Math.floor(eventDateTime.getTime() / 60000)) {
|
||||||
|
exitFlag = true
|
||||||
|
eventDateTime.setTime(it1 * 60000)
|
||||||
|
}
|
||||||
|
eventItem.end = eventDateTime.toLocaleTimeString()
|
||||||
|
cells[count]!.events.push(eventItem)
|
||||||
|
if (exitFlag) break
|
||||||
|
eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
})
|
||||||
|
|
||||||
|
// Render phase: apply per-collection visibility to each event.
|
||||||
|
const displayCache = computed<CalendarCell[]>(() => {
|
||||||
|
const owned = ownedVisible.value
|
||||||
|
const shared = sharedVisible.value
|
||||||
|
return expandedCells.value.map(cell => ({
|
||||||
|
...cell,
|
||||||
|
events: cell.events.map(ev => ({
|
||||||
|
...ev,
|
||||||
|
isVisible: (owned.get(ev.belongTo) ?? false) || (shared.get(ev.belongTo) ?? false),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
const { startTs, endTs } = windowRange.value
|
||||||
|
const rv = await apiCalendarGetFull(token.currentToken, startTs, endTs)
|
||||||
|
rawEvents.value = typeof rv === 'undefined' ? [] : rv
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshCollections = async () => {
|
||||||
|
const ownedRv = await apiCollectionGetFullOwn(token.currentToken)
|
||||||
|
ownedCollections.value = ownedRv ?? []
|
||||||
|
const ownedMap = new Map<string, boolean>()
|
||||||
|
for (const c of ownedCollections.value) ownedMap.set(c[0], true)
|
||||||
|
ownedVisible.value = ownedMap
|
||||||
|
|
||||||
|
const sharedRv = await apiCollectionGetShared(token.currentToken)
|
||||||
|
sharedCollections.value = sharedRv ?? []
|
||||||
|
const sharedMap = new Map<string, boolean>()
|
||||||
|
for (const c of sharedCollections.value) sharedMap.set(c[0], true)
|
||||||
|
sharedVisible.value = sharedMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOwned = (uuid: string) => {
|
||||||
|
const cur = ownedVisible.value.get(uuid) ?? false
|
||||||
|
ownedVisible.value.set(uuid, !cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleShared = (uuid: string) => {
|
||||||
|
const cur = sharedVisible.value.get(uuid) ?? false
|
||||||
|
sharedVisible.value.set(uuid, !cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMonthPicker = () => {
|
||||||
|
picker.value?.modal(viewedMonth.value, TabType.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPickerConfirm = (d: Date) => {
|
||||||
|
viewedMonth.value = d
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToday = () => {
|
||||||
|
viewedMonth.value = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = (): void => {
|
||||||
|
// TODO: prev/next month navigation not implemented (legacy left these buttons unbound).
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = (): void => {
|
||||||
|
// TODO: prev/next month navigation not implemented (legacy left these buttons unbound).
|
||||||
|
}
|
||||||
|
|
||||||
|
const goAddEvent = () => {
|
||||||
|
router.push({ name: 'CalendarEventAdd' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEventEdit = (uuid: string) => {
|
||||||
|
router.push({ name: 'CalendarEventUpdate', params: { uuid } })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(viewedMonth, () => {
|
||||||
|
fetchEvents()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshCollections()
|
||||||
|
await fetchEvents()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container" style="margin-top: 1.25rem;">
|
<div class="container" style="margin-top: 20px;">
|
||||||
<a class="button is-primary" @click="openPicker">{{ testDate.toLocaleString() }}</a>
|
<div class="tabs">
|
||||||
|
<ul>
|
||||||
|
<li :class="{ 'is-active': activeTab === CalendarTab.Calendar }" @click="activeTab = CalendarTab.Calendar">
|
||||||
|
<a>Calendar</a>
|
||||||
|
</li>
|
||||||
|
<li :class="{ 'is-active': activeTab === CalendarTab.Collection }"
|
||||||
|
@click="activeTab = CalendarTab.Collection"><a>Collection</a></li>
|
||||||
|
<li :class="{ 'is-active': activeTab === CalendarTab.Display }" @click="activeTab = CalendarTab.Display"><a>Display</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<DateTimePicker ref="picker" @confirm="onConfirm" />
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === CalendarTab.Calendar" class="container" style="margin-top: 20px;">
|
||||||
|
<nav class="level is-mobile">
|
||||||
|
<div class="level-left">
|
||||||
|
<div class="level-item control">
|
||||||
|
<a class="button" @click="prevMonth">
|
||||||
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-left"></font-awesome-icon></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-item control">
|
||||||
|
<a class="button" @click="openMonthPicker">
|
||||||
|
<span>{{ monthLabel }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="level-right">
|
||||||
|
<div class="level-item control">
|
||||||
|
<a class="button" @click="nextMonth">
|
||||||
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav class="level is-mobile">
|
||||||
|
<div class="level-item control">
|
||||||
|
<a class="button is-info" @click="goToday">Today</a>
|
||||||
|
</div>
|
||||||
|
<div class="level-item control">
|
||||||
|
<a class="button is-primary" @click="goAddEvent">Add...</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<CalendarGrid :cells="displayCache" />
|
||||||
|
|
||||||
|
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||||
|
<h1 class="title">Schedule</h1>
|
||||||
|
<ScheduleList :cells="displayCache" @edit="onEventEdit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === CalendarTab.Collection" class="container" style="margin-top: 20px;">
|
||||||
|
<div class="control" style="margin: 0.75rem;" @click="refreshCollections">
|
||||||
|
<a class="button is-primary">
|
||||||
|
<span class="icon is-small"><font-awesome-icon icon="fas fa-sync"></font-awesome-icon></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">My collections</h1>
|
||||||
|
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
|
||||||
|
<div v-for="c in ownedCollections" :key="c[0]"
|
||||||
|
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||||
|
<OwnedItem :name="c[1]" :is-visible="ownedVisible.get(c[0]) ?? false" @toggle="toggleOwned(c[0])" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">Shared collections</h1>
|
||||||
|
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
|
||||||
|
<div v-for="c in sharedCollections" :key="c[0]"
|
||||||
|
style="display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||||
|
<SharedItem :name="c[1]" :username="c[2]" :is-visible="sharedVisible.get(c[0]) ?? false"
|
||||||
|
@toggle="toggleShared(c[0])" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="activeTab === CalendarTab.Display" class="container" style="margin-top: 20px;">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">The first day of week</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select>
|
||||||
|
<option v-for="(w, i) in WEEK_NAMES" :key="i">{{ w }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Sub-Calendar</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select>
|
||||||
|
<option>None</option>
|
||||||
|
<option>Chinese Lunisolar Calendar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
Reference in New Issue
Block a user