add Calendar

This commit is contained in:
chfhtw
2025-07-15 11:17:49 +02:00
parent 1bf49ad7ab
commit da9f00fff0
28 changed files with 2826 additions and 427 deletions
+6
View File
@@ -0,0 +1,6 @@
<?php
if (!defined('BASEPATH')) exit('No direct script access allowed');
// Define configuration parameters
$config['timezone'] = 'Europe/Vienna';
+122 -427
View File
@@ -1,441 +1,136 @@
:root{ /* Themable Variables */
--fhc-calendar-pane-height: calc(100vh - 220px); :root {
--fhc-calendar-pane-height: calc(100vh - 220px);
--fhc-calendar-primary: var(--fhc-primary, #006095);
--fhc-calendar-border: var(--fhc-border, #dee2e6); --fhc-calendar-primary: var(--fhc-primary, #006095);
--fhc-calendar-border-highlight: var(--fhc-border-highlight, #495057); --fhc-calendar-border: var(--fhc-border, #dee2e6);
--fhc-calendar-text: var(--fhc-text, #212529); --fhc-calendar-border-highlight: var(--fhc-border-highlight, #495057);
--fhc-calendar-text-light: var(--fhc-text, #212529); --fhc-calendar-text: var(--fhc-text, #212529);
--fhc-calendar-background: var(--fhc-background, #fff); --fhc-calendar-text-light: var(--fhc-text, #212529);
--fhc-calendar-dark: var(--fhc-dark, #212529); --fhc-calendar-background: var(--fhc-background, #fff);
--fhc-calendar-hour-indicator-bg: var(--fhc-background, #fff); --fhc-calendar-dark: var(--fhc-dark, #212529);
--fhc-calendar-week-page-header-background: var(--fhc-background, #fff); --fhc-calendar-hour-indicator-bg: var(--fhc-background, #fff);
--fhc-calendar-week-page-header-color: var(--fhc-text, #212529); --fhc-calendar-week-page-header-background: var(--fhc-background, #fff);
--fhc-calendar-week-page-header-border: var(--fhc-border, #dee2e6); --fhc-calendar-week-page-header-color: var(--fhc-text, #212529);
--fhc-calendar-week-page-header-hover-background: var(--fhc-background-highlight, #d5dae0); --fhc-calendar-week-page-header-border: var(--fhc-border, #dee2e6);
--fhc-calendar-all-day-event-background: var(--fhc-background, #fff); --fhc-calendar-week-page-header-hover-background: var(--fhc-background-highlight, #d5dae0);
--fhc-calendar-past: var(--fhc-beige-10, rgba(245, 233, 215, 0.5)); --fhc-calendar-all-day-event-background: var(--fhc-background, #fff);
--fhc-calendar-box-shadow: var(--fhc-box-shadow, #dee2e6); --fhc-calendar-past: var(--fhc-beige-10, rgba(245, 233, 215, 0.5));
} --fhc-calendar-box-shadow: var(--fhc-box-shadow, #dee2e6);
:root.dark{
--fhc-calendar-past: var(--fhc-beige-20, rgba(172, 153, 125, 0.5));
}
.fhc-calendar-pane {
height: var(--fhc-calendar-pane-height);
}
.fhc-calendar-hour-indicator{
border-top: 1px solid var(--fhc-calendar-border);
}
.fhc-calendar-hour-indicator span{
background-color: var(--fhc-calendar-hour-indicator-bg) !important;
}
.fhc-calendar-week-page-header{
color: var(--fhc-calendar-text);
background-color: var(--fhc-calendar-week-page-header-background);
}
.fhc-calendar-week-page-header > div {
border-right: 1px solid var(--fhc-calendar-border);
}
.fhc-calendar-week-page-header > div:hover{
background-color: var(--fhc-calendar-week-page-header-hover-background);
}
.fhc-calendar-months .col-4,
.fhc-calendar-years .col-4 {
padding: 0.1875em 0;
}
.show-weeks .fhc-calendar-months .col-4,
.show-weeks .fhc-calendar-years .col-4 {
padding: 0.1875em 0.25em;
}
.fhc-calendar-months .col-4 button,
.fhc-calendar-years .col-4 button {
aspect-ratio: 28/18;
}
.fhc-calendar-month-page {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: 1.5em repeat(6, 1fr);
}
.fhc-calendar-month-page.show-weeks {
grid-template-columns: 1.5em repeat(7, 1fr);
grid-template-rows: 1.5em repeat(6, 1fr);
}
.fhc-calendar-month-page-weekday,
.fhc-calendar-month-page-day {
color: inherit;
}
.fhc-calendar-week .carousel-item {
/*padding: 0.75em;*/
}
.fhc-calendar-week-page {
/*aspect-ratio: 7/6;*/
min-height: 0;
}
.fhc-calendar-week-page > div {
/*transform: translate(-0.75em, -0.75em);*/
/*width: calc(100% + 1.5em);*/
/*height: calc(100% + 1.5em);*/
max-height: 100%;
}
.fhc-calendar-week-page > div > div {
padding-left: 3em;
}
.fhc-calendar-week-page .events {
display: grid;
grid-template-columns: 3em repeat(7, 1fr);
margin-left: -3em;
/*min-height: 266.6666666667%;*/
}
.fhc-calendar-week-page .events .day {
gap: 1px;
}
.fhc-calendar-week-page .events .hours, .fhc-calendar-week-page .events .day {
display: grid;
}
.fhc-calendar-week-page .all-day-event-border{
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
.fhc-calendar-week-page .all-day-event {
max-height: 75px;
overflow: auto;
overscroll-behavior: none;
}
.fhc-calendar-week-page .all-day-event-container{
position: sticky;
top: 44px;
display:grid;
grid-template-columns:repeat(7,1fr);
width:100%;
z-index:3;
background-color:var(--fhc-calendar-all-day-event-background);
}
.fhc-calendar-week-page .all-day-event-container::before {
position: absolute;
content:'';
top: 0;
bottom: 0;
left: -3em;
right: 100%;
background-color: var(--fhc-calendar-all-day-event-background);
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
.fhc-calendar-day-page .all-day-event-border {
box-shadow: 0 0 0 1px var(--fhc-calendar-box-shadow);
}
.fhc-calendar-day-page .all-day-event {
grid-column:2;
max-height: 75px;
overflow: auto;
}
.fhc-calendar-day-page .all-day-event-container {
position: sticky;
top:0;
display: grid;
grid-template-columns: 3em 1fr;
width: 100%;
z-index: 3;
background-color:var(--fhc-calendar-all-day-event-background);
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
}
/* grid hour lines of the Stundenplan use box-shadow instead of border because box-shadow renders consistently on different viewports*/
.box-shadow-border{
box-shadow: 0 0 0 1px var(--fhc-calendar-border) !important;
}
.fhc-calendar-no-events-overlay{
position: relative;
}
.fhc-calendar-no-events-overlay::before {
content: "";
position: absolute;
top: 0;
left: 3rem;
margin:auto;
width: 100%;
height: 100%;
background-image: linear-gradient(120deg, var(--fhc-calendar-background), var(--fhc-calendar-dark) );
opacity: .7;
}
.fhc-calendar-day-page {
/*aspect-ratio: 7/6;*/
min-height: 0;
}
.fhc-calendar-day-page > div {
height: calc(100% + 1.5em);
}
.fhc-calendar-day-page .flex-column > .flex-grow-1 {
padding-left: 3em;
}
.fhc-calendar-day-page .events {
display: grid;
grid-template-columns: 3em 1fr;
}
.fhc-calendar-day-page .events .day {
gap: 1px;
}
.fhc-calendar-day-page .events .hours, .fhc-calendar-day-page .events .day {
display: grid;
}
.fhc-calendar-month-page-day{
color: var(--fhc-calendar-text) !important;
}
.fhc-calendar-month-page-day.fhc-calendar-past:not(.fhc-calendar-month-page-day-focusday) {
color: var(--fhc-calendar-text) !important;
}
.fhc-calendar-lg .fhc-calendar-month-page-day,
.fhc-calendar-md .fhc-calendar-month-page-day {
border: solid 1px var(--fhc-calendar-border);
}
.fhc-calendar-lg .fhc-calendar-month-page-weekday,
.fhc-calendar-md .fhc-calendar-month-page-weekday{
border: solid 1px var(--fhc-secondary);
} }
.fhc-calendar-lg .fhc-calendar-month-page-day, /* Labels */
.fhc-calendar-md .fhc-calendar-month-page-day { /* ====== */
aspect-ratio: 1; .fhc-calendar-base-label-dow .short,
.fhc-calendar-base-label-dow .narrow,
.fhc-calendar-base-label-day .full,
.fhc-calendar-base-label-day .short,
.fhc-calendar-base-label-day .narrow,
.fhc-calendar-mode-month .fhc-calendar-base-label-day .long,
.collapsed-header .fhc-calendar-base-label-day .long,
.collapsed-header .fhc-calendar-base-label-dow .long {
display: none;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-day .narrow,
.collapsed-header .fhc-calendar-base-label-day .short,
.collapsed-header .fhc-calendar-base-label-dow .short {
display: block;
}
.fhc-calendar-mode-day .fhc-calendar-base-label-day,
.fhc-calendar-mode-week .fhc-calendar-base-label-day {
font-size: .875em;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-week {
text-align: right;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-week span {
color: var(--bs-secondary-color);
cursor: pointer;
}
.fhc-calendar-mode-month .fhc-calendar-base-label-week span + span:before {
content: "/";
}
/* Base Grid */
/* ========= */
/* Grid */
.fhc-calendar-base-grid {
--fhc-calendar-axis-collapsible: .2fr;
background-color: var(--bs-border-color);
grid-gap: 2px;
}
.fhc-calendar-base-grid .grid-header {
background-color: var(--bs-gray-200);
}
.fhc-calendar-base-grid .grid-main {
grid-gap: 1px;
}
.fhc-calendar-base-grid-line {
grid-gap: 1px;
}
.fhc-calendar-mode-month .main-header,
.fhc-calendar-base-grid .part-header,
.fhc-calendar-base-grid .part-body {
background-color: rgba(255,255,255, 1);
}
.fhc-calendar-base-label-time {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
align-items: end;
} }
.fhc-calendar-lg .fhc-calendar-month-page-day.active, /* Events */
.fhc-calendar-md .fhc-calendar-month-page-day.active { .fhc-calendar-base-grid-line-event.event-header {
border-color: var(--fhc-calendar-border-highlight);
}
/*.fhc-calendar-lg .fhc-calendar-month-page-day .events,*/
/*.fhc-calendar-md .fhc-calendar-month-page-day .events {*/
/* display: block;*/
/* overflow: auto;*/
/* font-size: 0.7em;*/
/*}*/
/*.fhc-calendar-lg .fhc-calendar-month-page-day .events span,*/
/*.fhc-calendar-md .fhc-calendar-month-page-day .events span {*/
/* display: block;*/
/* margin: 0.2em;*/
/* padding: 0.1em 0.4em;*/
/* border-radius: 0.1em;*/
/*}*/
.fhc-calendar-lg .fhc-calendar-years .col-4,
.fhc-calendar-md .fhc-calendar-years .col-4 {
padding: 0.09375em 0;
}
.show-weeks .fhc-calendar-lg .fhc-calendar-years .col-4,
.show-weeks .fhc-calendar-md .fhc-calendar-years .col-4 {
padding: 0.09375em 0.25em;
}
.fhc-calendar-lg .fhc-calendar-years .col-4 button,
.fhc-calendar-md .fhc-calendar-years .col-4 button {
aspect-ratio: 28/9;
}
.fhc-calendar-xs {
font-size: 0.6em;
}
.fhc-calendar-sm .fhc-calendar-month-page {
font-size: 0.8em;
}
.fhc-calendar-sm .fhc-calendar-month-page-weekday,
.fhc-calendar-xs .fhc-calendar-month-page-weekday {
font-style: italic;
display: flex;
align-items: center;
justify-content: end;
}
.fhc-calendar-month-page-weekday:hover {
background-color:var(--fhc-calendar-primary);
color:var(--fhc-calendar-text-light);
}
.fhc-calendar-sm .fhc-calendar-month-page-day,
.fhc-calendar-xs .fhc-calendar-month-page-day {
position: relative;
aspect-ratio: 1;
}
.fhc-calendar-past {
background-color: var(--fhc-calendar-past);
border-color: var(--fhc-calendar-border);
opacity: 0.5;
}
.fhc-calendar-month-page-day-focusday {
border-color: var(--fhc-calendar-border-highlight) !important;
background-color: var(--fhc-calendar-background);
position: relative;
animation: dash-animation 2.5s linear infinite;
}
@keyframes dash-animation {
0% {
border-style: dashed;
}
50% {
border-style: solid;
}
100% {
border-style: dashed;
}
}
.fhc-highlight-week {
/*border-color: black !important;*/
}
.fhc-highlight-day {
position:relative;
}
.fhc-highlight-day::before {
content:'';
position:absolute;
top:0;
bottom:0;
left:0;
right:0;
box-shadow: inset 0 0 0 2px var(--fhc-calendar-border) !important;
pointer-events: none;
z-index: 2;
}
.fhc-calendar-sm .fhc-calendar-month-page-day.active .no,
.fhc-calendar-xs .fhc-calendar-month-page-day.active .no {
background-color: var(--fhc-calendar-primary);
border-radius: 50%;
font-weight: bold; font-weight: bold;
} }
.fhc-calendar-sm .fhc-calendar-month-page-day:not(.active):hover .no, .fhc-calendar-base-grid-line-event.event-header .disabled {
.fhc-calendar-xs .fhc-calendar-month-page-day:not(.active):hover .no { opacity: .3;
background-color: var(--fhc-calendar-primary);
border-radius: 50%;
} }
/*.fhc-calendar-sm .fhc-calendar-month-page-day .no,*/ .fhc-calendar-mode-month .fhc-calendar-base-grid-line {
/*.fhc-calendar-xs .fhc-calendar-month-page-day .no {*/ overflow: hidden;
/* display: flex;*/ line-height: 1.5;
/* align-items: center;*/ font-size: .8rem;
/* justify-content: center;*/ grid-auto-rows: 1.5em;
/* width: 80%;*/ }
/* height: 80%;*/ .fhc-calendar-mode-month .fhc-calendar-base-grid-line-event {
/* margin: 10%;*/ text-indent: .5em;
/*}*/ height: 1.5em;
/*.fhc-calendar-sm .fhc-calendar-month-page-day .events,*/
/*.fhc-calendar-xs .fhc-calendar-month-page-day .events {*/
/* position: absolute;*/
/* bottom: 0;*/
/* left: 10%;*/
/* width: 80%;*/
/* height: 10%;*/
/* overflow: hidden;*/
/* display: flex;*/
/*}*/
.fhc-calendar-sm .fhc-calendar-month-page-day .events span,
.fhc-calendar-xs .fhc-calendar-month-page-day .events span {
overflow: hidden; overflow: hidden;
} }
.selectedEvent {
background-color: var(--fhc-calendar-primary) !important; /* Customize */
color: var(--fhc-calendar-text-light); /* ========= */
/* Header */
.fhc-calendar-base-header > .row {
align-items: center;
}
/* Events */
.event-colored {
background: rgba(var(--event-color-rgb), .5);
min-height: 100%;
}
.event-colored:hover {
background: rgba(var(--event-color-rgb), .3);
}
/* Backgrounds */
.background-past {
background: var(--fhc-calendar-past);
border-bottom: solid 1px var(--fhc-calendar-border);
}
.background-past > span {
display: none;
}
.background-past.bg-end > span {
display: block;
position: absolute;
top: 100%;
left: 1rem;
padding: 0 .5rem;
background-color: var(--fhc-background);
border: solid 1px var(--fhc-calendar-border);
font-size: .875em;
} }
.fhc-calendar-day-page .curTimeIndicator{
position: absolute;
left: 0;
right: 0;
pointer-events: none;
padding-left: 7rem;
margin-top: -1px;
z-index: 2;
border-color: var(--fhc-calendar-border)!important;
}
.fhc-calendar-week-page .curTimeIndicator {
position: absolute;
left: 0;
right: 0;
pointer-events: none;
padding-left: 1rem;
margin-top: -1px;
z-index: 2;
border-color: var(--fhc-calendar-border) !important;
}
.fhc-calendar-week-page .past::before{
content:"";
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.fhc-calendar-week-page .overlay {
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
}
.fhc-calendar-day-page .overlay {
background-color: var(--fhc-calendar-past);
position: absolute;
pointer-events: none;
z-index: 2;
opacity: 0.5;
}
.dayPageContainer,
.weekPageContainer {
container-type: inline-size;
}
#calendarHeaderTitle{
color: var(--fhc-text);
}
.fhc-calendar-day-page .hours,
.fhc-calendar-day-page .curTimeIndicator,
.fhc-calendar-day-page .fhc-calendar-hour-indicator,
.fhc-calendar-week-page .hours,
.fhc-calendar-week-page .curTimeIndicator,
.fhc-calendar-week-page .fhc-calendar-hour-indicator,
.fhc-calendar-day-page .date,
.fhc-calendar-week-page .date {
color: var(--fhc-text-secondary) !important;
}
+272
View File
@@ -0,0 +1,272 @@
import BaseDraganddrop from './Base/DragAndDrop.js';
import BaseHeader from './Base/Header.js';
import BaseSlider from './Base/Slider.js';
import CalClick from '../../directives/Calendar/Click.js';
/**
* TODO(chris):
* - check emits
* - event single mode (default for click:event)
* - get focusDate/currentDate correct
*/
export default {
name: "CalendarBase",
components: {
BaseDraganddrop,
BaseHeader,
BaseSlider
},
directives: {
CalClick
},
provide() {
return {
locale: Vue.computed(() => this.locale),
timezone: Vue.computed(() => this.timezone),
timeGrid: Vue.computed(() => this.timeGrid),
draggableEvents: Vue.computed(() => {
if (!this.draggableEvents)
return () => false;
if (Array.isArray(this.draggableEvents))
return event => this.draggableEvents.includes(event.type);
if (this.draggableEvents instanceof Function)
return this.draggableEvents;
return () => true;
}),
dropableEvents: Vue.computed(() => {
if (!this.onDrop)
return () => false;
if (Array.isArray(this.dropableEvents))
return item => this.dropableEvents.includes(item.type);
if (this.dropableEvents instanceof Function)
return this.dropableEvents;
return () => true;
}),
hasDragoverFunc: Vue.computed(() => this.onDragover),
mode: Vue.computed(() => this.mode)
};
},
props: {
locale: {
type: String,
default: 'de'
},
timezone: {
type: String,
required: true
},
date: {
type: [Date, String, Number, luxon.DateTime],
default: luxon.DateTime.local()
},
modes: {
type: Object,
required: true,
default: {}
// TODO(chris): verfication functions
},
mode: String,
modeOptions: Object,
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
},
showBtns: Boolean,
btnMonth: {
type: Boolean,
default: undefined
},
btnWeek: {
type: Boolean,
default: undefined
},
btnDay: {
type: Boolean,
default: undefined
},
btnList: {
type: Boolean,
default: undefined
},
timeGrid: Array,
draggableEvents: [Boolean, Array, Function],
dropableEvents: [Boolean, Array, Function],
onDragover: Function,
onDrop: Function
},
emits: [
"click:next",
"click:prev",
"click:mode",
"click:event",
"click:day",
"click:week",
"update:date",
"update:mode",
"update:range",
"drop"
],
data() {
return {
internalView: null,
internalDate: null
};
},
computed: {
convertedEvents() {
return this.events.map(orig => ({
id: orig.type + orig[orig.type + '_id'],
type: orig.type,
start: luxon.DateTime.fromISO(orig.isostart).setZone(this.timezone),
end: luxon.DateTime.fromISO(orig.isoend).setZone(this.timezone),
orig
}));
},
convertedBackgrounds() {
return this.backgrounds.map(bg => {
const res = { ...bg };
if (res.start) {
if (Number.isInteger(res.start))
res.start = luxon.DateTime.fromMillis(res.start, { zone: this.timezone, locale: this.locale });
else if (res.start instanceof Date)
res.start = luxon.DateTime.fromJSDate(res.start, { zone: this.timezone, locale: this.locale });
else if (typeof res.start ===
'string' || res.start instanceof String)
res.start = luxon.DateTime.fromISO(res.start, { zone: this.timezone, locale: this.locale });
}
if (res.end) {
if (Number.isInteger(res.end))
res.end = luxon.DateTime.fromMillis(res.end, { zone: this.timezone, locale: this.locale });
else if (res.end instanceof Date)
res.end = luxon.DateTime.fromJSDate(res.end, { zone: this.timezone, locale: this.locale });
else if (typeof res.end ===
'string' || res.end instanceof String)
res.end = luxon.DateTime.fromISO(res.end, { zone: this.timezone, locale: this.locale });
}
return res;
});
},
cDate: {
get() {
if (this.internalDate) {
return this.internalDate.setLocale(this.locale);
}
return luxon.DateTime.fromJSDate(new Date(this.date)).setZone(this.timezone).setLocale(this.locale);
},
set(value) {
this.internalDate = value;
this.$emit('update:date', value);
}
},
cMode: {
get() {
if (!this.internalView) {
// choose default mode
let mode = this.mode;
if (!mode || !this.modes[mode])
mode = Object.keys(this.modes).find(Boolean); // start with first entry as active mode
return mode || '';
}
return this.internalView;
},
set(value) {
this.internalView = value;
this.$emit('update:mode', value);
}
}
},
methods: {
clickPrev() {
const evt = new Event('click:prev', {cancelable: true});
this.$emit('click:prev', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.prevPage();
},
clickNext() {
const evt = new Event('click:next', {cancelable: true});
this.$emit('click:next', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.nextPage();
},
handleClickDefaults(evt) {
// TODO(chris): implement
switch (evt.detail.source) {
case 'day':
if (this.cMode != 'day' && this.modes['day']) {
evt.stopPropagation();
this.cDate = evt.detail.value;
this.cMode = 'day';
}
break;
case 'week':
if (this.cMode != 'week' && this.modes['week']) {
evt.stopPropagation();
this.cDate = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.cDate.zoneName,
locale: this.cDate.locale
});
this.cMode = 'week';
}
break;
}
},
onDropItem(evt, start, end) {
this.$emit('drop', evt, start, end);
}
},
template: /* html */`
<div class="fhc-calendar-base h-100">
<base-draganddrop
class="card h-100"
:events="convertedEvents"
:backgrounds="convertedBackgrounds"
@drop="onDropItem"
v-cal-click:container
@cal-click-default.capture="handleClickDefaults"
>
<base-header
class="card-header"
v-model:date="cDate"
v-model:mode="cMode"
@prev="clickPrev"
@next="clickNext"
@click:mode="$emit('click:mode', $event)"
:btn-day="!!modes['day'] && (btnDay || (showBtns && btnDay !== false))"
:btn-week="!!modes['week'] && (btnWeek || (showBtns && btnWeek !== false))"
:btn-month="!!modes['month'] && (btnMonth || (showBtns && btnMonth !== false))"
:btn-list="!!modes['list'] && (btnList || (showBtns && btnList !== false))"
>
<slot name="actions" />
</base-header>
<component
:is="modes ? modes[cMode] : null || 'div'"
ref="mode"
v-model:current-date="cDate"
@update:range="$emit('update:range', $event)"
v-bind="modeOptions ? modeOptions[cMode] : null || {}"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
</component>
</base-draganddrop>
</div>
`
}
@@ -0,0 +1,162 @@
import DragAndDrop from '../../../helpers/DragAndDrop.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
/**
* TODO(chris): this needs serious rework!
*/
export default {
name: "CalendarDragAndDrop",
directives: {
CalDnd
},
provide() {
return {
events: Vue.computed(() => this.correctedEvents),
backgrounds: Vue.computed(() => this.backgrounds),
dropAllowed: Vue.computed(() => this.dragging && this.dropAllowed)
};
},
inject: {
mode: "mode",
dropableEvents: "dropableEvents"
},
props: {
events: Array,
backgrounds: Array
},
emits: [
"drop"
],
data() {
return {
dragging: false,
allowed: false,
draggedInternalEvent: null,
draggedExternalEvent: null,
targetTimestamp: 0,
targetGridEnds: null,
dropAllowed: false,
shadowPreview: false // TODO(chris): IMPLEMENT! (use background instead of event as preview)
};
},
computed: {
correctedEvents() {
if (this.dragging) {
if (this.draggedInternalEvent) {
const index = this.events.findIndex(e => e.id == this.draggedInternalEvent.id);
if (this.previewEvent && !this.shadowPreview)
return this.events.toSpliced(index, 1, this.previewEvent);
else
return this.events.toSpliced(index, 1);
}
if (this.previewEvent && !this.shadowPreview)
return [...this.events, this.previewEvent];
}
return this.events;
},
correctedBackgrounds() {
if (this.dragging) {
if (this.shadowPreview) {
// TODO(chris): how to get the length
return [...this.backgrounds, {
start: new Date(this.targetTimestamp),
class: 'shadow-preview'
}];
}
}
return this.backgrounds;
},
previewEvent() {
if (!this.dragging || !this.dropAllowed)
return null;
if (!this.targetTimestamp)
return null;
const event = this.draggedInternalEvent || this.draggedExternalEvent;
if (!event)
return null;
// TODO(chris): calculate length correctly from orig
let length = event.end - event.start;
if (this.targetGridEnds)
length = this.targetGridEnds.find(end => end >= this.targetTimestamp + length) - this.targetTimestamp;
return {
orig: event.orig,
start: this.targetTimestamp,
end: this.targetTimestamp + length
};
}
},
methods: {
onDragstart(evt) {
DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig);
this.draggedInternalEvent = evt.detail.item;
},
onDragend() {
this.draggedInternalEvent = null;
this.dragging = false;
},
onDragenter(evt) {
this.dragging = true;
if (!this.draggedInternalEvent) {
const event = DragAndDrop.getValidTransferData(evt.detail.originalEvent);
if (event) {
this.draggedExternalEvent = {
id: event.id,
type: event.type,
start: event.isostart
? luxon.DateTime.fromISO(event.isostart).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
end: event.isoend
? luxon.DateTime.fromISO(event.isoend).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
orig: event
};
} else {
this.draggedExternalEvent = null;
}
this.dropAllowed = this.dropableEvents(event, this.mode);
} else {
this.dropAllowed = this.dropableEvents(this.draggedInternalEvent, this.mode);
}
},
onDragleave() {
this.dragging = false;
},
onDragchange(evt) {
this.targetTimestamp = evt.detail.timestamp;
this.targetGridEnds = evt.detail.ends || null;
},
onDrop(evt) {
if (!this.dragging || !this.dropAllowed)
return;
this.$emit('drop', evt, this.previewEvent.start, this.previewEvent.end);
this.dropAllowed = false;
this.dragging = false;
}
},
template: `
<div
class="fhc-calendar-base-draganddrop"
@calendar-dragstart="onDragstart"
@calendar-dragend="onDragend"
v-cal-dnd:dropcage
@calendar-dragenter="onDragenter"
@calendar-dragleave="onDragleave"
@calendar-dragchange="onDragchange"
@drop="onDrop"
>
<slot />
</div>
`
}
+344
View File
@@ -0,0 +1,344 @@
import GridLine from './Grid/Line.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
export default {
name: "CalendarGrid",
components: {
GridLine
},
directives: {
CalDnd
},
inject: {
originalEvents: "events",
originalBackgrounds: "backgrounds",
dropAllowed: "dropAllowed"
},
provide() {
return {
flipAxis: Vue.computed(() => this.flipAxis),
axisRow: Vue.computed(() => this.axisRow)
};
},
props: {
axisMain: {
type: Array,
required: true,
validator(value) {
return value.every(item => item instanceof luxon.DateTime);
}
},
axisParts: {
type: Array,
required: true,
validator(value) {
return value.every(item =>
item instanceof luxon.Duration
|| Number.isInteger(item)
|| (
(
item.start instanceof luxon.Duration
|| Number.isInteger(item.start)
) && (
item.end instanceof luxon.Duration
|| Number.isInteger(item.end)
)
)
);
}
},
flipAxis: Boolean,
allDayEvents: Boolean,
axisMainCollapsible: Boolean,
snapToGrid: Boolean
},
data() {
return {
dragging: false
};
},
computed: {
axisRow() {
return this.flipAxis ? 'column' : 'row';
},
axisCol() {
return this.flipAxis ? 'row' : 'column';
},
axisPartsWithBreaks() {
return this.axisParts.reduce((res, tu, index) => {
const start = tu.start || tu;
const end = tu.end;
if (res.length) {
const lastTuEnd = res.pop();
if (Array.isArray(lastTuEnd)) {
res.push({
start: lastTuEnd[0],
end: start,
index: lastTuEnd[1]
});
} else if (lastTuEnd != start) {
// add pause
res.push({
start: lastTuEnd,
end: start
});
}
}
if (!end) {
res.push([start, index]);
} else {
res.push({
start,
end,
index
});
res.push(end);
}
return res;
}, []).slice(0, -1);
},
axisPartsSave() {
if (!this.axisParts[this.axisParts.length - 1].end)
return this.axisParts.slice(0, -1);
return this.axisParts;
},
start() {
return this.axisPartsWithBreaks[0].start;
},
end() {
return this.axisPartsWithBreaks[this.axisPartsWithBreaks.length - 1].end;
},
ends() {
const ends = [];
const partsEnds = this.axisPartsWithBreaks
.filter(p => p.index !== undefined)
.map(p => p.end);
for (var date of this.axisMain)
for (var part of partsEnds)
ends.push(date.plus(part));
return ends;
},
axisMainBorders() {
const lastInMainAxis = this.axisMain[this.axisMain.length - 1];
const extraLength = this.end;
return [...this.axisMain, lastInMainAxis.plus(extraLength)];
},
events() {
return this.mapIntoMainAxis(this.originalEvents);
},
backgrounds() {
return this.mapIntoMainAxis(this.originalBackgrounds);
},
hasValidEvents() {
return this.events.find(e => e.length);
},
styleGridCols() {
let cols = 'repeat(' + this.axisMain.length + ', 1fr)';
if (this.axisMainCollapsible) {
if (this.hasValidEvents)
cols = this.events
.map(e => e.length
? '1fr'
: 'var(--fhc-calendar-axis-collapsible, .5fr)')
.join(' ');
}
return cols;
},
styleGridRows() {
const gridlines = {};
this.axisPartsWithBreaks.forEach(part => {
let ts = part.start.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('ps_' + part.index);
ts = part.end.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('pe_' + part.index);
});
this.events.forEach((events, mainIndex) => {
let day = this.axisMain[mainIndex];
events.forEach(event => {
if (!event.startsHere && !event.endsHere)
return;
if (this.allDayEvents && event.orig.allDayEvent)
return;
if (event.startsHere) {
let ts = event.start.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
if (event.endsHere) {
let ts = event.end.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
});
});
return '[allday] auto ' + Object.keys(gridlines).sort((a,b) => parseInt(a)-parseInt(b)).map((start, i, keys) => {
let end = keys[i + 1];
if (!end) {
gridlines[start].push('end');
return '[' + gridlines[start].join(' ') + ']';
}
return '[' + gridlines[start].join(' ') + '] ' + (end - start) + 'fr';
}).join(' ');
}
},
methods: {
mapIntoMainAxis(target) {
const result = Array.from({length: this.axisMain.length}, () => Array());
target.forEach(event => {
// NOTE(chris): make new Date object to reset the time
const start = event.start || this.axisMainBorders[0].plus(-1);
const end = event.end || this.axisMainBorders[this.axisMainBorders.length - 1].plus(1);
for (var i = 0; i < this.axisMain.length; i++) {
if (start < this.axisMainBorders[i + 1] && end > this.axisMainBorders[i]) {
const startsHere = start >= this.axisMainBorders[i];
const endsHere = end <= this.axisMainBorders[i+1];
result[i].push({
...event,
startsHere,
endsHere
});
}
}
});
return result;
},
/* DRAG AND DROP */
getPageTop(el) {
let pageTop = el.offsetTop;
if (el.offsetParent)
pageTop += this.getPageTop(el.offsetParent);
return pageTop;
},
getPageLeft(el) {
let pageLeft = el.offsetLeft;
if (el.offsetParent)
pageLeft += this.getPageLeft(el.offsetParent);
return pageLeft;
},
getTimestampFromMouse(evt, dayTimestamp) {
let mouse, mouseFrac;
if (this.flipAxis) {
mouse = evt.pageX - this.getPageLeft(this.$refs.body) + this.$refs.main.scrollLeft;
mouseFrac = mouse / this.$refs.body.offsetWidth;
} else {
mouse = evt.pageY - this.getPageTop(this.$refs.body) + this.$refs.main.scrollTop;
mouseFrac = mouse / this.$refs.body.offsetHeight;
}
return dayTimestamp + this.start + Math.floor((this.end - this.start) * mouseFrac);
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid"
style="display:grid;width:100%;height:100%"
:style="'grid-template-' + axisRow + 's:auto 1fr;grid-template-' + axisCol + 's:auto ' + styleGridCols"
>
<div
class="grid-header"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(date, index) in axisMain"
:key="index"
class="main-header"
:class="{'collapsed-header': axisMainCollapsible && hasValidEvents && !events[index].length}"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<slot name="main-header" v-bind="{ index, date }" />
</div>
</div>
<div
style="display:grid;overflow:auto"
:style="'grid-' + axisCol + ':1/-1;grid-template-' + axisCol + 's:subgrid'"
>
<div
ref="main"
class="grid-main"
style="grid-column:1/-1;grid-row:1/-1;display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-template-' + axisRow + 's:' + styleGridRows"
>
<div
v-for="(part, index) in axisPartsSave"
:key="index"
class="part-header"
:style="'grid-' + axisCol + ':1;grid-' + axisRow + ': ps_' + index + '/pe_' + index"
>
<slot name="part-header" v-bind="{ index, part }" />
</div>
<div
ref="body"
class="grid-body"
style="display:grid;grid-template-rows:subgrid;grid-template-columns:subgrid"
:style="'grid-' + axisCol + ':2/-1;grid-' + axisRow + ':1/-1'"
v-cal-dnd:dropcage
@calendar-dragenter="dragging = true"
@calendar-dragleave="dragging = false"
@dragover="dropAllowed ? $event.preventDefault() : null"
>
<template
v-for="(date, index) in axisMain"
:key="index"
>
<div
v-for="(part, i) in axisPartsSave"
:key="i"
class="part-body"
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone.once="{date: date.plus(part.start || part), ends: ends.slice(ends.findIndex(end => end > date))}"
></div>
</div>
<grid-line
:start="date.plus(start)"
:end="date.plus(end)"
:date="date"
:events="events[index]"
:backgrounds="backgrounds[index]"
style="position:relative"
:style="'grid-' + axisRow + ':1/-1;grid-' + axisCol + ':' + (1+index)"
:all-day-events="allDayEvents"
>
<template #event="slot">
<slot name="event" v-bind="slot" />
</template>
<template #dropzone>
<div
v-if="!snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone="evt => getTimestampFromMouse(evt, date)"
></div>
</template>
</grid-line>
</template>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,111 @@
import LineEvent from './Line/Event.js';
import LineBackground from './Line/Background.js';
/**
* TODO(chris):
* Event overflow for Month mode (more-button)
*/
export default {
name: "GridLine",
components: {
LineEvent,
LineBackground
},
inject: {
axisRow: "axisRow"
},
props: {
date: {
type: luxon.DateTime,
required: true
},
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
},
allDayEvents: Boolean
},
computed: {
eventsAllDay() {
if (!this.allDayEvents)
return [];
return this.events.filter(event => event.orig.allDayEvent);
},
eventsNormal() {
if (!this.allDayEvents)
return this.events;
return this.events.filter(event => !event.orig.allDayEvent);
},
eventsWithRowInfo() {
const events = [];
this.eventsNormal.forEach(event => {
const rows = [2, -1];
if (event.startsHere) {
rows[0] = 't_' + event.start.diff(this.date).toMillis();
}
if (event.endsHere) {
rows[1] = 't_' + event.end.diff(this.date).toMillis();
}
events.push({
...event,
rows
});
});
return events;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line"
style="position:relative;display:grid;grid-auto-flow:dense"
:style="'grid-template-' + axisRow + 's:subgrid'"
>
<line-background
v-for="bg in backgrounds"
:start="start"
:end="end"
:background="bg"
></line-background>
<div
v-if="eventsAllDay.length"
:style="'grid-' + axisRow + ': allday'"
class="all-day-events"
>
<line-event
v-for="(event, i) in eventsAllDay"
:key="i"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</line-event>
</div>
<line-event
v-for="(event, i) in eventsWithRowInfo"
:key="i"
:style="'grid-' + axisRow + ': ' + event.rows.join('/')"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</line-event>
<slot name="dropzone" />
</div>
`
}
@@ -0,0 +1,72 @@
export default {
name: "GridLineBackground",
inject: {
flipAxis: "flipAxis"
},
props: {
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
background: {
type: Object,
required: true,
validator(value) {
if (!value.start && !value.end)
return false;
if (value.start && !(value.start instanceof luxon.DateTime))
return false;
if (value.end && !(value.end instanceof luxon.DateTime))
return false;
return true;
}
}
},
computed: {
styles() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.style;
const perc = (this.end.ts - this.start.ts) / 100;
let border = {};
if (this.background.startsHere)
border[this.flipAxis ? 'left' : 'top'] = (this.background.start.diff(this.start)) / perc + '%';
if (this.background.endsHere)
border[this.flipAxis ? 'right' : 'bottom'] = (this.end.diff(this.background.end)) / perc + '%';
if (!this.background.style)
return border;
return [this.background.style, border];
},
classes() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.class;
const result = [];
if (this.background.class)
result.push(this.background.class);
if (this.background.startsHere)
result.push('bg-begin');
if (this.background.endsHere)
result.push('bg-end');
return result;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-background"
:class="classes"
style="position:absolute;inset:0;z-index:0"
:style="styles"
:title="background.title"
>
<span v-if="background.label">{{ background.label }}</span>
</div>
`
}
@@ -0,0 +1,57 @@
import CalDnd from '../../../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../../../directives/Calendar/Click.js';
export default {
name: "GridLineEvent",
directives: {
CalDnd,
CalClick
},
inject: {
draggableEvents: "draggableEvents",
mode: "mode"
},
props: {
event: {
type: Object,
required: true,
validator(value) {
return (value.start && value.end && value.orig);
}
}
},
computed: {
isHeaderOrFooter() {
return ['header', 'footer'].includes(this.event.orig);
},
draggable() {
return !this.isHeaderOrFooter && this.draggableEvents(this.event.orig, this.mode);
},
classes() {
const classes = [];
if (this.isHeaderOrFooter) {
classes.push('event-' + this.event.orig);
} else {
if (this.event.startsHere)
classes.push('event-begin');
if (this.event.endsHere)
classes.push('event-end');
}
return classes
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-event"
:class="classes"
style="z-index: 1"
:draggable="draggable"
v-cal-dnd:draggable="event"
v-cal-click:event="isHeaderOrFooter ? event : event.orig"
>
<slot :event="isHeaderOrFooter ? event : event.orig">
{{ event.orig }}
</slot>
</div>
`
}
@@ -0,0 +1,118 @@
/**
* TODO(chris): use click-directive
*/
import DatePicker from './Header/Datepicker.js';
export default {
name: "CalendarHeader",
components: {
DatePicker
},
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
btnMonth: Boolean,
btnWeek: Boolean,
btnDay: Boolean,
btnList: Boolean
},
emits: [
"next",
"prev",
"click:mode",
"update:date",
"update:mode"
],
data() {
return {
open: false
};
},
methods: {
clickMode(evt, mode) {
this.$emit('click:mode', evt);
if (!evt.defaultPrevented)
this.$emit('update:mode', mode);
}
},
template: /* html */`
<div class="fhc-calendar-base-header">
<div class="row">
<div class="col">
<slot />
</div>
<div class="col-auto d-flex justify-content-center">
<div class="btn-group" role="group">
<button
class="btn btn-outline-secondary border-0"
@click="$emit('prev')"
:disabled="open"
>
<i class="fa fa-chevron-left"></i>
</button>
<date-picker
:mode="mode"
:date="date"
@update:date="$emit('update:date', $event)"
@open="open = true"
@closed="open = false"
/>
<button
class="btn btn-outline-secondary border-0"
@click="$emit('next')"
:disabled="open"
>
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
<div class="col">
<div class="d-flex gap-1 justify-content-end" role="group">
<button
v-if="btnMonth"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'month'}"
@click="clickMode($event, 'month')"
>
<i class="fa fa-calendar-days"></i>
</button>
<button
v-if="btnWeek"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'week'}"
@click="clickMode($event, 'week')"
>
<i class="fa fa-calendar-week"></i>
</button>
<button
v-if="btnDay"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'day'}"
@click="clickMode($event, 'day')"
>
<i class="fa fa-calendar-day"></i>
</button>
<button
v-if="btnList"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'list'}"
@click="clickMode($event, 'list')"
>
<i class="fa fa-table-list"></i>
</button>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,119 @@
// TODO(chris): translate aria-labels
export default {
name: "CalendarHeaderDatepicker",
components: {
VueDatePicker
},
inject: [
"locale",
"timezone"
],
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
listLength: {
type: Number,
default: 7
}
},
emits: [
"update:date"
],
computed: {
convertedDate() {
// convert to target TZ then strip TZ Information
// so the datepicker can work with local times
return this.date.setZone(this.timezone).setZone('local', { keepLocalTime: true });
},
current() {
switch (this.mode) {
case "month":
return {month: this.convertedDate.month-1, year: this.convertedDate.year};
case "list":
return [this.convertedDate.startOf('day').ts, this.convertedDate.startOf('day').plus({ days: this.listLength }).ts - 1];
case "week":
return [this.convertedDate.startOf('week', { useLocaleWeeks: true }).ts, this.convertedDate.endOf('week', { useLocaleWeeks: true }).ts];
case "day":
return this.convertedDate;
default:
return null;
}
},
title() {
switch (this.mode) {
case "month":
return this.date.toLocaleString({ month: 'long', year: 'numeric' });
case "week":
const year = this.date.localWeekYear;
const week = this.date.toFormat('nn');
return this.$p.t('calendar/year_kw', { year, week });
case "list":
case "day":
return this.date.toLocaleString(luxon.DateTime.DATE_FULL);
default:
return 'View not Supported';
}
},
format() {
const title = this.title;
return `'${title}'`;
},
weekStart() {
return luxon.Info.getStartOfWeek(this.date)%7;
}
},
methods: {
update(value) {
let date;
switch (this.mode) {
case "month":
value.month++;
date = luxon.DateTime.fromObject(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "list":
case "week":
date = luxon.DateTime.fromJSDate(value[0]).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "day":
date = luxon.DateTime.fromJSDate(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
default:
return; // Don't update if the value is invalid!
}
this.$emit("update:date", date);
},
weekNumbers(date) {
return luxon.DateTime.fromJSDate(date, { locale: this.locale }).localWeekNumber;
}
},
template: /* html */`
<vue-date-picker
:model-value="current"
@update:model-value="update"
:format="format"
:month-picker="mode == 'month'"
:week-picker="mode == 'week'"
:range="mode == 'list' ? { autoRange: listLength } : false"
:text-input="mode == 'day'"
:week-start="weekStart"
:week-numbers="{ type: weekNumbers }"
:clearable="false"
:enable-time-picker="false"
:config="{ keepActionRow: mode != 'month' }"
:action-row="{ showSelect: false, showCancel: false, showNow: mode != 'month', showPreview: false }"
auto-apply
six-weeks
teleport
:locale="locale"
:now-button-label="$p.t('calendar/today')"
:week-num-name="$p.t('calendar/kw')"
/>
`
}
@@ -0,0 +1,39 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDay",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleFull() {
return this.date.toLocaleString({day: 'numeric', month: 'long', year: 'numeric'});
},
titleLong() {
return this.date.toLocaleString({day: '2-digit', month: '2-digit', year: 'numeric'});
},
titleShort() {
return this.date.toLocaleString({day: 'numeric', month: 'numeric'});
},
titleNarrow() {
return this.date.toLocaleString({day: 'numeric'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-day"
v-cal-click:day="date"
>
<span class="full">{{ titleFull }}</span>
<span class="long">{{ titleLong }}</span>
<span class="short">{{ titleShort }}</span>
<span class="narrow">{{ titleNarrow }}</span>
</div>
`
}
@@ -0,0 +1,35 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDow",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleLong() {
return this.date.toLocaleString({weekday: 'long'});
},
titleShort() {
return this.date.toLocaleString({weekday: 'short'});
},
titleNarrow() {
return this.date.toLocaleString({weekday: 'narrow'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-dow"
v-cal-click:dow="date"
>
<b class="long">{{ titleLong }}</b>
<b class="short">{{ titleShort }}</b>
<b class="narrow">{{ titleNarrow }}</b>
</div>
`
}
@@ -0,0 +1,58 @@
export default {
name: "LabelTime",
props: {
part: {
type: [luxon.Duration, Number, Object],
required: true,
validator(value) {
if (value instanceof Object) {
if (value instanceof luxon.Duration)
return true;
let start_ok = true;
let end_ok = true;
if (value.start) {
start_ok = (
value.start instanceof luxon.Duration
|| Number.isInteger(value.start)
);
}
if (value.end) {
end_ok = (
value.end instanceof luxon.Duration
|| Number.isInteger(value.end)
);
}
return start_ok && end_ok;
}
return true;
}
}
},
computed: {
sanitizedTimestamps() {
return this.part.start || this.part.end ? this.part : { start: this.part };
},
start() {
if (!this.sanitizedTimestamps.start)
return null;
return this.formatTime(this.sanitizedTimestamps.start);
},
end() {
if (!this.sanitizedTimestamps.end)
return null;
return this.formatTime(this.sanitizedTimestamps.end);
}
},
methods: {
formatTime(date) {
return date.toISOTime({ suppressSeconds: true });
}
},
template: `
<div class="fhc-calendar-base-label-time">
<span v-if="start">{{ start }}</span>
<span v-if="end">-</span>
<span v-if="end">{{ end }}</span>
</div>
`
}
@@ -0,0 +1,38 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelWeek",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
weeks() {
const firstDay = this.date.startOf('week', { useLocaleWeeks: true });
const lastDay = this.date.endOf('week', { useLocaleWeeks: true });
const weeks = [
{ number: firstDay.localWeekNumber, year: firstDay.localWeekYear },
{ number: lastDay.localWeekNumber, year: lastDay.localWeekYear }
];
if (weeks[0].number == weeks[1].number)
weeks.pop();
return weeks;
}
},
template: `
<div class="fhc-calendar-base-label-week">
<span
v-for="week in weeks"
v-cal-click:week="week"
>
{{ week.number }}
</span>
</div>
`
}
@@ -0,0 +1,129 @@
export default {
name: 'CalendarSlider',
inject: {
time: {
from: "sliderTime",
default: ".3s"
}
},
emits: [
'slid'
],
data() {
return {
target: 0,
extrasAfter: 0,
extrasBefore: 0,
running: false,
promiseResolve: null
}
},
computed: {
itemsAfter() {
return [...Array(this.extrasAfter)].map((i, k) => 1+k);
},
itemsBefore() {
return [...Array(this.extrasBefore)].map((i, k) => k-this.extrasBefore);
},
styleSlider() {
const style = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
};
if (this.running) {
style.left = (-this.target * 100) + '%';
style.transition = 'left ' + this.time + ' ease-in-out';
}
return style;
},
styleBefore() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
right: '100%',
width: (this.extrasBefore * 100) + '%'
};
},
styleAfter() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
left: '100%',
width: (this.extrasAfter * 100) + '%'
};
}
},
methods: {
prevPage() {
return this.slidePages(-1);
},
nextPage() {
return this.slidePages(1);
},
slidePages(dir) {
return new Promise(resolve => {
this.promiseResolve = resolve;
this.running = true;
const newTarget = this.target + dir;
if (newTarget > 0) {
if (this.extrasAfter < newTarget)
this.extrasAfter = newTarget;
} else if (newTarget < 0) {
if (-this.extrasBefore > newTarget)
this.extrasBefore = -newTarget;
}
this.target = newTarget;
});
},
endSlide() {
if (this.promiseResolve) {
this.promiseResolve(this.target);
this.promiseResolve = null;
}
this.$emit('slid', this.target);
this.running = false;
this.target = 0;
this.extrasAfter = this.extrasBefore = 0;
}
},
template: /* html */`
<div
class="fhc-calendar-base-slider h-100"
style="position:relative;overflow:hidden"
>
<div
:style="styleSlider"
@transitionend="endSlide"
>
<div :style="styleBefore">
<div
v-for="i in itemsBefore"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div :style="styleAfter">
<div
v-for="i in itemsAfter"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div style="height:100%;width:100%">
<slot :offset="0" />
</div>
</div>
</div>
`
}
+89
View File
@@ -0,0 +1,89 @@
import BaseSlider from '../Base/Slider.js';
import DayView from './Day/View.js';
export default {
name: "ModeDay",
components: {
BaseSlider,
DayView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('day');
let last = this.focusDate.endOf('day');
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset });
} else {
last = last.plus({ days: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(days) {
const newFocusDate = this.focusDate.plus({ days });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(days) {
const day = this.focusDate.plus({ days });
return { day };
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-day flex-grow-1 position-relative"
>
<base-slider ref="slider" v-slot="slot">
<day-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" /></template>
</day-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,103 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
export default {
name: "DayView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid",
originalEvents: "events"
},
props: {
day: {
type: luxon.DateTime,
required: true
}
},
data() {
return {
chosenEvent: null
};
},
computed: {
axisMain() {
return [this.day.startOf('day')];
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
},
events() {
return this.originalEvents
.filter(event => event.start < this.day.plus({ days: 1 }) && event.end > this.day)
.sort((a, b) => a.start.ts - b.start.ts)
.map(evt => evt.orig);
},
currentEvent() {
if (this.chosenEvent) {
if (this.events.find(e => e == this.chosenEvent))
return this.chosenEvent;
}
if (this.events)
return this.events.find(Boolean); // undefined => none found
return null; // null => loading
}
},
methods: {
handleClickDefaults(evt) {
if (evt.detail.source == 'event') {
this.chosenEvent = evt.detail.value;
}
}
},
template: /* html */`
<div
class="fhc-calendar-mode-day-view d-flex h-100"
@cal-click-default.capture="handleClickDefaults"
>
<calendar-grid
:axis-main="axisMain"
:axis-parts="axisParts"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<label-dow
@cal-click="evt => evt.detail.source = 'day'"
v-bind="{ date }"
/>
<label-day
v-bind="{ date }"
/>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<slot v-bind="slot" mode="day" />
</template>
</calendar-grid>
<div class="w-100">
<div v-if="currentEvent === null">loading...</div>
<slot v-else :event="currentEvent" mode="event" />
</div>
</div>
`
}
@@ -0,0 +1,93 @@
import BaseSlider from '../Base/Slider.js';
import ListView from './List/View.js';
export default {
name: "ModeList",
components: {
BaseSlider,
ListView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
default: 7
}
},
emits: [
"update:currentDate",
"update:range",
"click"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate;
let last = first.plus({ days: this.length });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset * this.length });
} else {
last = last.plus({ days: this.rangeOffset * this.length });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
this.rangeOffset = Math.floor(this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days / this.length);
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(offset) {
const newFocusDate = this.focusDate.plus({ days: offset * this.length });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(offset) {
const day = this.focusDate.plus({ days: offset * this.length });
return { day, length: this.length };
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-list flex-grow-1 position-relative"
>
<base-slider ref="slider" v-slot="slot">
<list-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" /></template>
</list-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,61 @@
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
// TODO(chris): drag and drop
export default {
name: "ListView",
components: {
LabelDay,
LabelDow
},
inject: {
events: "events"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
required: true
}
},
data() {
return {
chosenEvent: null
};
},
computed: {
days() {
return Array.from({ length: this.length }, (e, days) => this.day.plus({ days }));
},
eventsPerDay() {
const eventsPerDay = this.days.map(day => {
return {
day,
events: this.events
.filter(event => event.start < day.plus({ days: 1 }) && event.end > day)
.sort((a, b) => a.start.ts - b.start.ts)
};
});
return eventsPerDay.filter(day => day.events.length);
}
},
template: /* html */`
<div
class="fhc-calendar-mode-list-view h-100"
>
<div v-if="!eventsPerDay.length">
<slot :event="undefined" mode="list" />
</div>
<div v-for="{ day, events } in eventsPerDay" class="text-center">
<label-dow :date="day" class="d-inline" />, <label-day :date="day" class="d-inline" />
<div v-for="event in events">
<slot :event="event.orig" mode="list" />
</div>
</div>
</div>
`
}
+125
View File
@@ -0,0 +1,125 @@
import BaseSlider from '../Base/Slider.js';
import MonthView from './Month/View.js';
export default {
name: "ModeMonth",
components: {
BaseSlider,
MonthView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
let last = first.plus({ days: 41 }).endOf('day'); // NOTE(chris): 6 weeks minus 1 day
if (this.rangeOffset != 0) {
const nextFocusDate = this.focusDate.plus({ months: this.rangeOffset});
const nextRangeStart = nextFocusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
if (this.rangeOffset < 0) {
first = nextRangeStart;
} else {
last = nextRangeStart.plus({ days: 41 }).endOf('day');
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('month').diff(this.focusDate.startOf('month'), 'months').months;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(months) {
const newFocusDate = this.focusDate.plus({ months });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(months) {
const day = this.focusDate.plus({ months });
return { day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'week':
// default: Move to week if not in month
let dayInWeek = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.currentDate.zoneName,
locale: this.currentDate.locale
});
if (!this.focusDate.hasSame(dayInWeek.startOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.startOf('week', { useLocaleWeeks: true }));
} else if (!this.focusDate.hasSame(dayInWeek.endOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.endOf('week', { useLocaleWeeks: true }));
}
break;
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// TODO(chris): IMPLEMENT!
// default: ???
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-month flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<month-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="month" /></template>
</month-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,87 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelWeek from '../../Base/Label/Week.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelDay from '../../Base/Label/Day.js';
export default {
name: "MonthView",
components: {
CalendarGrid,
LabelWeek,
LabelDow,
LabelDay
},
provide() {
return {
// NOTE(chris): snap events to day
events: Vue.computed(() => {
//const events = [];
const events = this.events.map(event => {
const start = event.start.startOf('day');
const end = event.end.plus({ days: 1 }).startOf('day');
return {
...event,
start,
end
};
});
for (var w = 5; w > -1; w--) {
for (var d = 6; d > -1; d--) {
const startdate = this.axisMain[w].plus(this.axisParts[d]);
events.unshift({
start: startdate,
end: startdate.plus({ days: 1 }),
orig: 'header'
});
}
}
return events;
})
};
},
inject: {
events: "events"
},
props: {
day: {
type: luxon.DateTime,
required: true
}
},
computed: {
axisMain() {
const start = this.day.startOf('month').startOf('week', { useLocaleWeeks: true });
return Array.from({ length: 6 }, (e, i) => start.plus({ weeks: i }));
},
axisParts() {
return Array.from({ length: 8 }, (e, i) => luxon.Duration.fromObject({ days: i }));
}
},
template: /* html */`
<div class="fhc-calendar-mode-month-view h-100">
<calendar-grid
flip-axis
:axis-main="axisMain"
:axis-parts="axisParts"
snap-to-grid
>
<template #main-header="{ date }">
<label-week v-bind="{ date }" />
</template>
<template #part-header="{ part }">
<label-dow :date="axisMain[0].plus(part)" class="text-center" />
</template>
<template #event="slot">
<label-day
v-if="slot.event.orig == 'header'"
:date="slot.event.start"
class="text-center"
:class="{ disabled: day.month != slot.event.start.month }"
/>
<slot v-else v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+108
View File
@@ -0,0 +1,108 @@
import BaseSlider from '../Base/Slider.js';
import WeekView from './Week/View.js';
export default {
name: "ModeWeek",
components: {
BaseSlider,
WeekView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('week', { useLocaleWeeks: true });
let last = this.focusDate.endOf('week', { useLocaleWeeks: true });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ weeks: this.rangeOffset });
} else {
last = last.plus({ weeks: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
console.log(this.focusDate.toISODate(), this.currentDate.toISODate());
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('week', { useLocaleWeeks: true }).diff(this.focusDate.startOf('week', { useLocaleWeeks: true }), 'weeks').weeks;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(weeks) {
const newFocusDate = this.focusDate.plus({ weeks });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(weeks) {
const day = this.focusDate.plus({ weeks });
return { ...this.$attrs, day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// TODO(chris): IMPLEMENT!
// default: ???
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-week flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<week-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="week" /></template>
</week-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,75 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
export default {
name: "WeekView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
collapseEmptyDays: Boolean
},
computed: {
start() {
return this.day.startOf('week', { useLocaleWeeks: true });
},
axisMain() {
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
}
},
template: /* html */`
<div class="fhc-calendar-mode-week-view h-100">
<calendar-grid
:axis-main="axisMain"
:axis-parts="axisParts"
:axis-main-collapsible="collapseEmptyDays"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<label-dow
v-bind="{ date }"
@cal-click="evt => evt.detail.source = 'day'"
class="text-center"
/>
<label-day
v-bind="{ date }"
class="text-center"
/>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<slot v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+125
View File
@@ -0,0 +1,125 @@
export function useEventLoader(rangeInterval, getPromiseFunc) {
const events = Vue.ref([]);
const lv = Vue.ref(null);
const eventsLoaded = [];
const mergePromiseArr = (n, o) => {
if (Array.isArray(n))
return o.concat(n);
return o.push(n), o;
};
const markEventsLoaded = (start, end) => {
let result = [];
if (!eventsLoaded.length) {
// empty: add new chunk
eventsLoaded.push(start.ts, end.ts);
} else {
if (eventsLoaded[eventsLoaded.length-1] + 1 == start.ts) {
// add to the end of last chunk
eventsLoaded[eventsLoaded.length-1] = end.ts;
} else if (eventsLoaded[eventsLoaded.length-1] < start.ts) {
// add new chunk after the last chunk
eventsLoaded.push(start.ts, end.ts);
} else if (eventsLoaded[0] == end.ts + 1) {
// add to the start of first chunk
eventsLoaded[0] = start.ts;
} else if (eventsLoaded[0] > end.ts) {
eventsLoaded.unshift(start.ts, end.ts);
} else {
let index = eventsLoaded.findIndex(e => e >= start.ts);
if (index % 2) {
// starts inside an existing chunk
if (eventsLoaded[index] >= end.ts)
return []; // Already loaded
let indexIsLast = (index == eventsLoaded.length - 1);
if (indexIsLast || eventsLoaded[index + 1] > end.ts) {
// extend an existing chunk
// and merge with the next if necessary
let nStart = eventsLoaded[index] + 1;
start = start.plus(nStart - start.ts);
if (!indexIsLast && eventsLoaded[index + 1] == end.ts + 1)
eventsLoaded.splice(index, 2);
else
eventsLoaded[index] = end.ts;
} else {
// merge exising chunks
// and load the rest if necessary
if (eventsLoaded[index + 2] < end.ts) {
let rStart = eventsLoaded[index + 2] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nStart = eventsLoaded[index] + 1;
start = start.plus(nStart - start.ts);
let nEnd = eventsLoaded[index + 1] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded.splice(index, 2);
}
} else {
// starts between two chunks or before the first
if (!index) {
// extend the first chunk
// and load the rest if necessary
if (eventsLoaded[1] < end.ts) {
let rStart = eventsLoaded[1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nEnd = eventsLoaded[0] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded[0] = start.ts;
} else if (eventsLoaded[index] == start.ts) {
// starts at the same position as an existing chunk
if (eventsLoaded[index + 1] >= end.ts)
return []; // Already loaded
// load this rest
let rStart = eventsLoaded[index + 1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
} else {
// extend an existing chunk
// and load the rest if necessary
if (eventsLoaded[index + 1] < end.ts) {
let rStart = eventsLoaded[index + 1] + 1;
result = mergePromiseArr(markEventsLoaded(start.plus(rStart - start.ts), end), result);
}
let nEnd = eventsLoaded[index] - 1;
end = end.plus(nEnd - end.ts);
eventsLoaded[index] = start.ts;
}
}
}
}
if (start.ts > end.ts)
return [];
return mergePromiseArr(getPromiseFunc(start, end), result);
};
Vue.watchEffect(() => {
const range = Vue.toValue(rangeInterval);
if (!(range instanceof luxon.Interval))
return;
const promises = markEventsLoaded(range.start, range.end);
Promise
.allSettled(promises)
.then(results => {
results.forEach(res => {
if (
res.status === 'fulfilled'
&& res.value.meta.status === "success"
) {
if (res.value.meta.lv)
lv.value = res.value.meta.lv;
events.value = events.value.concat(res.value.data);
}
})
});
})
return { events, lv }
}
+49
View File
@@ -0,0 +1,49 @@
const clickListeners = [];
function saveAddClickListener(el, source, value) {
const index = clickListeners.findIndex(data => data.el == el);
if (index >= 0) {
el.removeEventListener('click', clickListeners[index].listener);
clickListeners.splice(index, 1);
}
const listener = evt => {
evt.preventDefault();
evt.stopPropagation();
const customEvent = new CustomEvent('cal-click', {
cancelable: true,
bubbles: true,
detail: { source, value }
});
evt.target.dispatchEvent(customEvent);
}
clickListeners.push({el, listener});
el.addEventListener('click', listener);
}
export default {
mounted(el, binding, vnode) {
if (binding.arg == 'container') {
el.addEventListener('cal-click', evt => {
const customEvent = new Event('click:' + evt.detail.source, {
cancelable: true
});
binding.instance.$emit('click:' + evt.detail.source, customEvent, evt.detail.value);
if (!customEvent.defaultPrevented) {
const finalEvent = new CustomEvent('cal-click-default', {
cancelable: true,
bubbles: true,
detail: evt.detail
});
evt.target.dispatchEvent(finalEvent);
}
});
} else {
saveAddClickListener(el, binding.arg, binding.value);
}
},
updated(el, binding, vnode, prevVnode) {
if (binding.arg != 'container') {
saveAddClickListener(el, binding.arg, binding.value);
}
}
}
@@ -0,0 +1,100 @@
/**
* TODO(chris): This needs serious rework!!!
*/
export default {
mounted(el, binding, vnode) {
if (binding.arg == 'draggable') {
el.addEventListener('update-my-value', evt => {
evt.preventDefault();
binding.value = evt.detail.item;
});
el.addEventListener('dragstart', evt => {
el.dispatchEvent(new CustomEvent('calendar-dragstart', {
cancelable: true,
bubbles: true,
detail: {
item: binding.value,
x: evt.offsetX / el.offsetWidth,
y: evt.offsetY / el.offsetHeight,
originalEvent: evt
}
}));
});
el.addEventListener('dragend', evt => {
el.dispatchEvent(new CustomEvent('calendar-dragend', {
cancelable: true,
bubbles: true,
detail: {
item: binding.value,
originalEvent: evt
}
}));
});
} else if (binding.arg == 'dropcage') {
let hitbox = null;
el.addEventListener('dragover', evt => {
if (hitbox)
return;
hitbox = el.getBoundingClientRect();
return el.dispatchEvent(new CustomEvent('calendar-dragenter', {
detail: { originalEvent: evt }
}));
});
window.addEventListener('dragleave', evt => {
if (!hitbox)
return;
let pos;
if (typeof evt.clientX === 'undefined')
pos = {
x: evt.pageX + document.documentElement.scrollLeft,
y: evt.pageY + document.documentElement.scrollTop
};
else
pos = {
x: evt.clientX + document.body.scrollLeft + document.documentElement.scrollLeft,
y: evt.clientY + document.body.scrollTop + document.documentElement.scrollTop
};
if (pos.x > hitbox.left + hitbox.width - 1 || pos.x < hitbox.left || pos.y > hitbox.top + hitbox.height - 1 || pos.y < hitbox.top) {
hitbox = null;
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
detail: { originalEvent: evt }
}));
}
});
window.addEventListener('drop', evt => {
if (!hitbox)
return;
hitbox = null;
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
detail: { originalEvent: evt }
}));
});
} else if (binding.arg == 'dropzone') {
el.addEventListener(
binding.modifiers.once ? 'dragenter' : 'dragover',
evt => {
const timestamp = binding.value instanceof Function
? binding.value(evt)
: binding.value;
const detail = timestamp.timestamp ? timestamp : { timestamp };
el.dispatchEvent(new CustomEvent('calendar-dragchange', {
cancelable: true,
bubbles: true,
detail
}));
}
);
}
},
updated(el, binding, vnode, prevVnode) {
if (binding.arg == 'draggable') {
el.dispatchEvent(new CustomEvent('update-my-value', {
cancelable: true,
detail: {
item: binding.value
}
}));
}
}
}
+67
View File
@@ -0,0 +1,67 @@
/**
* TODO(chris): This is only a prototype!!!
*/
const DragAndDrop = {
TYPE_LE: "lehreinheit",
TYPE_VEVENT: "vevent",
getValidTransferData(event, allowedTypes) {
const json = event.dataTransfer.getData('text');
let obj;
try {
obj = JSON.parse(json);
if (!obj.type)
return null;
if (allowedTypes && !allowedTypes.includes(obj.type))
return null;
} catch (error) {
return null;
}
return obj;
},
isValidTransferData(event, allowedTypes) {
return this.getValidTransferData(event, allowedTypes) ? true : false;
},
getTransferData(event) {
const json = event.dataTransfer.getData('text');
return JSON.parse(json);
},
setTransferData(event, data) {
switch (data.type) {
case DragAndDrop.TYPE_LE:
data = DragAndDrop.fromLe(data);
break;
default:
if (data.dtstart && data.dtend && data.uid && data.summary) {
data = DragAndDrop.fromVEvent(data);
break;
}
return false; // No type found => abort
}
event.dataTransfer.setData('text', JSON.stringify(data));
return true;
},
fromLe(data) {
const {
type = DragAndDrop.TYPE_LE,
lehreinheit_id: id,
stundenblockung
} = data;
return { type, id, stundenblockung };
},
fromVEvent(data) {
const {
type = DragAndDrop.TYPE_VEVENT,
uid: id,
dtstart,
dtend,
summary
} = data;
return { type, id, dtstart, dtend, summary };
}
};
export default DragAndDrop;
+62
View File
@@ -38499,6 +38499,68 @@ array(
) )
), ),
//**************************** CORE/calendar
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'kw',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'W',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'W',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'year_kw',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => '{year} KW {week}',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => '{year} W {week}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'calendar',
'phrase' => 'today',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Heute',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'today',
'description' => '',
'insertvon' => 'system'
)
)
),
//**************************** FHC-Core-SAP //**************************** FHC-Core-SAP
array( array(
'app' => 'core', 'app' => 'core',