Compare commits
2
Commits
91317c9eb7
...
0f674d0483
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f674d0483 | ||
|
|
e6fc06c0f9 |
@@ -33,7 +33,7 @@ const logout = async () => {
|
||||
// Check for click events on the navbar burger icon
|
||||
const toggleBurger = () => {
|
||||
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
||||
isBurgerActive.value = !isBurgerActive
|
||||
isBurgerActive.value = !isBurgerActive.value
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
<script lang="ts">
|
||||
export enum TabType {
|
||||
Year,
|
||||
Month,
|
||||
Day,
|
||||
Hour,
|
||||
Minute
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import {
|
||||
MAX_DATETIME,
|
||||
MIN_DATETIME,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
MONTH_DAY_COUNT,
|
||||
dayOfWeek,
|
||||
isLeapYear
|
||||
} from '@/utils/datetime';
|
||||
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
|
||||
|
||||
const DIAL_PLATE_WIDTH: number = 200;
|
||||
const DIAL_PLATE_RADIUS: number = DIAL_PLATE_WIDTH / 2;
|
||||
const DIAL_PLATE_HOUR_INNER_PERCENT: number = 0.6;
|
||||
const DIAL_PLATE_HOUR_OUTTER_PERCENT: number = 0.8;
|
||||
const DIAL_PLATE_HOUR_DISTINGUISH_PERCENT: number = 0.7;
|
||||
const DIAL_PLATE_MINUTE_PERCENT: number = 0.8;
|
||||
const DIAL_PLATE_HOUR_RESOLUTION: number = Math.PI * 2 / 12;
|
||||
const DIAL_PLATE_MINUTE_RESOLUTION: number = Math.PI * 2 / 60;
|
||||
|
||||
const NBSP: string = '\u00A0';
|
||||
|
||||
const isVisible = ref(false);
|
||||
const currentTab = ref<TabType>(TabType.Year);
|
||||
const mode = ref<TabType>(TabType.Day);
|
||||
const svgFontScale = ref<number>(1);
|
||||
|
||||
let enableHourDrag = false;
|
||||
let enableMinuteDrag = false;
|
||||
|
||||
const internalDateTime = ref<Date>(new Date());
|
||||
const displayCacheDateTime = ref<Date>(new Date());
|
||||
|
||||
const pickerContainerRef = ref<HTMLElement>();
|
||||
const hourSvgRef = ref<SVGSVGElement>();
|
||||
const minuteSvgRef = ref<SVGSVGElement>();
|
||||
|
||||
// region: Exported API and Signals
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', date: Date): void
|
||||
(e: 'cancel'): void
|
||||
}>();
|
||||
|
||||
const clampDateTime = (d: Date): void => {
|
||||
if (d < MIN_DATETIME) d.setTime(MIN_DATETIME.getTime());
|
||||
if (d >= MAX_DATETIME) d.setTime(MAX_DATETIME.getTime());
|
||||
};
|
||||
|
||||
const switchTab = (newTab: TabType): void => {
|
||||
displayCacheDateTime.value = new Date(internalDateTime.value);
|
||||
currentTab.value = newTab;
|
||||
};
|
||||
|
||||
const modal = (initialDate: Date, _mode: TabType): void => {
|
||||
mode.value = _mode;
|
||||
const d = new Date(initialDate);
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = new Date(d);
|
||||
displayCacheDateTime.value = new Date(d);
|
||||
isVisible.value = true;
|
||||
switchTab(_mode);
|
||||
};
|
||||
|
||||
const confirm = (): void => {
|
||||
isVisible.value = false;
|
||||
emit('confirm', new Date(internalDateTime.value));
|
||||
};
|
||||
|
||||
const cancel = (): void => {
|
||||
isVisible.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
defineExpose({ modal });
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Mutator helpers
|
||||
// Vue does not track in-place Date mutation, so every change clones, mutates,
|
||||
// clamps, and reassigns the ref to trigger reactivity.
|
||||
|
||||
const updateDisplay = (mutator: (d: Date) => void): void => {
|
||||
const d = new Date(displayCacheDateTime.value);
|
||||
mutator(d);
|
||||
clampDateTime(d);
|
||||
displayCacheDateTime.value = d;
|
||||
};
|
||||
|
||||
const updateInternal = (mutator: (d: Date) => void): void => {
|
||||
const d = new Date(internalDateTime.value);
|
||||
mutator(d);
|
||||
clampDateTime(d);
|
||||
internalDateTime.value = d;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Computed views (replaces legacy imperative RefreshDisplay)
|
||||
|
||||
const visibleTabs = computed<Set<TabType>>(() => {
|
||||
const s = new Set<TabType>();
|
||||
if (mode.value >= TabType.Year) s.add(TabType.Year);
|
||||
if (mode.value >= TabType.Month) s.add(TabType.Month);
|
||||
if (mode.value >= TabType.Day) s.add(TabType.Day);
|
||||
if (mode.value >= TabType.Hour) s.add(TabType.Hour);
|
||||
if (mode.value >= TabType.Minute) s.add(TabType.Minute);
|
||||
return s;
|
||||
});
|
||||
|
||||
const headerParts = computed(() => ({
|
||||
year: internalDateTime.value.getFullYear(),
|
||||
month: internalDateTime.value.getMonth() + 1,
|
||||
day: internalDateTime.value.getDate(),
|
||||
hour: internalDateTime.value.getHours(),
|
||||
minute: internalDateTime.value.getMinutes(),
|
||||
}));
|
||||
|
||||
const yearPageStart = computed<number>(() => {
|
||||
const y = displayCacheDateTime.value.getFullYear();
|
||||
return Math.floor((y - MIN_YEAR) / 12) * 12 + MIN_YEAR;
|
||||
});
|
||||
|
||||
const yearCells = computed<{ value: number | undefined; picked: boolean }[]>(() => {
|
||||
const start = yearPageStart.value;
|
||||
const internalYear = internalDateTime.value.getFullYear();
|
||||
const cells: { value: number | undefined; picked: boolean }[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const counter = start + i;
|
||||
if (counter < MAX_YEAR) {
|
||||
cells.push({ value: counter, picked: counter === internalYear });
|
||||
} else {
|
||||
cells.push({ value: undefined, picked: false });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
const yearRows = computed<{ value: number | undefined; picked: boolean }[][]>(() => {
|
||||
const cells = yearCells.value;
|
||||
const rows: { value: number | undefined; picked: boolean }[][] = [];
|
||||
for (let i = 0; i < 3; i++) rows.push(cells.slice(i * 4, i * 4 + 4));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const yearTitle = computed<string>(() => {
|
||||
const start = yearPageStart.value;
|
||||
return `${start} - ${Math.min(start + 12, MAX_YEAR)}`;
|
||||
});
|
||||
|
||||
const pickedMonthIndex = computed<number>(() => {
|
||||
if (internalDateTime.value.getFullYear() === displayCacheDateTime.value.getFullYear()) {
|
||||
return internalDateTime.value.getMonth();
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
const monthCells = computed<{ index: number; name: string; picked: boolean }[]>(() => {
|
||||
const picked = pickedMonthIndex.value;
|
||||
const cells: { index: number; name: string; picked: boolean }[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
cells.push({ index: i, name: MONTH_NAMES[i]!, picked: i === picked });
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
const monthRows = computed<{ index: number; name: string; picked: boolean }[][]>(() => {
|
||||
const cells = monthCells.value;
|
||||
const rows: { index: number; name: string; picked: boolean }[][] = [];
|
||||
for (let i = 0; i < 3; i++) rows.push(cells.slice(i * 4, i * 4 + 4));
|
||||
return rows;
|
||||
});
|
||||
|
||||
const monthTitle = computed<number>(() => displayCacheDateTime.value.getFullYear());
|
||||
|
||||
const dayGrid = computed<{ value: number | undefined; picked: boolean }[][]>(() => {
|
||||
const y = displayCacheDateTime.value.getFullYear();
|
||||
const m = displayCacheDateTime.value.getMonth() + 1;
|
||||
const firstDow = dayOfWeek(y, m, 1);
|
||||
const daysInMonth = MONTH_DAY_COUNT[m - 1]! + (m === 2 && isLeapYear(y) ? 1 : 0);
|
||||
const internalDay = internalDateTime.value.getDate();
|
||||
const rows: { value: number | undefined; picked: boolean }[][] = [];
|
||||
let counter = -firstDow;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const row: { value: number | undefined; picked: boolean }[] = [];
|
||||
for (let j = 0; j < 7; j++, counter++) {
|
||||
if (counter < 0 || counter >= daysInMonth) {
|
||||
row.push({ value: undefined, picked: false });
|
||||
} else {
|
||||
const dayNum = counter + 1;
|
||||
row.push({ value: dayNum, picked: dayNum === internalDay });
|
||||
}
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const dayTitle = computed<string>(() => {
|
||||
const y = displayCacheDateTime.value.getFullYear();
|
||||
const m = displayCacheDateTime.value.getMonth();
|
||||
return `${y} - ${MONTH_NAMES[m]!}`;
|
||||
});
|
||||
|
||||
const hourHand = computed(() => {
|
||||
const h = displayCacheDateTime.value.getHours();
|
||||
const angle = (3 - h) * DIAL_PLATE_HOUR_RESOLUTION;
|
||||
const radius = DIAL_PLATE_RADIUS * (h < 12 ? DIAL_PLATE_HOUR_OUTTER_PERCENT : DIAL_PLATE_HOUR_INNER_PERCENT);
|
||||
const x = Math.cos(angle) * radius + DIAL_PLATE_RADIUS;
|
||||
const y = (-Math.sin(angle) * radius) + DIAL_PLATE_RADIUS;
|
||||
return { x2: x, y2: y, cx: x, cy: y };
|
||||
});
|
||||
|
||||
const minuteHand = computed(() => {
|
||||
const mi = displayCacheDateTime.value.getMinutes();
|
||||
const angle = (15 - mi) * DIAL_PLATE_MINUTE_RESOLUTION;
|
||||
const radius = DIAL_PLATE_RADIUS * DIAL_PLATE_MINUTE_PERCENT;
|
||||
const x = Math.cos(angle) * radius + DIAL_PLATE_RADIUS;
|
||||
const y = (-Math.sin(angle) * radius) + DIAL_PLATE_RADIUS;
|
||||
return { x2: x, y2: y, cx: x, cy: y };
|
||||
});
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Navigation and click handlers
|
||||
|
||||
const prevNextYear = (isPrev: boolean): void => {
|
||||
updateDisplay((d) => d.setFullYear(d.getFullYear() + (isPrev ? -12 : 12)));
|
||||
};
|
||||
|
||||
// NOTE: legacy PrevNextMonth actually shifts the YEAR (the month panel browses years).
|
||||
const prevNextMonth = (isPrev: boolean): void => {
|
||||
updateDisplay((d) => d.setFullYear(d.getFullYear() + (isPrev ? -1 : 1)));
|
||||
};
|
||||
|
||||
// NOTE: legacy PrevNextDay actually shifts the MONTH (the day panel browses months).
|
||||
const prevNextDay = (isPrev: boolean): void => {
|
||||
updateDisplay((d) => d.setMonth(d.getMonth() + (isPrev ? -1 : 1)));
|
||||
};
|
||||
|
||||
const clickYear = (value: number | undefined): void => {
|
||||
if (value === undefined) return;
|
||||
updateInternal((d) => d.setFullYear(value));
|
||||
if (mode.value !== TabType.Year) switchTab(TabType.Month);
|
||||
};
|
||||
|
||||
const clickMonth = (monthIndex: number): void => {
|
||||
updateInternal((d) => {
|
||||
d.setFullYear(displayCacheDateTime.value.getFullYear());
|
||||
d.setMonth(monthIndex);
|
||||
});
|
||||
if (mode.value !== TabType.Month) switchTab(TabType.Day);
|
||||
};
|
||||
|
||||
const clickDay = (day: number | undefined): void => {
|
||||
if (day === undefined) return;
|
||||
updateInternal((d) => {
|
||||
d.setFullYear(displayCacheDateTime.value.getFullYear());
|
||||
d.setMonth(displayCacheDateTime.value.getMonth());
|
||||
d.setDate(day);
|
||||
});
|
||||
if (mode.value !== TabType.Day) switchTab(TabType.Hour);
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Dial drag interaction
|
||||
|
||||
const getUniformedXY = (e: MouseEvent | TouchEvent, el: Element): { x: number; y: number } => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const halfWidth = rect.width / 2;
|
||||
const halfHeight = rect.height / 2;
|
||||
const halfSquare = Math.min(rect.width, rect.height) / 2;
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
const te = e as TouchEvent;
|
||||
const me = e as MouseEvent;
|
||||
if (te.targetTouches && te.targetTouches.length >= 1) {
|
||||
clientX = te.targetTouches[0]!.clientX;
|
||||
clientY = te.targetTouches[0]!.clientY;
|
||||
} else {
|
||||
clientX = me.clientX;
|
||||
clientY = me.clientY;
|
||||
}
|
||||
const x = ((clientX - rect.left - halfWidth) / halfSquare) * DIAL_PLATE_RADIUS;
|
||||
const y = -(((clientY - rect.top - halfHeight) / halfSquare) * DIAL_PLATE_RADIUS);
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const startDragHour = (): void => { enableHourDrag = true; };
|
||||
const draggingHour = (e: MouseEvent | TouchEvent): void => {
|
||||
if (!enableHourDrag || !hourSvgRef.value) return;
|
||||
const { x, y } = getUniformedXY(e, hourSvgRef.value);
|
||||
const distance = Math.sqrt(x * x + y * y);
|
||||
let angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle;
|
||||
angle += DIAL_PLATE_HOUR_RESOLUTION / 2;
|
||||
if (angle > Math.PI * 2) angle -= Math.PI * 2;
|
||||
let number = Math.floor(angle / DIAL_PLATE_HOUR_RESOLUTION);
|
||||
if (number >= 12) number = 11;
|
||||
number = (15 - number) % 12;
|
||||
if (distance < DIAL_PLATE_RADIUS * DIAL_PLATE_HOUR_DISTINGUISH_PERCENT) number += 12;
|
||||
if (displayCacheDateTime.value.getHours() !== number) {
|
||||
updateDisplay((d) => d.setHours(number));
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
const stopDragHour = (): void => {
|
||||
enableHourDrag = false;
|
||||
updateInternal((d) => d.setHours(displayCacheDateTime.value.getHours()));
|
||||
if (mode.value !== TabType.Hour) switchTab(TabType.Minute);
|
||||
};
|
||||
|
||||
const startDragMinute = (): void => { enableMinuteDrag = true; };
|
||||
const draggingMinute = (e: MouseEvent | TouchEvent): void => {
|
||||
if (!enableMinuteDrag || !minuteSvgRef.value) return;
|
||||
const { x, y } = getUniformedXY(e, minuteSvgRef.value);
|
||||
const distance = Math.sqrt(x * x + y * y);
|
||||
let angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle;
|
||||
angle += DIAL_PLATE_MINUTE_RESOLUTION / 2;
|
||||
if (angle > Math.PI * 2) angle -= Math.PI * 2;
|
||||
let number = Math.floor(angle / DIAL_PLATE_MINUTE_RESOLUTION);
|
||||
if (number >= 60) number = 59;
|
||||
number = (75 - number) % 60;
|
||||
if (displayCacheDateTime.value.getMinutes() !== number) {
|
||||
updateDisplay((d) => d.setMinutes(number));
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
const stopDragMinute = (): void => {
|
||||
enableMinuteDrag = false;
|
||||
updateInternal((d) => d.setMinutes(displayCacheDateTime.value.getMinutes()));
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Lifecycle - SVG font-size scaling
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = entry.contentRect.height;
|
||||
if (w > 0 && h > 0) svgFontScale.value = 200 / Math.min(w, h);
|
||||
}
|
||||
});
|
||||
if (pickerContainerRef.value) resizeObserver.observe(pickerContainerRef.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
});
|
||||
|
||||
// endregion
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal" :class="{ 'is-active': isVisible }"
|
||||
style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card" style="height: 70%;">
|
||||
<header class="modal-card-head pickerHeader">
|
||||
<div v-show="visibleTabs.has(TabType.Year)" @click="switchTab(TabType.Year)">
|
||||
<small>Year</small><span>{{ headerParts.year }}</span>
|
||||
</div>
|
||||
<div v-show="visibleTabs.has(TabType.Month)" @click="switchTab(TabType.Month)">
|
||||
<small>Month</small><span>{{ headerParts.month }}</span>
|
||||
</div>
|
||||
<div v-show="visibleTabs.has(TabType.Day)" @click="switchTab(TabType.Day)">
|
||||
<small>Day</small><span>{{ headerParts.day }}</span>
|
||||
</div>
|
||||
<div v-show="visibleTabs.has(TabType.Hour)" @click="switchTab(TabType.Hour)">
|
||||
<small>Hour</small><span>{{ headerParts.hour }}</span>
|
||||
</div>
|
||||
<div v-show="visibleTabs.has(TabType.Minute)" @click="switchTab(TabType.Minute)">
|
||||
<small>Minute</small><span>{{ headerParts.minute }}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="modal-card-body pickerContainer" ref="pickerContainerRef">
|
||||
<div v-show="currentTab === TabType.Year">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextYear(true)">
|
||||
<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">{{ yearTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextYear(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div v-for="(row, ri) in yearRows" :key="ri">
|
||||
<div v-for="(cell, ci) in row" :key="ci"
|
||||
:class="{ picked: cell.picked }"
|
||||
@click="clickYear(cell.value)">{{ cell.value ?? NBSP }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === TabType.Month">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextMonth(true)">
|
||||
<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">{{ monthTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextMonth(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div v-for="(row, ri) in monthRows" :key="ri">
|
||||
<div v-for="(cell, ci) in row" :key="ci"
|
||||
:class="{ picked: cell.picked }"
|
||||
@click="clickMonth(cell.index)">{{ cell.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="currentTab === TabType.Day">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextDay(true)">
|
||||
<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">{{ dayTitle }}</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button" @click="prevNextDay(false)">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-chevron-circle-right"></font-awesome-icon></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div>
|
||||
<div v-for="(name, wi) in WEEK_NAMES" :key="wi">{{ name }}</div>
|
||||
</div>
|
||||
<div v-for="(row, ri) in dayGrid" :key="ri">
|
||||
<div v-for="(cell, ci) in row" :key="ci"
|
||||
:class="{ picked: cell.picked }"
|
||||
@click="clickDay(cell.value)">{{ cell.value ?? NBSP }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg v-show="currentTab === TabType.Hour" ref="hourSvgRef"
|
||||
xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200"
|
||||
:style="{ fontSize: svgFontScale + 'em' }"
|
||||
@mousedown="startDragHour" @mousemove="draggingHour" @mouseup="stopDragHour"
|
||||
@touchstart="startDragHour" @touchmove="draggingHour" @touchend="stopDragHour">
|
||||
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
|
||||
<line x1="100" y1="100" :x2="hourHand.x2" :y2="hourHand.y2"></line>
|
||||
<circle :cx="hourHand.cx" :cy="hourHand.cy" r="1em" type="symbol"></circle>
|
||||
|
||||
<text x="100.000000" y="20.000000">0</text>
|
||||
<text x="140.000000" y="30.717968">1</text>
|
||||
<text x="169.282032" y="60.000000">2</text>
|
||||
<text x="180.000000" y="100.000000">3</text>
|
||||
<text x="169.282032" y="140.000000">4</text>
|
||||
<text x="140.000000" y="169.282032">5</text>
|
||||
<text x="100.000000" y="180.000000">6</text>
|
||||
<text x="60.000000" y="169.282032">7</text>
|
||||
<text x="30.717968" y="140.000000">8</text>
|
||||
<text x="20.000000" y="100.000000">9</text>
|
||||
<text x="30.717968" y="60.000000">10</text>
|
||||
<text x="60.000000" y="30.717968">11</text>
|
||||
<text x="100.000000" y="40.000000">12</text>
|
||||
<text x="130.000000" y="48.038476">13</text>
|
||||
<text x="151.961524" y="70.000000">14</text>
|
||||
<text x="160.000000" y="100.000000">15</text>
|
||||
<text x="151.961524" y="130.000000">16</text>
|
||||
<text x="130.000000" y="151.961524">17</text>
|
||||
<text x="100.000000" y="160.000000">18</text>
|
||||
<text x="70.000000" y="151.961524">19</text>
|
||||
<text x="48.038476" y="130.000000">20</text>
|
||||
<text x="40.000000" y="100.000000">21</text>
|
||||
<text x="48.038476" y="70.000000">22</text>
|
||||
<text x="70.000000" y="48.038476">23</text>
|
||||
</svg>
|
||||
|
||||
<svg v-show="currentTab === TabType.Minute" ref="minuteSvgRef"
|
||||
xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
preserveAspectRatio="xMidYMid" viewBox="0 0 200 200"
|
||||
:style="{ fontSize: svgFontScale + 'em' }"
|
||||
@mousedown="startDragMinute" @mousemove="draggingMinute" @mouseup="stopDragMinute"
|
||||
@touchstart="startDragMinute" @touchmove="draggingMinute" @touchend="stopDragMinute">
|
||||
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
|
||||
<line x1="100" y1="100" :x2="minuteHand.x2" :y2="minuteHand.y2"></line>
|
||||
<circle :cx="minuteHand.cx" :cy="minuteHand.cy" r="1em" type="symbol"></circle>
|
||||
|
||||
<text x="100.000000" y="20.000000">0</text>
|
||||
<text x="140.000000" y="30.717968">5</text>
|
||||
<text x="169.282032" y="60.000000">10</text>
|
||||
<text x="180.000000" y="100.000000">15</text>
|
||||
<text x="169.282032" y="140.000000">20</text>
|
||||
<text x="140.000000" y="169.282032">25</text>
|
||||
<text x="100.000000" y="180.000000">30</text>
|
||||
<text x="60.000000" y="169.282032">35</text>
|
||||
<text x="30.717968" y="140.000000">40</text>
|
||||
<text x="20.000000" y="100.000000">45</text>
|
||||
<text x="30.717968" y="60.000000">50</text>
|
||||
<text x="60.000000" y="30.717968">55</text>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
<footer class="modal-card-foot">
|
||||
<a class="button is-success" @click="confirm">
|
||||
<span>OK</span>
|
||||
</a>
|
||||
<a class="button" @click="cancel">
|
||||
<span>Cancel</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div.perfectTable {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
/*
|
||||
div.perfectTable > div > div:nth-child(1) {
|
||||
border-left: 1px solid black;
|
||||
}
|
||||
|
||||
div.perfectTable > div:nth-child(1) > div {
|
||||
border-top: 1px solid black;
|
||||
}
|
||||
*/
|
||||
|
||||
div.perfectTable>div>div {
|
||||
/*border-top: 0 solid black;
|
||||
border-left: 0 solid black;
|
||||
border-right: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
|
||||
padding: 0.75em;*/
|
||||
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
div.perfectTable>div {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
div.perfectTable>div>div.picked {
|
||||
background: hsl(171, 100%, 41%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
div.pickerContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.pickerContainer>div {
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
div.pickerContainer>svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.pickerContainer>svg>text {
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
div.pickerContainer>svg>circle[type=background] {
|
||||
stroke-width: 0;
|
||||
fill: #d0d0d0;
|
||||
}
|
||||
|
||||
div.pickerContainer>svg>circle[type=symbol] {
|
||||
stroke-width: 0;
|
||||
fill: hsl(171, 100%, 41%);
|
||||
/* $primary */
|
||||
}
|
||||
|
||||
div.pickerContainer>svg>line {
|
||||
stroke-width: 0.125em;
|
||||
stroke: hsl(171, 100%, 41%);
|
||||
/* $primary */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
header.pickerHeader {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
flex-grow: 0;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
header.pickerHeader>div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -5,14 +5,24 @@ const isVisible = ref(false);
|
||||
const title = ref<string>("");
|
||||
const content = ref<string>("");
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'closed', isOk: boolean): void
|
||||
}>()
|
||||
|
||||
const show = (_content: string, _title?: string) => {
|
||||
title.value = _title ?? "Notification";
|
||||
content.value = _content;
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
const close = () => {
|
||||
isVisible.value = false;
|
||||
emit('closed', false);
|
||||
}
|
||||
|
||||
const ok = () => {
|
||||
isVisible.value = false;
|
||||
emit('closed', true);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -26,13 +36,13 @@ defineExpose({
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ title }}</p>
|
||||
<button class="delete" aria-label="close" @click="hide"></button>
|
||||
<button class="delete" aria-label="close" @click="close"></button>
|
||||
</header>
|
||||
<div class="modal-card-body">
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button is-success" @click="hide">OK</button>
|
||||
<button class="button is-success" @click="ok">OK</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
||||
import { resolveLoopRules4UI, getDayInMonth } from '@/utils/datetime';
|
||||
import { WEEK_NAMES } from '@/utils/calendar-names';
|
||||
import { format } from '@/utils/utils';
|
||||
|
||||
type LoopMethod = 'never' | 'day' | 'week' | 'month' | 'year';
|
||||
type StopMethod = 'forever' | 'datetime' | 'times';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
startDate: Date;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>();
|
||||
|
||||
const stopPicker = ref<InstanceType<typeof DateTimePicker>>();
|
||||
|
||||
const loopMethod = ref<LoopMethod>('never');
|
||||
const daySpan = ref<number>(1);
|
||||
const weekSpan = ref<number>(1);
|
||||
const monthSpan = ref<number>(1);
|
||||
const yearSpan = ref<number>(1);
|
||||
// default: today's weekday checked (Monday-first 0-6)
|
||||
const initialWeekday = (new Date().getDay() + 6) % 7;
|
||||
const weekChecks = ref<boolean[]>(Array.from({ length: 7 }, (_, i) => i === initialWeekday));
|
||||
const monthMode = ref<'A' | 'B' | 'C' | 'D'>('A');
|
||||
const isStrict = ref<boolean>(true);
|
||||
const stopMethod = ref<StopMethod>('forever');
|
||||
const stopTimes = ref<number>(1);
|
||||
const stopDateTime = ref<Date>(new Date());
|
||||
|
||||
// region: Serialize / parse
|
||||
|
||||
const buildString = (): string => {
|
||||
let loopPart = '';
|
||||
switch (loopMethod.value) {
|
||||
case 'never':
|
||||
return '';
|
||||
case 'day':
|
||||
loopPart = `D${daySpan.value}`;
|
||||
break;
|
||||
case 'week': {
|
||||
const checks = weekChecks.value.map(b => b ? 'T' : 'F').join('');
|
||||
loopPart = `W${checks}${weekSpan.value}`;
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
loopPart = `M${isStrict.value ? 'S' : 'R'}${monthMode.value}${monthSpan.value}`;
|
||||
break;
|
||||
case 'year':
|
||||
loopPart = `Y${isStrict.value ? 'S' : 'R'}${yearSpan.value}`;
|
||||
break;
|
||||
}
|
||||
let stopPart = '';
|
||||
switch (stopMethod.value) {
|
||||
case 'forever':
|
||||
stopPart = '-F';
|
||||
break;
|
||||
case 'datetime':
|
||||
stopPart = `-D${Math.floor(stopDateTime.value.getTime() / 60000)}`;
|
||||
break;
|
||||
case 'times':
|
||||
stopPart = `-T${stopTimes.value}`;
|
||||
break;
|
||||
}
|
||||
return loopPart + stopPart;
|
||||
};
|
||||
|
||||
const serialized = computed<string>(() => buildString());
|
||||
|
||||
// Cast helpers: the parsed rule tuples are a union; index access is loose here.
|
||||
const parseFromModel = (val: string): void => {
|
||||
if (val === serialized.value) return;
|
||||
if (val === '') {
|
||||
loopMethod.value = 'never';
|
||||
return;
|
||||
}
|
||||
const parsed = resolveLoopRules4UI(val);
|
||||
if (typeof parsed === 'undefined') {
|
||||
loopMethod.value = 'never';
|
||||
return;
|
||||
}
|
||||
const loopRule = parsed[0];
|
||||
const stopRule = parsed[1];
|
||||
switch (loopRule[0]) {
|
||||
case 0:
|
||||
loopMethod.value = 'year';
|
||||
isStrict.value = loopRule[1];
|
||||
yearSpan.value = loopRule[2];
|
||||
break;
|
||||
case 1:
|
||||
loopMethod.value = 'month';
|
||||
isStrict.value = loopRule[1];
|
||||
monthMode.value = loopRule[2];
|
||||
monthSpan.value = loopRule[3];
|
||||
break;
|
||||
case 2:
|
||||
loopMethod.value = 'week';
|
||||
weekChecks.value = [loopRule[1], loopRule[2], loopRule[3], loopRule[4], loopRule[5], loopRule[6], loopRule[7]];
|
||||
weekSpan.value = loopRule[8];
|
||||
break;
|
||||
case 3:
|
||||
loopMethod.value = 'day';
|
||||
daySpan.value = loopRule[1];
|
||||
break;
|
||||
}
|
||||
switch (stopRule[0]) {
|
||||
case 0:
|
||||
stopMethod.value = 'forever';
|
||||
break;
|
||||
case 1:
|
||||
stopMethod.value = 'datetime';
|
||||
// NOTE: treated as a plain UTC instant (day-precision stop), deviating
|
||||
// from the legacy timezone-shifted display. See migration notes.
|
||||
stopDateTime.value = new Date(stopRule[1] * 60000);
|
||||
break;
|
||||
case 2:
|
||||
stopMethod.value = 'times';
|
||||
stopTimes.value = stopRule[1];
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.modelValue, (val) => parseFromModel(val), { immediate: true });
|
||||
watch(serialized, (val) => {
|
||||
if (val !== props.modelValue) emit('update:modelValue', val);
|
||||
});
|
||||
|
||||
// endregion
|
||||
|
||||
const showLoopStop = computed(() => loopMethod.value !== 'never');
|
||||
const showStrictMode = computed(() => loopMethod.value === 'month' || loopMethod.value === 'year');
|
||||
|
||||
const monthOptionTexts = computed(() => {
|
||||
const d = getDayInMonth(
|
||||
props.startDate.getFullYear(),
|
||||
props.startDate.getMonth() + 1,
|
||||
props.startDate.getDate()
|
||||
);
|
||||
return {
|
||||
A: format('Day {0} in month', d[0]),
|
||||
B: format('Day {0} from the end of the month', d[1]),
|
||||
C: format('Day {1} in week {0}', d[2], d[3] + 1),
|
||||
D: format('Day {1} in week {0} from the end of the month', d[4], d[5] + 1),
|
||||
};
|
||||
});
|
||||
|
||||
const stopDateTimeText = computed(() => stopDateTime.value.toLocaleDateString());
|
||||
|
||||
const openStopPicker = () => {
|
||||
stopPicker.value?.modal(stopDateTime.value, TabType.Day);
|
||||
};
|
||||
|
||||
const onStopPickerConfirm = (date: Date) => {
|
||||
stopDateTime.value = date;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section">
|
||||
<h2 class="subtitle">Event Loop</h2>
|
||||
<div class="button-list">
|
||||
<label class="radio">
|
||||
<input type="radio" value="never" v-model="loopMethod">
|
||||
<span>Never</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="day" v-model="loopMethod">
|
||||
<span>Day</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="week" v-model="loopMethod">
|
||||
<span>Week</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="month" v-model="loopMethod">
|
||||
<span>Month</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="year" v-model="loopMethod">
|
||||
<span>Year</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loopMethod === 'day'">
|
||||
<div class="field">
|
||||
<label class="label">Day span</label>
|
||||
<div class="control">
|
||||
<input v-model.number="daySpan" class="input spanpicker" type="number" min="1" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loopMethod === 'week'">
|
||||
<div class="field">
|
||||
<label class="label">Week span</label>
|
||||
<div class="control">
|
||||
<input v-model.number="weekSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Week options</label>
|
||||
<div class="button-list">
|
||||
<label v-for="(name, i) in WEEK_NAMES" :key="i" class="checkbox">
|
||||
<input type="checkbox" v-model="weekChecks[i]">
|
||||
<span>{{ name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loopMethod === 'month'">
|
||||
<div class="field">
|
||||
<label class="label">Month span</label>
|
||||
<div class="control">
|
||||
<input v-model.number="monthSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Month mode</label>
|
||||
<div class="button-list">
|
||||
<label class="radio">
|
||||
<input type="radio" value="A" v-model="monthMode">
|
||||
<span>{{ monthOptionTexts.A }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="B" v-model="monthMode">
|
||||
<span>{{ monthOptionTexts.B }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="C" v-model="monthMode">
|
||||
<span>{{ monthOptionTexts.C }}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="D" v-model="monthMode">
|
||||
<span>{{ monthOptionTexts.D }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loopMethod === 'year'">
|
||||
<div class="field">
|
||||
<label class="label">Year span</label>
|
||||
<div class="control">
|
||||
<input v-model.number="yearSpan" class="input spanpicker" type="number" min="1" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showStrictMode" class="section">
|
||||
<h2 class="subtitle">Strict Mode in Event Loop</h2>
|
||||
<p>You can choose strict mode or rough mode in following content. This is only effect on looped event.</p>
|
||||
<div class="button-list">
|
||||
<label class="radio">
|
||||
<input type="radio" :value="true" v-model="isStrict">
|
||||
<span>Strict Mode. If ordered day is not existing, skip it.</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" :value="false" v-model="isStrict">
|
||||
<span>Rough mode. If ordered day is not existing, choose the day closing with original day to arrange event.</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showLoopStop" class="section">
|
||||
<h2 class="subtitle">Event Loop Stop</h2>
|
||||
<div class="button-list">
|
||||
<label class="radio">
|
||||
<input type="radio" value="forever" v-model="stopMethod">
|
||||
<span>Forever</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="datetime" v-model="stopMethod">
|
||||
<span>Date Time</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" value="times" v-model="stopMethod">
|
||||
<span>Times</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="stopMethod === 'datetime'">
|
||||
<a class="button" @click="openStopPicker">
|
||||
<span>{{ stopDateTimeText }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="stopMethod === 'times'">
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<input v-model.number="stopTimes" class="input spanpicker" type="number" min="1" max="100" step="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DateTimePicker ref="stopPicker" @confirm="onStopPickerConfirm" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
border-top: 1px solid rgba(219, 219, 219, .5);
|
||||
padding-top: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { DisplayDay } from './types';
|
||||
import { format } from '@/utils/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
cells: DisplayDay[];
|
||||
weekNames: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'event-click', uuid: string): void
|
||||
}>();
|
||||
|
||||
const rows = computed<DisplayDay[][]>(() => {
|
||||
const r: DisplayDay[][] = [];
|
||||
for (let i = 0; i < 6; i++) r.push(props.cells.slice(i * 7, i * 7 + 7));
|
||||
return r;
|
||||
});
|
||||
|
||||
const overflowText = (count: number): string => {
|
||||
return format('{0} items', count.toString());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-grid-header">
|
||||
<div v-for="(name, i) in weekNames" :key="i" :class="{ weekend: i >= 5 }"><b>{{ name }}</b></div>
|
||||
</div>
|
||||
<div v-for="(row, ri) in rows" :key="ri" class="calendar-grid-row">
|
||||
<div v-for="(cell, ci) in row" :key="ci" class="calendar-grid-cell"
|
||||
:class="{ 'not-current-month': !cell.isCurrentMonth }">
|
||||
<p class="cell-title">
|
||||
<b>{{ cell.day }}</b>
|
||||
<span>{{ cell.subcalendar }}</span>
|
||||
</p>
|
||||
<div v-for="(e, ei) in cell.events.slice(0, 4)" :key="ei" class="event-bar"
|
||||
:style="{ background: e.color }" @click="emit('event-click', e.uuid)"></div>
|
||||
<p v-if="cell.events.length > 4" class="cell-overflow">{{ overflowText(cell.events.length) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.calendar-grid-header,
|
||||
.calendar-grid-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
.calendar-grid-cell {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
border-top: 1px solid black;
|
||||
border-left: 1px 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;
|
||||
}
|
||||
|
||||
.calendar-grid-cell.not-current-month {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
|
||||
/* remove the double border between adjacent cells */
|
||||
.calendar-grid-row .calendar-grid-cell:nth-child(n+2) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.calendar-grid-row:nth-child(n+2) .calendar-grid-cell {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.cell-overflow {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.event-bar {
|
||||
border: 1px solid black;
|
||||
border-radius: 2px;
|
||||
margin-top: 0.2rem;
|
||||
height: 0.75rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.weekend {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
isShow: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="paperbox-item card">
|
||||
<div class="paperbox-item-words">
|
||||
<p v-if="subtitle === undefined"><b>{{ name }}</b></p>
|
||||
<div v-else>
|
||||
<b>{{ name }}</b>
|
||||
<p>
|
||||
<span>Shared by: </span>
|
||||
<span>{{ subtitle }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paperbox-item-icon control" @click="emit('toggle')">
|
||||
<a class="button">
|
||||
<span class="icon is-small">
|
||||
<font-awesome-icon :icon="isShow ? 'fas fa-eye' : 'fas fa-eye-slash'"></font-awesome-icon>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { DisplayDay } from './types';
|
||||
import { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
|
||||
|
||||
defineProps<{
|
||||
days: DisplayDay[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'event-click', uuid: string): void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="schedule-list">
|
||||
<div v-for="(day, di) in days" :key="di" class="schedule-day container">
|
||||
<div class="schedule-day-words">
|
||||
<b>{{ MONTH_NAMES[day.month - 1] }}</b>
|
||||
<b>{{ day.day }}</b>
|
||||
<b>{{ WEEK_NAMES[day.dayOfWeek - 1] }}</b>
|
||||
</div>
|
||||
<div class="schedule-event-list">
|
||||
<template v-for="(ev, ei) in day.events" :key="ei">
|
||||
<div v-if="ev.isVisible" class="schedule-event-outter card" @click="emit('event-click', ev.uuid)">
|
||||
<div class="schedule-event-color" :style="{ background: ev.color }"></div>
|
||||
<div class="schedule-event-inner">
|
||||
<div class="schedule-event-words">
|
||||
<p class="level-item"><b>{{ ev.title }}</b></p>
|
||||
<p class="level-item">{{ ev.description }}</p>
|
||||
<p class="level-item"><span>{{ ev.start }}</span>-<span>{{ ev.end }}</span></p>
|
||||
<p v-if="ev.loopText !== ''">
|
||||
<span class="icon is-small"><font-awesome-icon icon="fas fa-retweet"></font-awesome-icon></span>
|
||||
<span>{{ ev.loopText }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="schedule-event-icon">
|
||||
<span v-if="ev.isLocked" class="icon is-small">
|
||||
<font-awesome-icon icon="fas fa-lock"></font-awesome-icon>
|
||||
</span>
|
||||
<span v-if="ev.timezoneWarning" class="icon is-small">
|
||||
<font-awesome-icon icon="fas fa-globe"></font-awesome-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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-day-words b {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.schedule-event-list {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.schedule-event-outter {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: flex-start;
|
||||
|
||||
margin-bottom: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-event-inner {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: flex-start;
|
||||
|
||||
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;
|
||||
height: 100%;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.schedule-list .schedule-day:nth-child(n+2) {
|
||||
border-top: 1px solid rgba(219, 219, 219, .5);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
/** A single analyzed event occurrence placed into a calendar day cell. */
|
||||
export interface DisplayEvent {
|
||||
uuid: string;
|
||||
belongTo: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
loopText: string;
|
||||
timezoneWarning: boolean;
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** A single day cell in the 6x7 calendar grid, carrying its events. */
|
||||
export interface DisplayDay {
|
||||
/** 1-12 */
|
||||
month: number;
|
||||
day: number;
|
||||
/** 1-7, Monday = 1 */
|
||||
dayOfWeek: number;
|
||||
isCurrentMonth: boolean;
|
||||
subcalendar: string;
|
||||
events: DisplayEvent[];
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const deleteItem = () => {
|
||||
}
|
||||
|
||||
const updateItem = () => {
|
||||
let new_name = editingName.value;
|
||||
const new_name = editingName.value;
|
||||
editingName.value = "";
|
||||
isEditing.value = false;
|
||||
emit('update', props.uuid, new_name);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
@@ -19,7 +19,7 @@ const app = createApp(App);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync);
|
||||
library.add(faUser, faLock, faPen, faShare, faTrash, faCheck, faTimes, faPlus, faSync, faChevronCircleLeft, faChevronCircleRight, faEye, faEyeSlash, faRetweet, faGlobe);
|
||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -18,7 +18,8 @@ const routes = [
|
||||
{ path: '/todo', name: "Todo", meta: { requireLoggedInCheck: true }, component: Todo },
|
||||
{ path: '/admin', name: "Admin", meta: { requireLoggedInCheck: true }, component: Admin },
|
||||
|
||||
{ path: '/calendar/event', name: "CalendarEvent", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
|
||||
{ path: '/calendar/event', name: "CalendarEventAdd", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
|
||||
{ path: '/calendar/event/:uuid', name: "CalendarEventUpdate", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
|
||||
{ path: '/login', name: "Login", meta: { requireLoggedOutCheck: true }, component: Login },
|
||||
|
||||
{ path: '/404', name: "NotFound", component: NotFound },
|
||||
@@ -52,4 +53,8 @@ export const goToHome = () => {
|
||||
router.push({ name: 'Home' })
|
||||
}
|
||||
|
||||
export const goToCalendar = () => {
|
||||
router.push({ name: 'Calendar' })
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const MONTH_NAMES: string[] = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
];
|
||||
|
||||
export const WEEK_NAMES: string[] = [
|
||||
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
|
||||
];
|
||||
@@ -62,7 +62,7 @@ type LoopStopRule = InfinityStopRule | DatetimeStopRule | TimesStopRule;
|
||||
export function resolveLoopRules4UI(strl: string): [LoopRule, LoopStopRule] | undefined {
|
||||
if (strl == '') return undefined;
|
||||
|
||||
let sp = strl.split('-');
|
||||
const sp = strl.split('-');
|
||||
if (sp.length != 2) return undefined;
|
||||
let loopRules: LoopRule | undefined = undefined;
|
||||
let loopStopRules: LoopStopRule | undefined = undefined;
|
||||
@@ -130,35 +130,35 @@ export function resolveLoopRules2Event(
|
||||
Math.max(loopDateTimeEnd, eventDateTimeEnd)]
|
||||
];
|
||||
|
||||
let sp = fullLoopRules.split('-');
|
||||
const sp = fullLoopRules.split('-');
|
||||
if (sp.length != 2) return undefined;
|
||||
let loopRules = sp[0]!; // we don't need consider stop flag
|
||||
let result: TimeRange[] = new Array();
|
||||
const loopRules = sp[0]!; // we don't need consider stop flag
|
||||
const result: TimeRange[] = [];
|
||||
|
||||
// compute offset and duration
|
||||
let eventDateTime = new Date((eventDateTimeStart + timezoneOffset) * 60000);
|
||||
const eventDateTime = new Date((eventDateTimeStart + timezoneOffset) * 60000);
|
||||
eventDateTime.setUTCHours(0, 0, 0, 0);
|
||||
let eventOffset = eventDateTimeStart - (Math.floor(eventDateTime.getTime() / 60000) - timezoneOffset);
|
||||
let eventDuration = eventDateTimeEnd - eventDateTimeStart;
|
||||
const eventOffset = eventDateTimeStart - (Math.floor(eventDateTime.getTime() / 60000) - timezoneOffset);
|
||||
const eventDuration = eventDateTimeEnd - eventDateTimeStart;
|
||||
|
||||
let detectDateTime = new Date(loopDateTimeStart * 60000);
|
||||
const detectDateTime = new Date(loopDateTimeStart * 60000);
|
||||
detectDateTime.setUTCHours(0, 0, 0, 0);
|
||||
let originalYear = eventDateTime.getUTCFullYear();
|
||||
let originalMonth = eventDateTime.getUTCMonth() + 1;
|
||||
let originalDay = eventDateTime.getUTCDate();
|
||||
const originalYear = eventDateTime.getUTCFullYear();
|
||||
const originalMonth = eventDateTime.getUTCMonth() + 1;
|
||||
const originalDay = eventDateTime.getUTCDate();
|
||||
|
||||
// compute event
|
||||
let match: RegExpExecArray | null;
|
||||
if ((match = PRECOMPILED_LOOP_RULES.year.exec(loopRules)) !== null) {
|
||||
let isStrict = match[1]! == 'S';
|
||||
let loopSpan = parseInt(match[2]!);
|
||||
const isStrict = match[1]! == 'S';
|
||||
const loopSpan = parseInt(match[2]!);
|
||||
|
||||
let yearCount = detectDateTime.getFullYear() - originalYear;
|
||||
let isSpecial = (originalMonth == 2 && originalDay == 29);
|
||||
let realLoopSpan = (isSpecial && isStrict) ? lcm(4, loopSpan) : loopSpan;
|
||||
const yearCount = detectDateTime.getFullYear() - originalYear;
|
||||
const isSpecial = (originalMonth == 2 && originalDay == 29);
|
||||
const realLoopSpan = (isSpecial && isStrict) ? lcm(4, loopSpan) : loopSpan;
|
||||
|
||||
//let fullSpanCount = Math.floor(yearCount / realLoopSpan);
|
||||
let remainYear = yearCount % realLoopSpan;
|
||||
const remainYear = yearCount % realLoopSpan;
|
||||
//detectDateTime.setUTCFullYear(fullSpanCount + detectDateTime.getUTCFullYear(), 1, 1);
|
||||
if (remainYear != 0)
|
||||
detectDateTime.setUTCFullYear(realLoopSpan - remainYear + detectDateTime.getUTCFullYear(), 1 - 1, 1);
|
||||
@@ -189,22 +189,22 @@ export function resolveLoopRules2Event(
|
||||
}
|
||||
|
||||
} else if ((match = PRECOMPILED_LOOP_RULES.month.exec(loopRules)) !== null) {
|
||||
let isStrict = match[1]! == 'S';
|
||||
let loopMethod = match[2]!;
|
||||
let loopSpan = parseInt(match[3]!);
|
||||
const isStrict = match[1]! == 'S';
|
||||
const loopMethod = match[2]!;
|
||||
const loopSpan = parseInt(match[3]!);
|
||||
|
||||
let monthsCountValue = monthsCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1) -
|
||||
const monthsCountValue = monthsCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1) -
|
||||
monthsCount(originalYear, originalMonth);
|
||||
|
||||
//let fullSpanCount = Math.floor(monthsCountValue / loopSpan);
|
||||
let remainMonth = monthsCountValue % loopSpan;
|
||||
const remainMonth = monthsCountValue % loopSpan;
|
||||
//detectDateTime.setUTCMonth(fullSpanCount * loopSpan + detectDateTime.getUTCMonth(), 1);
|
||||
detectDateTime.setUTCDate(1);
|
||||
if (remainMonth != 0)
|
||||
detectDateTime.setUTCMonth(loopSpan - remainMonth + detectDateTime.getUTCMonth(), 1);
|
||||
|
||||
while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) {
|
||||
let data = getRemanagedDayInMonth(originalYear, originalMonth, originalDay, detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, isStrict);
|
||||
const data = getRemanagedDayInMonth(originalYear, originalMonth, originalDay, detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, isStrict);
|
||||
let predictedDay: number | undefined = undefined;
|
||||
switch (loopMethod) {
|
||||
case 'A':
|
||||
@@ -233,15 +233,15 @@ export function resolveLoopRules2Event(
|
||||
|
||||
|
||||
} else if ((match = PRECOMPILED_LOOP_RULES.week.exec(loopRules)) !== null) {
|
||||
let loopSpan = parseInt(match[2]!);
|
||||
let weekOption: boolean[] = [];
|
||||
const loopSpan = parseInt(match[2]!);
|
||||
const weekOption: boolean[] = [];
|
||||
let weekEventCount = 0
|
||||
for (let i = 0; i < 7; i++) {
|
||||
weekOption.push(match[1]![i] == 'T');
|
||||
if (match[1]![i] == 'T') weekEventCount++;
|
||||
}
|
||||
|
||||
let originalWeek = dayOfWeek(originalYear, originalMonth, originalDay);
|
||||
const originalWeek = dayOfWeek(originalYear, originalMonth, originalDay);
|
||||
|
||||
// try insert original event
|
||||
if (!weekOption[originalWeek]) {
|
||||
@@ -250,11 +250,11 @@ export function resolveLoopRules2Event(
|
||||
);
|
||||
}
|
||||
|
||||
let daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getDate()) -
|
||||
const daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getDate()) -
|
||||
daysCount(originalYear, originalMonth, originalDay);
|
||||
//let fullSpanCount = Math.floor(daysCountValue / (7 * loopSpan));
|
||||
let remainFullSpanCount = Math.floor((daysCountValue % (7 * loopSpan)) / 7);
|
||||
let remainDays = (daysCountValue % (7 * loopSpan)) % 7;
|
||||
const remainFullSpanCount = Math.floor((daysCountValue % (7 * loopSpan)) / 7);
|
||||
const remainDays = (daysCountValue % (7 * loopSpan)) % 7;
|
||||
|
||||
//detectDateTime.setUTCDate((7 * loopSpan * fullSpanCount) + detectDateTime.getUTCDate());
|
||||
if (remainFullSpanCount != 0) {
|
||||
@@ -276,12 +276,12 @@ export function resolveLoopRules2Event(
|
||||
}
|
||||
|
||||
} else if ((match = PRECOMPILED_LOOP_RULES.day.exec(loopRules)) !== null) {
|
||||
let loopSpan = parseInt(match[1]!);
|
||||
const loopSpan = parseInt(match[1]!);
|
||||
|
||||
let daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getUTCDate()) -
|
||||
const daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getUTCDate()) -
|
||||
daysCount(originalYear, originalMonth, originalDay);
|
||||
//let fullSpanCount = Math.floor(daysCountValue / loopSpan);
|
||||
let remainDays = daysCountValue % loopSpan;
|
||||
const remainDays = daysCountValue % loopSpan;
|
||||
//detectDateTime.setUTCDate(fullSpanCount * loopSpan + detectDateTime.getUTCDate());
|
||||
if (remainDays != 0)
|
||||
detectDateTime.setUTCDate(loopSpan - remainDays + detectDateTime.getUTCDate());
|
||||
@@ -296,10 +296,10 @@ export function resolveLoopRules2Event(
|
||||
} else return undefined;
|
||||
|
||||
// clamp item
|
||||
let realResult: TimeRange[] = new Array();
|
||||
for (let i of result) {
|
||||
let start = i[0];
|
||||
let end = i[1];
|
||||
const realResult: TimeRange[] = [];
|
||||
for (const i of result) {
|
||||
const start = i[0];
|
||||
const end = i[1];
|
||||
if (end > clampStartDateTime && start <= loopDateTimeEnd)
|
||||
realResult.push([Math.max(start, clampStartDateTime), Math.min(end, loopDateTimeEnd)]);
|
||||
}
|
||||
@@ -319,11 +319,11 @@ export function resolveLoopRules2Event(
|
||||
export function resolveLoopRules4Text(strl: string, startDateTime: number, timezoneOffset: number): string {
|
||||
if (strl == '') return "";
|
||||
|
||||
let sp = strl.split('-');
|
||||
const sp = strl.split('-');
|
||||
if (sp.length != 2) return "";
|
||||
let loopRules: string;
|
||||
let loopStopRules: string;
|
||||
let datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000)
|
||||
const datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000)
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
if ((match = PRECOMPILED_LOOP_RULES.year.exec(sp[0]!)) !== null) {
|
||||
@@ -340,7 +340,7 @@ export function resolveLoopRules4Text(strl: string, startDateTime: number, timez
|
||||
else
|
||||
loopRules = "宽松模式。";
|
||||
|
||||
let dayInMonth = getDayInMonth(
|
||||
const dayInMonth = getDayInMonth(
|
||||
datetimeInstance.getUTCFullYear(),
|
||||
datetimeInstance.getUTCMonth() + 1,
|
||||
datetimeInstance.getUTCDate());
|
||||
@@ -367,7 +367,7 @@ export function resolveLoopRules4Text(strl: string, startDateTime: number, timez
|
||||
break;
|
||||
}
|
||||
} else if ((match = PRECOMPILED_LOOP_RULES.week.exec(sp[0]!)) !== null) {
|
||||
let weekOfDayCache = [];
|
||||
const weekOfDayCache = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (match[1]![i] == 'T')
|
||||
weekOfDayCache.push(universalGetDayOfWeek(i));
|
||||
@@ -414,7 +414,7 @@ function leapYearCountEx(endYear: number, includeThis: boolean, baseYear: number
|
||||
}
|
||||
|
||||
function daysCount(year: number, month: number, day: number): number {
|
||||
let ly = leapYearCountEx(year, false, 1, true);
|
||||
const ly = leapYearCountEx(year, false, 1, true);
|
||||
let days = 365 * (year - 1);
|
||||
days += ly;
|
||||
|
||||
@@ -432,7 +432,7 @@ function monthsCount(year: number, month: number): number {
|
||||
return (year - 1) * 12 + (month - 1);
|
||||
}
|
||||
|
||||
function dayOfWeek(year: number, month: number, day: number): number {
|
||||
export function dayOfWeek(year: number, month: number, day: number): number {
|
||||
return daysCount(year, month, day) % 7;
|
||||
}
|
||||
|
||||
@@ -451,16 +451,16 @@ type DayInMonthInfo = [
|
||||
weeksBackwardDayOfWeek: number,
|
||||
];
|
||||
|
||||
function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo {
|
||||
let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
|
||||
let firstDayOfWeek = dayOfWeek(year, month, 1);
|
||||
let bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7;
|
||||
export function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo {
|
||||
const days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
|
||||
const firstDayOfWeek = dayOfWeek(year, month, 1);
|
||||
const bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7;
|
||||
|
||||
let dayForwards = day;
|
||||
let dayBackwards = days - day + 1;
|
||||
const dayForwards = day;
|
||||
const dayBackwards = days - day + 1;
|
||||
|
||||
let weeksForward = Math.floor((dayForwards - 1) / 7) + 1;
|
||||
let weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1;
|
||||
const weeksForward = Math.floor((dayForwards - 1) / 7) + 1;
|
||||
const weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1;
|
||||
|
||||
return [dayForwards, dayBackwards, weeksForward, bilateralDayOfWeek, weeksBackwards, bilateralDayOfWeek];
|
||||
}
|
||||
@@ -493,10 +493,10 @@ type RemanagedDayInMonth = [
|
||||
* @returns
|
||||
*/
|
||||
function getRemanagedDayInMonth(oldYear: number, oldMonth: number, oldDay: number, newYear: number, newMonth: number, isStrict: boolean): RemanagedDayInMonth {
|
||||
let ddata = getDayInMonth(oldYear, oldMonth, oldDay);
|
||||
let mdata = getMonthWeekStatistics(newYear, newMonth);
|
||||
let days = MONTH_DAY_COUNT[newMonth - 1]! + ((newMonth == 2 && isLeapYear(newYear)) ? 1 : 0);
|
||||
let firstDayOfWeek = dayOfWeek(newYear, newMonth, 1);
|
||||
const ddata = getDayInMonth(oldYear, oldMonth, oldDay);
|
||||
const mdata = getMonthWeekStatistics(newYear, newMonth);
|
||||
const days = MONTH_DAY_COUNT[newMonth - 1]! + ((newMonth == 2 && isLeapYear(newYear)) ? 1 : 0);
|
||||
const firstDayOfWeek = dayOfWeek(newYear, newMonth, 1);
|
||||
//let lastDayOfWeek = (firstDayOfWeek + days - 1) % 7;
|
||||
|
||||
let methodA = undefined;
|
||||
@@ -511,14 +511,14 @@ function getRemanagedDayInMonth(oldYear: number, oldMonth: number, oldDay: numbe
|
||||
|
||||
let methodC = undefined;
|
||||
if (ddata[2] <= mdata[ddata[3]]! || !isStrict) {
|
||||
let targetWeek = Math.min(ddata[2], mdata[ddata[3]]!);
|
||||
const targetWeek = Math.min(ddata[2], mdata[ddata[3]]!);
|
||||
methodC = 1 + (targetWeek - 1) * 7 + ((ddata[3] + 7 - firstDayOfWeek) % 7);
|
||||
}
|
||||
|
||||
let methodD = undefined;
|
||||
if (ddata[4] <= mdata[ddata[5]]! || !isStrict) {
|
||||
// convert to type c and calc
|
||||
let targetWeek = mdata[ddata[5]]! - Math.min(ddata[4], mdata[ddata[5]]!) + 1;
|
||||
const targetWeek = mdata[ddata[5]]! - Math.min(ddata[4], mdata[ddata[5]]!) + 1;
|
||||
methodD = 1 + (targetWeek - 1) * 7 + ((ddata[5] + 7 - firstDayOfWeek) % 7);
|
||||
}
|
||||
|
||||
@@ -543,10 +543,10 @@ type MonthWeekStatistics = [
|
||||
];
|
||||
|
||||
function getMonthWeekStatistics(year: number, month: number): MonthWeekStatistics {
|
||||
let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
|
||||
let firstDayOfWeek = dayOfWeek(year, month, 1);
|
||||
const days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0);
|
||||
const firstDayOfWeek = dayOfWeek(year, month, 1);
|
||||
|
||||
let result: MonthWeekStatistics = [4, 4, 4, 4, 4, 4, 4];
|
||||
const result: MonthWeekStatistics = [4, 4, 4, 4, 4, 4, 4];
|
||||
let remain = days % 7;
|
||||
let week = firstDayOfWeek;
|
||||
while (remain > 0) {
|
||||
@@ -558,7 +558,7 @@ function getMonthWeekStatistics(year: number, month: number): MonthWeekStatistic
|
||||
return result;
|
||||
}
|
||||
|
||||
function isLeapYear(year: number): boolean {
|
||||
export function isLeapYear(year: number): boolean {
|
||||
let isLeap = false;
|
||||
if (year % 4 == 0) isLeap = true;
|
||||
if (year % 100 == 0) isLeap = false;
|
||||
|
||||
@@ -34,7 +34,7 @@ export function format(str: string, ...args: any[]): string {
|
||||
* @returns The zero-based weekday. 0 stands for Monday.
|
||||
*/
|
||||
export function getWeekday(date: Date): number {
|
||||
let day = date.getDay();
|
||||
const day = date.getDay();
|
||||
if (day == 0) return 6;
|
||||
else return day - 1;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,344 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTokenStore } from '@/stores/token';
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
||||
import CalendarGrid from '@/components/calendar/CalendarGrid.vue';
|
||||
import ScheduleList from '@/components/calendar/ScheduleList.vue';
|
||||
import CollectionToggleItem from '@/components/calendar/CollectionToggleItem.vue';
|
||||
import type { DisplayDay, DisplayEvent } 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 { MONTH_NAMES, WEEK_NAMES } from '@/utils/calendar-names';
|
||||
|
||||
const token = useTokenStore();
|
||||
const router = useRouter();
|
||||
|
||||
const messagebox = ref<InstanceType<typeof MessageBox>>();
|
||||
const picker = ref<InstanceType<typeof DateTimePicker>>();
|
||||
|
||||
const activeTab = ref<number>(1);
|
||||
|
||||
// The viewed month cursor. Only year + month are meaningful; the time-of-day
|
||||
// participates in the grid start timestamp exactly like the legacy code.
|
||||
const currentMonth = ref<Date>(new Date());
|
||||
|
||||
// Raw event rows covering the current grid window.
|
||||
const eventsCache = ref<CalendarRow[]>([]);
|
||||
|
||||
// Collection lists for tab 2.
|
||||
const ownedCollections = ref<CollectionRow[]>([]);
|
||||
const sharedCollections = ref<SharedCollectionRow[]>([]);
|
||||
|
||||
// Per-collection visibility flags, keyed by collection uuid. Default (absent)
|
||||
// means hidden, matching the legacy behaviour where missing cache entries are
|
||||
// treated as false.
|
||||
const ownedVisible = ref<Map<string, boolean>>(new Map());
|
||||
const sharedVisible = ref<Map<string, boolean>>(new Map());
|
||||
|
||||
// region: Computed grid window and analyzed display data
|
||||
|
||||
const gridYear = computed(() => currentMonth.value.getFullYear());
|
||||
const gridMonth = computed(() => currentMonth.value.getMonth() + 1);
|
||||
const firstDow = computed(() => dayOfWeek(gridYear.value, gridMonth.value, 1));
|
||||
const gridStartTimestamp = computed(() =>
|
||||
Math.floor(currentMonth.value.getTime() / 60000) - firstDow.value * DAY1_SPAN
|
||||
);
|
||||
const gridEndTimestamp = computed(() => gridStartTimestamp.value + DAY1_SPAN * 6 * 7 - 1);
|
||||
|
||||
const textMonth = computed(() => `${gridYear.value} - ${MONTH_NAMES[gridMonth.value - 1]}`);
|
||||
|
||||
const myTimezoneOffset = computed(() => -(new Date().getTimezoneOffset()));
|
||||
|
||||
// Port of ccn_calendar_calendar_Analyse: build the 42-cell display cache,
|
||||
// expand loop events and split occurrences that cross day boundaries.
|
||||
const displayDays = computed<DisplayDay[]>(() => {
|
||||
const startTimestamp = gridStartTimestamp.value;
|
||||
const endTimestamp = gridEndTimestamp.value;
|
||||
const gottenMonth = gridMonth.value;
|
||||
const myTz = myTimezoneOffset.value;
|
||||
|
||||
// Build the 42 day cells.
|
||||
const days: DisplayDay[] = [];
|
||||
const cursor = new Date(startTimestamp * 60000);
|
||||
for (let i = 0; i < 6 * 7; i++) {
|
||||
days.push({
|
||||
month: cursor.getMonth() + 1,
|
||||
day: cursor.getDate(),
|
||||
dayOfWeek: (cursor.getDay() === 0 ? 6 : cursor.getDay() - 1) + 1,
|
||||
isCurrentMonth: (cursor.getMonth() + 1) === gottenMonth,
|
||||
subcalendar: '',
|
||||
events: [],
|
||||
});
|
||||
cursor.setTime(cursor.getTime() + DAY1_SPAN * 60000);
|
||||
}
|
||||
|
||||
// Expand and place each event.
|
||||
for (const item of eventsCache.value) {
|
||||
const deserialized = deserializeDescription(item[3]);
|
||||
const minStartTimestamp = startTimestamp - (item[6] - item[5]);
|
||||
const result = resolveLoopRules2Event(
|
||||
item[8],
|
||||
item[9] < minStartTimestamp ? minStartTimestamp : item[9],
|
||||
Math.min(item[10], endTimestamp),
|
||||
item[5],
|
||||
item[6],
|
||||
item[7],
|
||||
startTimestamp
|
||||
);
|
||||
if (typeof result === 'undefined') continue;
|
||||
|
||||
const loopText = resolveLoopRules4Text(item[8], item[5], item[7]);
|
||||
const timezoneWarning = myTz !== item[7];
|
||||
|
||||
for (const it of result) {
|
||||
const eventDateTime = new Date(it[0] * 60000);
|
||||
let count = Math.floor((it[0] - startTimestamp) / DAY1_SPAN);
|
||||
let exitFlag = false;
|
||||
while (count < 6 * 7) {
|
||||
// NOTE: legacy indexes the owned cache by the *event* uuid (item[0]),
|
||||
// which is collection-keyed, so this is always false. Kept faithful.
|
||||
const isLocked = ownedVisible.value.has(item[0]);
|
||||
const belongVisible = ownedVisible.value.get(item[1]) || sharedVisible.value.get(item[1]) || false;
|
||||
const eventItem: DisplayEvent = {
|
||||
uuid: item[0],
|
||||
belongTo: item[1],
|
||||
title: item[2],
|
||||
description: deserialized.description,
|
||||
color: deserialized.color,
|
||||
isVisible: belongVisible,
|
||||
isLocked,
|
||||
loopText,
|
||||
timezoneWarning,
|
||||
start: eventDateTime.toLocaleTimeString(),
|
||||
end: '',
|
||||
};
|
||||
eventDateTime.setHours(23, 59, 0, 0);
|
||||
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
|
||||
exitFlag = true;
|
||||
eventDateTime.setTime(it[1] * 60000);
|
||||
}
|
||||
eventItem.end = eventDateTime.toLocaleTimeString();
|
||||
days[count]!.events.push(eventItem);
|
||||
if (exitFlag) break;
|
||||
eventDateTime.setMinutes(eventDateTime.getMinutes() + 1, 0, 0);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
});
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Data fetching
|
||||
|
||||
const refreshEvents = async () => {
|
||||
const rv = await apiCalendarGetFull(token.currentToken, gridStartTimestamp.value, gridEndTimestamp.value);
|
||||
eventsCache.value = typeof rv === 'undefined' ? [] : rv;
|
||||
};
|
||||
|
||||
const refreshCollections = async () => {
|
||||
const newOwned = new Map<string, boolean>();
|
||||
const newShared = new Map<string, boolean>();
|
||||
|
||||
const sharedRv = await apiCollectionGetShared(token.currentToken);
|
||||
sharedCollections.value = typeof sharedRv === 'undefined' ? [] : sharedRv;
|
||||
if (sharedCollections.value.length > 0) {
|
||||
for (const item of sharedCollections.value) {
|
||||
newShared.set(item[0], true);
|
||||
}
|
||||
}
|
||||
|
||||
const ownedRv = await apiCollectionGetFullOwn(token.currentToken);
|
||||
ownedCollections.value = typeof ownedRv === 'undefined' ? [] : ownedRv;
|
||||
if (ownedCollections.value.length > 0) {
|
||||
for (const item of ownedCollections.value) {
|
||||
newOwned.set(item[0], true);
|
||||
}
|
||||
}
|
||||
|
||||
ownedVisible.value = newOwned;
|
||||
sharedVisible.value = newShared;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Navigation
|
||||
|
||||
const prevMonth = () => {
|
||||
const d = new Date(currentMonth.value);
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
currentMonth.value = d;
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
const d = new Date(currentMonth.value);
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
currentMonth.value = d;
|
||||
};
|
||||
|
||||
const today = () => {
|
||||
currentMonth.value = new Date();
|
||||
};
|
||||
|
||||
const jump = () => {
|
||||
picker.value?.modal(currentMonth.value, TabType.Month);
|
||||
};
|
||||
|
||||
const onPickerConfirm = (date: Date) => {
|
||||
currentMonth.value = date;
|
||||
};
|
||||
|
||||
const addEvent = () => {
|
||||
router.push({ name: 'CalendarEventAdd' });
|
||||
};
|
||||
|
||||
const editEvent = (uuid: string) => {
|
||||
router.push({ name: 'CalendarEventUpdate', params: { uuid } });
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Collection visibility toggles
|
||||
|
||||
const toggleOwned = (uuid: string) => {
|
||||
const m = new Map(ownedVisible.value);
|
||||
m.set(uuid, !m.get(uuid));
|
||||
ownedVisible.value = m;
|
||||
};
|
||||
|
||||
const toggleShared = (uuid: string) => {
|
||||
const m = new Map(sharedVisible.value);
|
||||
m.set(uuid, !m.get(uuid));
|
||||
sharedVisible.value = m;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
watch(currentMonth, () => { refreshEvents(); });
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCollections();
|
||||
await refreshEvents();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is calendar.</p>
|
||||
<div class="container" style="margin-top: 20px;">
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 1 }" @click="activeTab = 1"><a>Calendar</a></li>
|
||||
<li :class="{ 'is-active': activeTab === 2 }" @click="activeTab = 2"><a>Collection</a></li>
|
||||
<li :class="{ 'is-active': activeTab === 3 }" @click="activeTab = 3"><a>Display</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 1: Calendar -->
|
||||
<div v-show="activeTab === 1" 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="jump">
|
||||
<span>{{ textMonth }}</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="today">Today</a>
|
||||
</div>
|
||||
<div class="level-item control">
|
||||
<a class="button is-primary" @click="addEvent">Add...</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="card" style="padding: 1.25rem;">
|
||||
<CalendarGrid :cells="displayDays" :week-names="WEEK_NAMES" @event-click="editEvent" />
|
||||
</div>
|
||||
|
||||
<div class="container" style="padding: 1.25rem; display: flex; flex-flow: column; margin-top: 1.25rem;">
|
||||
<h1 class="title">Schedule</h1>
|
||||
<ScheduleList :days="displayDays" @event-click="editEvent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 2: Collection -->
|
||||
<div v-show="activeTab === 2" class="container" style="margin-top: 20px;">
|
||||
<div class="control" style="margin: 0.75rem;">
|
||||
<a class="button is-primary" @click="refreshCollections">
|
||||
<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;">
|
||||
<CollectionToggleItem v-for="item in ownedCollections" :key="item[0]" :name="item[1]"
|
||||
:is-show="ownedVisible.get(item[0]) || false" @toggle="toggleOwned(item[0])" />
|
||||
</div>
|
||||
<h1 class="title">Shared collections</h1>
|
||||
<div style="display: flex; flex-flow: column; margin-top: 1.25rem; margin-bottom: 1.25rem;">
|
||||
<CollectionToggleItem v-for="item in sharedCollections" :key="item[0]" :name="item[1]" :subtitle="item[2]"
|
||||
:is-show="sharedVisible.get(item[0]) || false" @toggle="toggleShared(item[0])" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 3: Display (legacy dead UI, reproduced as-is) -->
|
||||
<div v-show="activeTab === 3" 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="(name, i) in WEEK_NAMES" :key="i">{{ name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Sub-Calendar</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select>
|
||||
<option>Chinese Lunisolar Calendar</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageBox ref="messagebox" />
|
||||
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,8 +1,322 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useTokenStore } from '@/stores/token';
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import DateTimePicker, { TabType } from '@/components/DateTimePicker.vue';
|
||||
import EventLoopEditor from '@/components/calendar-event/EventLoopEditor.vue';
|
||||
import {
|
||||
getDetail as apiCalendarGetDetail,
|
||||
add as apiCalendarAdd,
|
||||
update as apiCalendarUpdate,
|
||||
serializeDescription,
|
||||
deserializeDescription,
|
||||
type CalendarRow,
|
||||
} from '@/api/calendar';
|
||||
import {
|
||||
getFullOwn as apiCollectionGetFullOwn,
|
||||
type CollectionRow,
|
||||
} from '@/api/collection';
|
||||
import { DEFAULT_COLOR } from '@/utils/utils';
|
||||
import { goToCalendar } from '@/router';
|
||||
|
||||
const token = useTokenStore();
|
||||
const route = useRoute();
|
||||
|
||||
const messagebox = ref<InstanceType<typeof MessageBox>>();
|
||||
const picker = ref<InstanceType<typeof DateTimePicker>>();
|
||||
|
||||
const uuid = computed<string | undefined>(() => route.params.uuid as string | undefined);
|
||||
const isAdd = computed(() => typeof uuid.value === 'undefined');
|
||||
|
||||
const editingEvent = ref<CalendarRow | undefined>(undefined);
|
||||
|
||||
const title = ref<string>('');
|
||||
const description = ref<string>('');
|
||||
const color = ref<string>(DEFAULT_COLOR);
|
||||
const belongTo = ref<string>('');
|
||||
const startDt = ref<Date>(new Date());
|
||||
const endDt = ref<Date>(new Date());
|
||||
const loopRules = ref<string>('');
|
||||
|
||||
const collections = ref<CollectionRow[]>([]);
|
||||
|
||||
const keepTimezone = ref<boolean>(true);
|
||||
const showTimezoneBox = ref<boolean>(false);
|
||||
|
||||
let activeSlot: 'start' | 'end' | null = null;
|
||||
|
||||
const startDtText = computed(() => startDt.value.toLocaleString());
|
||||
const endDtText = computed(() => endDt.value.toLocaleString());
|
||||
|
||||
// region: Initialization (port of ccn_event_Init)
|
||||
|
||||
const init = async () => {
|
||||
// Fetch collections for the dropdown.
|
||||
const collectionsRv = await apiCollectionGetFullOwn(token.currentToken);
|
||||
collections.value = typeof collectionsRv === 'undefined' ? [] : collectionsRv;
|
||||
|
||||
if (isAdd.value) {
|
||||
// Add mode: start = current hour (truncated), end = start + 2 hours.
|
||||
const start = new Date();
|
||||
start.setMilliseconds(0);
|
||||
start.setSeconds(0);
|
||||
start.setMinutes(0);
|
||||
startDt.value = start;
|
||||
const end = new Date(start);
|
||||
end.setHours(end.getHours() + 2);
|
||||
endDt.value = end;
|
||||
|
||||
title.value = '';
|
||||
description.value = '';
|
||||
color.value = DEFAULT_COLOR;
|
||||
belongTo.value = '';
|
||||
loopRules.value = '';
|
||||
showTimezoneBox.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update mode: load the editing event.
|
||||
const detail = await apiCalendarGetDetail(token.currentToken, uuid.value!);
|
||||
if (typeof detail === 'undefined') {
|
||||
messagebox.value?.show("A get operation failed. It may caused by server internal error or your limited permission. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
editingEvent.value = detail;
|
||||
const deserialized = deserializeDescription(detail[3]);
|
||||
|
||||
title.value = detail[2];
|
||||
description.value = deserialized.description;
|
||||
color.value = deserialized.color;
|
||||
belongTo.value = detail[1];
|
||||
|
||||
// Wall-clock-shifted dates: local getters yield the event's original timezone.
|
||||
startDt.value = new Date((detail[5] + detail[7]) * 60000);
|
||||
endDt.value = new Date((detail[6] + detail[7]) * 60000);
|
||||
|
||||
loopRules.value = detail[8];
|
||||
|
||||
// Show the timezone prompt only when the browser tz differs from the event tz.
|
||||
showTimezoneBox.value = (-new Date().getTimezoneOffset()) !== detail[7];
|
||||
keepTimezone.value = true;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Quick actions
|
||||
|
||||
const spot = () => {
|
||||
const d = new Date(startDt.value);
|
||||
d.setMinutes(d.getMinutes() + 1);
|
||||
endDt.value = d;
|
||||
};
|
||||
|
||||
const fullDay = () => {
|
||||
const s = new Date(startDt.value);
|
||||
s.setMinutes(0);
|
||||
s.setHours(0);
|
||||
startDt.value = s;
|
||||
const e = new Date(s);
|
||||
e.setMinutes(59);
|
||||
e.setHours(23);
|
||||
endDt.value = e;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: DateTimePicker
|
||||
|
||||
const openStart = () => {
|
||||
activeSlot = 'start';
|
||||
picker.value?.modal(startDt.value, TabType.Minute);
|
||||
};
|
||||
|
||||
const openEnd = () => {
|
||||
activeSlot = 'end';
|
||||
picker.value?.modal(endDt.value, TabType.Minute);
|
||||
};
|
||||
|
||||
const onPickerConfirm = (date: Date) => {
|
||||
if (activeSlot === 'start') startDt.value = date;
|
||||
else if (activeSlot === 'end') endDt.value = date;
|
||||
activeSlot = null;
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
// region: Submit (port of ccn_event_GetForm / btnSubmit)
|
||||
|
||||
const submit = async () => {
|
||||
// Validate required fields.
|
||||
if (title.value === '' || description.value === '' || color.value === '' || belongTo.value === '') {
|
||||
messagebox.value?.show("Your filled event form is not fufilled or have error. Please check it and try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const serializedDescription = serializeDescription(description.value, color.value);
|
||||
|
||||
// Compute event start/end timestamps + timezone offset.
|
||||
// The Date objects' local getters expose the event wall-clock time.
|
||||
let eventStart: number;
|
||||
let eventEnd: number;
|
||||
let timezoneOffset: number;
|
||||
|
||||
if (!isAdd.value && !keepTimezone.value) {
|
||||
// "Use my timezone" / replace path: reinterpret the wall-clock as the
|
||||
// event's original timezone, then convert back to true UTC minutes.
|
||||
timezoneOffset = editingEvent.value![7];
|
||||
eventStart = Math.floor(Date.UTC(
|
||||
startDt.value.getFullYear(), startDt.value.getMonth(), startDt.value.getDate(),
|
||||
startDt.value.getHours(), startDt.value.getMinutes()
|
||||
) / 60000) - timezoneOffset;
|
||||
eventEnd = Math.floor(Date.UTC(
|
||||
endDt.value.getFullYear(), endDt.value.getMonth(), endDt.value.getDate(),
|
||||
endDt.value.getHours(), endDt.value.getMinutes()
|
||||
) / 60000) - timezoneOffset;
|
||||
} else {
|
||||
// Keep / add path: use the Date's UTC instant directly, browser tz wins.
|
||||
timezoneOffset = -startDt.value.getTimezoneOffset();
|
||||
eventStart = Math.floor(startDt.value.getTime() / 60000);
|
||||
eventEnd = Math.floor(endDt.value.getTime() / 60000);
|
||||
}
|
||||
|
||||
const newLoopRules = loopRules.value;
|
||||
|
||||
if (isAdd.value) {
|
||||
const rv = await apiCalendarAdd(
|
||||
token.currentToken,
|
||||
belongTo.value,
|
||||
title.value,
|
||||
serializedDescription,
|
||||
eventStart,
|
||||
eventEnd,
|
||||
newLoopRules,
|
||||
timezoneOffset
|
||||
);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
goToCalendar();
|
||||
} else {
|
||||
const ev = editingEvent.value!;
|
||||
const rv = await apiCalendarUpdate(
|
||||
token.currentToken,
|
||||
ev[0],
|
||||
ev[4],
|
||||
ev[1] === belongTo.value ? undefined : belongTo.value,
|
||||
ev[2] === title.value ? undefined : title.value,
|
||||
ev[3] === serializedDescription ? undefined : serializedDescription,
|
||||
ev[5] === eventStart ? undefined : eventStart,
|
||||
ev[6] === eventEnd ? undefined : eventEnd,
|
||||
ev[8] === newLoopRules ? undefined : newLoopRules,
|
||||
ev[7] === timezoneOffset ? undefined : timezoneOffset,
|
||||
);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
goToCalendar();
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
goToCalendar();
|
||||
};
|
||||
|
||||
// endregion
|
||||
|
||||
onMounted(() => { init(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is calendar event.</p>
|
||||
<div class="container" style="margin-top: 20px;">
|
||||
<h1 class="title">Edit Event</h1>
|
||||
|
||||
<section class="section">
|
||||
<div class="field">
|
||||
<label class="label">Title</label>
|
||||
<div class="control">
|
||||
<input v-model="title" class="input" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Description</label>
|
||||
<div class="control">
|
||||
<textarea v-model="description" class="textarea"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Color</label>
|
||||
<div class="control">
|
||||
<input v-model="color" class="input" type="color">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">Collection</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select v-model="belongTo">
|
||||
<option v-for="item in collections" :key="item[0]" :value="item[0]">{{ item[1] }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="subtitle">Start Date Time</h2>
|
||||
<a class="button" @click="openStart">
|
||||
<span>{{ startDtText }}</span>
|
||||
</a>
|
||||
|
||||
<h2 class="subtitle">Stop Date Time</h2>
|
||||
<div class="button-list">
|
||||
<div class="control">
|
||||
<a class="button is-link" @click="spot">Spot</a>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-link" @click="fullDay">Full day</a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="button" @click="openEnd">
|
||||
<span>{{ endDtText }}</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<EventLoopEditor v-model="loopRules" :start-date="startDt" />
|
||||
|
||||
<section v-if="showTimezoneBox" class="section">
|
||||
<h2 class="subtitle">Timezone</h2>
|
||||
<p>The timezone of this event is not corresponding with your current timezone. All of date and time in this page are shown as the original timezone of this event. You can choose a timezone option in follwing content. If you are not familar with this, please pick keep timezone.</p>
|
||||
<div class="button-list">
|
||||
<label class="radio">
|
||||
<input type="radio" :value="true" v-model="keepTimezone">
|
||||
<span>Keep timezone</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" :value="false" v-model="keepTimezone">
|
||||
<span>Use my timezone</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="button-list">
|
||||
<a class="button is-success" @click="submit">Submit</a>
|
||||
<a class="button" @click="cancel">Cancel</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<MessageBox ref="messagebox" />
|
||||
<DateTimePicker ref="picker" @confirm="onPickerConfirm" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.section {
|
||||
border-top: 1px solid rgba(219, 219, 219, .5);
|
||||
padding-top: 1.25rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,13 +37,13 @@ const refreshOwnedItem = async () => {
|
||||
ownedItems.value.clear();
|
||||
|
||||
// Fetch data
|
||||
let rv = await apiCollectionGetFullOwn(token.currentToken);
|
||||
const rv = await apiCollectionGetFullOwn(token.currentToken);
|
||||
if (typeof rv === 'undefined') {
|
||||
console.error("fail to fetch owned collection");
|
||||
return;
|
||||
}
|
||||
// Add into dict for rendering
|
||||
for (let item of rv) {
|
||||
for (const item of rv) {
|
||||
ownedItems.value.set(item[0], item);
|
||||
}
|
||||
|
||||
@@ -53,20 +53,20 @@ const refreshOwnedItem = async () => {
|
||||
}
|
||||
|
||||
const addOwnedItem = async () => {
|
||||
let new_name = pendingOwnedItemName.value;
|
||||
const new_name = pendingOwnedItemName.value;
|
||||
if (new_name === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// first add it
|
||||
let rv = await apiCollectionAddOwn(token.currentToken, new_name);
|
||||
const rv = await apiCollectionAddOwn(token.currentToken, new_name);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// second get its detail
|
||||
let rv2 = await apiCollectionGetDetailOwn(token.currentToken, rv);
|
||||
const rv2 = await apiCollectionGetDetailOwn(token.currentToken, rv);
|
||||
if (typeof rv2 === 'undefined') {
|
||||
messagebox.value?.show("A get operation failed. It may caused by server internal error or your limited permission. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
@@ -77,7 +77,7 @@ const addOwnedItem = async () => {
|
||||
}
|
||||
|
||||
const deleteOwnedItem = async (uuid: string) => {
|
||||
let rv = await apiCollectionDeleteOwn(token.currentToken, uuid, ownedItems.value.get(uuid)![2]);
|
||||
const rv = await apiCollectionDeleteOwn(token.currentToken, uuid, ownedItems.value.get(uuid)![2]);
|
||||
if (rv) {
|
||||
messagebox.value?.show("A delete operation failed. It may caused by no matched item. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
@@ -97,7 +97,7 @@ const deleteOwnedItem = async (uuid: string) => {
|
||||
const shareOwnedItem = async (uuid: string) => {
|
||||
// if there is no editing sharing target, set it.
|
||||
// otherwise override it or toggle it according to its old value.
|
||||
let old_value = sharingItemUuid.value;
|
||||
const old_value = sharingItemUuid.value;
|
||||
if (typeof old_value === 'string') {
|
||||
if (old_value === uuid) {
|
||||
sharingItemUuid.value = null;
|
||||
@@ -112,15 +112,15 @@ const shareOwnedItem = async (uuid: string) => {
|
||||
}
|
||||
|
||||
const updateOwnedItem = async (uuid: string, name: string) => {
|
||||
let lastChange = ownedItems.value.get(uuid)![2];
|
||||
let rv = await apiCollectionUpdateOwn(token.currentToken, uuid, name, lastChange);
|
||||
const lastChange = ownedItems.value.get(uuid)![2];
|
||||
const rv = await apiCollectionUpdateOwn(token.currentToken, uuid, name, lastChange);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An update operation failed. It may caused by wrong arguments or lost target. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
}
|
||||
|
||||
// return value is new lastChange, so we update it.
|
||||
let row = ownedItems.value.get(uuid)!;
|
||||
const row = ownedItems.value.get(uuid)!;
|
||||
row[1] = name;
|
||||
row[2] = rv;
|
||||
|
||||
@@ -140,8 +140,8 @@ const refreshSharingItem = async () => {
|
||||
}
|
||||
|
||||
// fetch data
|
||||
let uuid = sharingItemUuid.value!;
|
||||
let rv = await apiCollectionGetSharing(token.currentToken, uuid);
|
||||
const uuid = sharingItemUuid.value!;
|
||||
const rv = await apiCollectionGetSharing(token.currentToken, uuid);
|
||||
if (typeof rv === 'undefined') {
|
||||
console.error(`fail to fetch sharing target for collection ${uuid}`);
|
||||
return;
|
||||
@@ -152,15 +152,15 @@ const refreshSharingItem = async () => {
|
||||
}
|
||||
|
||||
const addSharingItem = async () => {
|
||||
let uuid = sharingItemUuid.value!;
|
||||
let lastChange = ownedItems.value.get(uuid)![2];
|
||||
let new_username = pendingSharingItemUsername.value;
|
||||
const uuid = sharingItemUuid.value!;
|
||||
const lastChange = ownedItems.value.get(uuid)![2];
|
||||
const new_username = pendingSharingItemUsername.value;
|
||||
if (new_username === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// order adding
|
||||
let rv = await apiCollectionAddSharing(token.currentToken, uuid, new_username, lastChange);
|
||||
const rv = await apiCollectionAddSharing(token.currentToken, uuid, new_username, lastChange);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("An add operation failed. It may caused by wrong arguments. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
@@ -173,11 +173,11 @@ const addSharingItem = async () => {
|
||||
}
|
||||
|
||||
const deleteSharingItem = async (username: string) => {
|
||||
let uuid = sharingItemUuid.value!;
|
||||
let lastChange = ownedItems.value.get(uuid)![2];
|
||||
const uuid = sharingItemUuid.value!;
|
||||
const lastChange = ownedItems.value.get(uuid)![2];
|
||||
|
||||
// order deleting
|
||||
let rv = await apiCollectionDeleteSharing(token.currentToken, uuid, username, lastChange);
|
||||
const rv = await apiCollectionDeleteSharing(token.currentToken, uuid, username, lastChange);
|
||||
if (typeof rv === 'undefined') {
|
||||
messagebox.value?.show("A delete operation failed. It may caused by no matched item. Refreshing page may fix system problem. Before refreshing page, please backup all your unsaved data.");
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user