From da9f00fff03c4690d0f05ad7c1987bf742efdd3f Mon Sep 17 00:00:00 2001 From: chfhtw Date: Tue, 15 Jul 2025 11:17:49 +0200 Subject: [PATCH] add Calendar --- application/config/calendar.php | 6 + public/css/components/calendar.css | 549 ++++-------------- public/js/components/Calendar/Base.js | 272 +++++++++ .../components/Calendar/Base/DragAndDrop.js | 162 ++++++ public/js/components/Calendar/Base/Grid.js | 344 +++++++++++ .../js/components/Calendar/Base/Grid/Line.js | 111 ++++ .../Calendar/Base/Grid/Line/Background.js | 72 +++ .../Calendar/Base/Grid/Line/Event.js | 57 ++ public/js/components/Calendar/Base/Header.js | 118 ++++ .../Calendar/Base/Header/Datepicker.js | 119 ++++ .../js/components/Calendar/Base/Label/Day.js | 39 ++ .../js/components/Calendar/Base/Label/Dow.js | 35 ++ .../js/components/Calendar/Base/Label/Time.js | 58 ++ .../js/components/Calendar/Base/Label/Week.js | 38 ++ public/js/components/Calendar/Base/Slider.js | 129 ++++ public/js/components/Calendar/Mode/Day.js | 89 +++ .../js/components/Calendar/Mode/Day/View.js | 103 ++++ public/js/components/Calendar/Mode/List.js | 93 +++ .../js/components/Calendar/Mode/List/View.js | 61 ++ public/js/components/Calendar/Mode/Month.js | 125 ++++ .../js/components/Calendar/Mode/Month/View.js | 87 +++ public/js/components/Calendar/Mode/Week.js | 108 ++++ .../js/components/Calendar/Mode/Week/View.js | 75 +++ public/js/composables/EventLoader.js | 125 ++++ public/js/directives/Calendar/Click.js | 49 ++ public/js/directives/Calendar/DragAndDrop.js | 100 ++++ public/js/helpers/DragAndDrop.js | 67 +++ system/phrasesupdate.php | 62 ++ 28 files changed, 2826 insertions(+), 427 deletions(-) create mode 100644 application/config/calendar.php create mode 100644 public/js/components/Calendar/Base.js create mode 100644 public/js/components/Calendar/Base/DragAndDrop.js create mode 100644 public/js/components/Calendar/Base/Grid.js create mode 100644 public/js/components/Calendar/Base/Grid/Line.js create mode 100644 public/js/components/Calendar/Base/Grid/Line/Background.js create mode 100644 public/js/components/Calendar/Base/Grid/Line/Event.js create mode 100644 public/js/components/Calendar/Base/Header.js create mode 100644 public/js/components/Calendar/Base/Header/Datepicker.js create mode 100644 public/js/components/Calendar/Base/Label/Day.js create mode 100644 public/js/components/Calendar/Base/Label/Dow.js create mode 100644 public/js/components/Calendar/Base/Label/Time.js create mode 100644 public/js/components/Calendar/Base/Label/Week.js create mode 100644 public/js/components/Calendar/Base/Slider.js create mode 100644 public/js/components/Calendar/Mode/Day.js create mode 100644 public/js/components/Calendar/Mode/Day/View.js create mode 100644 public/js/components/Calendar/Mode/List.js create mode 100644 public/js/components/Calendar/Mode/List/View.js create mode 100644 public/js/components/Calendar/Mode/Month.js create mode 100644 public/js/components/Calendar/Mode/Month/View.js create mode 100644 public/js/components/Calendar/Mode/Week.js create mode 100644 public/js/components/Calendar/Mode/Week/View.js create mode 100644 public/js/composables/EventLoader.js create mode 100644 public/js/directives/Calendar/Click.js create mode 100644 public/js/directives/Calendar/DragAndDrop.js create mode 100644 public/js/helpers/DragAndDrop.js 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 */` +
+ + + + + +
+
loading...
+ +
+
+ ` +} 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 */` +
+ + + + + +
+ ` +} 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',