diff --git a/application/config/calendar.php b/application/config/calendar.php
new file mode 100644
index 000000000..99bb9b2c6
--- /dev/null
+++ b/application/config/calendar.php
@@ -0,0 +1,6 @@
+ 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);
+/* Themable Variables */
+:root {
+ --fhc-calendar-pane-height: calc(100vh - 220px);
+
+ --fhc-calendar-primary: var(--fhc-primary, #006095);
+ --fhc-calendar-border: var(--fhc-border, #dee2e6);
+ --fhc-calendar-border-highlight: var(--fhc-border-highlight, #495057);
+ --fhc-calendar-text: var(--fhc-text, #212529);
+ --fhc-calendar-text-light: var(--fhc-text, #212529);
+ --fhc-calendar-background: var(--fhc-background, #fff);
+ --fhc-calendar-dark: var(--fhc-dark, #212529);
+ --fhc-calendar-hour-indicator-bg: var(--fhc-background, #fff);
+ --fhc-calendar-week-page-header-background: var(--fhc-background, #fff);
+ --fhc-calendar-week-page-header-color: var(--fhc-text, #212529);
+ --fhc-calendar-week-page-header-border: var(--fhc-border, #dee2e6);
+ --fhc-calendar-week-page-header-hover-background: var(--fhc-background-highlight, #d5dae0);
+ --fhc-calendar-all-day-event-background: var(--fhc-background, #fff);
+ --fhc-calendar-past: var(--fhc-beige-10, rgba(245, 233, 215, 0.5));
+ --fhc-calendar-box-shadow: var(--fhc-box-shadow, #dee2e6);
}
-.fhc-calendar-lg .fhc-calendar-month-page-day,
-.fhc-calendar-md .fhc-calendar-month-page-day {
- aspect-ratio: 1;
+/* Labels */
+/* ====== */
+.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;
flex-direction: column;
+ justify-content: space-between;
+ align-items: end;
}
-.fhc-calendar-lg .fhc-calendar-month-page-day.active,
-.fhc-calendar-md .fhc-calendar-month-page-day.active {
- 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%;
+/* Events */
+.fhc-calendar-base-grid-line-event.event-header {
font-weight: bold;
}
-.fhc-calendar-sm .fhc-calendar-month-page-day:not(.active):hover .no,
-.fhc-calendar-xs .fhc-calendar-month-page-day:not(.active):hover .no {
- background-color: var(--fhc-calendar-primary);
- border-radius: 50%;
+.fhc-calendar-base-grid-line-event.event-header .disabled {
+ opacity: .3;
}
-/*.fhc-calendar-sm .fhc-calendar-month-page-day .no,*/
-/*.fhc-calendar-xs .fhc-calendar-month-page-day .no {*/
-/* display: flex;*/
-/* align-items: center;*/
-/* justify-content: center;*/
-/* width: 80%;*/
-/* height: 80%;*/
-/* margin: 10%;*/
-/*}*/
-/*.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 {
+.fhc-calendar-mode-month .fhc-calendar-base-grid-line {
+ overflow: hidden;
+ line-height: 1.5;
+ font-size: .8rem;
+ grid-auto-rows: 1.5em;
+}
+.fhc-calendar-mode-month .fhc-calendar-base-grid-line-event {
+ text-indent: .5em;
+ height: 1.5em;
overflow: hidden;
}
-.selectedEvent {
- background-color: var(--fhc-calendar-primary) !important;
- color: var(--fhc-calendar-text-light);
+
+/* Customize */
+/* ========= */
+/* 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;
-}
\ No newline at end of file
diff --git a/public/js/components/Calendar/Base.js b/public/js/components/Calendar/Base.js
new file mode 100644
index 000000000..816027ade
--- /dev/null
+++ b/public/js/components/Calendar/Base.js
@@ -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 */`
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/DragAndDrop.js b/public/js/components/Calendar/Base/DragAndDrop.js
new file mode 100644
index 000000000..0be17ddb4
--- /dev/null
+++ b/public/js/components/Calendar/Base/DragAndDrop.js
@@ -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: `
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Grid.js b/public/js/components/Calendar/Base/Grid.js
new file mode 100644
index 000000000..56465c0f3
--- /dev/null
+++ b/public/js/components/Calendar/Base/Grid.js
@@ -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 */`
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Grid/Line.js b/public/js/components/Calendar/Base/Grid/Line.js
new file mode 100644
index 000000000..41f9bd58d
--- /dev/null
+++ b/public/js/components/Calendar/Base/Grid/Line.js
@@ -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 */`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Grid/Line/Background.js b/public/js/components/Calendar/Base/Grid/Line/Background.js
new file mode 100644
index 000000000..e87737ffc
--- /dev/null
+++ b/public/js/components/Calendar/Base/Grid/Line/Background.js
@@ -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 */`
+
+ {{ background.label }}
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Grid/Line/Event.js b/public/js/components/Calendar/Base/Grid/Line/Event.js
new file mode 100644
index 000000000..5cf098eb5
--- /dev/null
+++ b/public/js/components/Calendar/Base/Grid/Line/Event.js
@@ -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 */`
+
+
+ {{ event.orig }}
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Header.js b/public/js/components/Calendar/Base/Header.js
new file mode 100644
index 000000000..6dc0d3c26
--- /dev/null
+++ b/public/js/components/Calendar/Base/Header.js
@@ -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 */`
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Header/Datepicker.js b/public/js/components/Calendar/Base/Header/Datepicker.js
new file mode 100644
index 000000000..f6e521805
--- /dev/null
+++ b/public/js/components/Calendar/Base/Header/Datepicker.js
@@ -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 */`
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Label/Day.js b/public/js/components/Calendar/Base/Label/Day.js
new file mode 100644
index 000000000..731beebd2
--- /dev/null
+++ b/public/js/components/Calendar/Base/Label/Day.js
@@ -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 */`
+
+ {{ titleFull }}
+ {{ titleLong }}
+ {{ titleShort }}
+ {{ titleNarrow }}
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Label/Dow.js b/public/js/components/Calendar/Base/Label/Dow.js
new file mode 100644
index 000000000..4da02de64
--- /dev/null
+++ b/public/js/components/Calendar/Base/Label/Dow.js
@@ -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 */`
+
+ {{ titleLong }}
+ {{ titleShort }}
+ {{ titleNarrow }}
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Label/Time.js b/public/js/components/Calendar/Base/Label/Time.js
new file mode 100644
index 000000000..a7fc21ae7
--- /dev/null
+++ b/public/js/components/Calendar/Base/Label/Time.js
@@ -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: `
+
+ {{ start }}
+ -
+ {{ end }}
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Label/Week.js b/public/js/components/Calendar/Base/Label/Week.js
new file mode 100644
index 000000000..76849f2c6
--- /dev/null
+++ b/public/js/components/Calendar/Base/Label/Week.js
@@ -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: `
+
+
+ {{ week.number }}
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Base/Slider.js b/public/js/components/Calendar/Base/Slider.js
new file mode 100644
index 000000000..192eccd5f
--- /dev/null
+++ b/public/js/components/Calendar/Base/Slider.js
@@ -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 */`
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Day.js b/public/js/components/Calendar/Mode/Day.js
new file mode 100644
index 000000000..dc697cfc0
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Day.js
@@ -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: `
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Day/View.js b/public/js/components/Calendar/Mode/Day/View.js
new file mode 100644
index 000000000..cdd212b6d
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Day/View.js
@@ -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 */`
+
+
+
+ evt.detail.source = 'day'"
+ v-bind="{ date }"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/List.js b/public/js/components/Calendar/Mode/List.js
new file mode 100644
index 000000000..56ba7b1f9
--- /dev/null
+++ b/public/js/components/Calendar/Mode/List.js
@@ -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: `
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/List/View.js b/public/js/components/Calendar/Mode/List/View.js
new file mode 100644
index 000000000..50560c0e7
--- /dev/null
+++ b/public/js/components/Calendar/Mode/List/View.js
@@ -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 */`
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Month.js b/public/js/components/Calendar/Mode/Month.js
new file mode 100644
index 000000000..038c39123
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Month.js
@@ -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: `
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Month/View.js b/public/js/components/Calendar/Mode/Month/View.js
new file mode 100644
index 000000000..a9f5a5008
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Month/View.js
@@ -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 */`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Week.js b/public/js/components/Calendar/Mode/Week.js
new file mode 100644
index 000000000..a49f91194
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Week.js
@@ -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: `
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/components/Calendar/Mode/Week/View.js b/public/js/components/Calendar/Mode/Week/View.js
new file mode 100644
index 000000000..4cc2a3ae3
--- /dev/null
+++ b/public/js/components/Calendar/Mode/Week/View.js
@@ -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 */`
+
+
+
+ evt.detail.source = 'day'"
+ class="text-center"
+ />
+
+
+
+
+
+
+
+
+
+
+ `
+}
diff --git a/public/js/composables/EventLoader.js b/public/js/composables/EventLoader.js
new file mode 100644
index 000000000..541903012
--- /dev/null
+++ b/public/js/composables/EventLoader.js
@@ -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 }
+}
\ No newline at end of file
diff --git a/public/js/directives/Calendar/Click.js b/public/js/directives/Calendar/Click.js
new file mode 100644
index 000000000..e5de31f1a
--- /dev/null
+++ b/public/js/directives/Calendar/Click.js
@@ -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);
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/js/directives/Calendar/DragAndDrop.js b/public/js/directives/Calendar/DragAndDrop.js
new file mode 100644
index 000000000..b1cd015ec
--- /dev/null
+++ b/public/js/directives/Calendar/DragAndDrop.js
@@ -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
+ }
+ }));
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/js/helpers/DragAndDrop.js b/public/js/helpers/DragAndDrop.js
new file mode 100644
index 000000000..1160400f7
--- /dev/null
+++ b/public/js/helpers/DragAndDrop.js
@@ -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;
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php
index b0e9f8888..979323ab5 100644
--- a/system/phrasesupdate.php
+++ b/system/phrasesupdate.php
@@ -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
array(
'app' => 'core',