mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-06-01 12:19:28 +00:00
add Calendar
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
if (!defined('BASEPATH')) exit('No direct script access allowed');
|
||||
|
||||
// Define configuration parameters
|
||||
$config['timezone'] = 'Europe/Vienna';
|
||||
+122
-427
@@ -1,441 +1,136 @@
|
||||
: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);
|
||||
}
|
||||
|
||||
:root.dark{
|
||||
--fhc-calendar-past: var(--fhc-beige-20, rgba(172, 153, 125, 0.5));
|
||||
}
|
||||
|
||||
.fhc-calendar-pane {
|
||||
height: var(--fhc-calendar-pane-height);
|
||||
}
|
||||
|
||||
.fhc-calendar-hour-indicator{
|
||||
border-top: 1px solid var(--fhc-calendar-border);
|
||||
}
|
||||
|
||||
.fhc-calendar-hour-indicator span{
|
||||
background-color: var(--fhc-calendar-hour-indicator-bg) !important;
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page-header{
|
||||
color: var(--fhc-calendar-text);
|
||||
background-color: var(--fhc-calendar-week-page-header-background);
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page-header > div {
|
||||
border-right: 1px solid var(--fhc-calendar-border);
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page-header > div:hover{
|
||||
background-color: var(--fhc-calendar-week-page-header-hover-background);
|
||||
}
|
||||
|
||||
.fhc-calendar-months .col-4,
|
||||
.fhc-calendar-years .col-4 {
|
||||
padding: 0.1875em 0;
|
||||
}
|
||||
.show-weeks .fhc-calendar-months .col-4,
|
||||
.show-weeks .fhc-calendar-years .col-4 {
|
||||
padding: 0.1875em 0.25em;
|
||||
}
|
||||
.fhc-calendar-months .col-4 button,
|
||||
.fhc-calendar-years .col-4 button {
|
||||
aspect-ratio: 28/18;
|
||||
}
|
||||
|
||||
.fhc-calendar-month-page {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
grid-template-rows: 1.5em repeat(6, 1fr);
|
||||
}
|
||||
.fhc-calendar-month-page.show-weeks {
|
||||
grid-template-columns: 1.5em repeat(7, 1fr);
|
||||
grid-template-rows: 1.5em repeat(6, 1fr);
|
||||
}
|
||||
|
||||
.fhc-calendar-month-page-weekday,
|
||||
.fhc-calendar-month-page-day {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fhc-calendar-week .carousel-item {
|
||||
/*padding: 0.75em;*/
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page {
|
||||
/*aspect-ratio: 7/6;*/
|
||||
min-height: 0;
|
||||
}
|
||||
.fhc-calendar-week-page > div {
|
||||
/*transform: translate(-0.75em, -0.75em);*/
|
||||
/*width: calc(100% + 1.5em);*/
|
||||
/*height: calc(100% + 1.5em);*/
|
||||
max-height: 100%;
|
||||
}
|
||||
.fhc-calendar-week-page > div > div {
|
||||
padding-left: 3em;
|
||||
}
|
||||
.fhc-calendar-week-page .events {
|
||||
display: grid;
|
||||
grid-template-columns: 3em repeat(7, 1fr);
|
||||
margin-left: -3em;
|
||||
/*min-height: 266.6666666667%;*/
|
||||
}
|
||||
.fhc-calendar-week-page .events .day {
|
||||
gap: 1px;
|
||||
}
|
||||
.fhc-calendar-week-page .events .hours, .fhc-calendar-week-page .events .day {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page .all-day-event-border{
|
||||
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page .all-day-event {
|
||||
max-height: 75px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page .all-day-event-container{
|
||||
position: sticky;
|
||||
top: 44px;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(7,1fr);
|
||||
width:100%;
|
||||
z-index:3;
|
||||
background-color:var(--fhc-calendar-all-day-event-background);
|
||||
}
|
||||
|
||||
.fhc-calendar-week-page .all-day-event-container::before {
|
||||
position: absolute;
|
||||
content:'';
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -3em;
|
||||
right: 100%;
|
||||
background-color: var(--fhc-calendar-all-day-event-background);
|
||||
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
|
||||
}
|
||||
|
||||
.fhc-calendar-day-page .all-day-event-border {
|
||||
box-shadow: 0 0 0 1px var(--fhc-calendar-box-shadow);
|
||||
}
|
||||
|
||||
.fhc-calendar-day-page .all-day-event {
|
||||
grid-column:2;
|
||||
max-height: 75px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fhc-calendar-day-page .all-day-event-container {
|
||||
position: sticky;
|
||||
top:0;
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr;
|
||||
width: 100%;
|
||||
z-index: 3;
|
||||
background-color:var(--fhc-calendar-all-day-event-background);
|
||||
box-shadow: 1px 1px 0 var(--fhc-calendar-box-shadow);
|
||||
}
|
||||
|
||||
/* grid hour lines of the Stundenplan use box-shadow instead of border because box-shadow renders consistently on different viewports*/
|
||||
.box-shadow-border{
|
||||
box-shadow: 0 0 0 1px var(--fhc-calendar-border) !important;
|
||||
}
|
||||
|
||||
.fhc-calendar-no-events-overlay{
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fhc-calendar-no-events-overlay::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 3rem;
|
||||
margin:auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: linear-gradient(120deg, var(--fhc-calendar-background), var(--fhc-calendar-dark) );
|
||||
opacity: .7;
|
||||
}
|
||||
.fhc-calendar-day-page {
|
||||
/*aspect-ratio: 7/6;*/
|
||||
min-height: 0;
|
||||
}
|
||||
.fhc-calendar-day-page > div {
|
||||
height: calc(100% + 1.5em);
|
||||
}
|
||||
.fhc-calendar-day-page .flex-column > .flex-grow-1 {
|
||||
padding-left: 3em;
|
||||
}
|
||||
|
||||
.fhc-calendar-day-page .events {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr;
|
||||
}
|
||||
.fhc-calendar-day-page .events .day {
|
||||
gap: 1px;
|
||||
}
|
||||
.fhc-calendar-day-page .events .hours, .fhc-calendar-day-page .events .day {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.fhc-calendar-month-page-day{
|
||||
color: var(--fhc-calendar-text) !important;
|
||||
}
|
||||
|
||||
.fhc-calendar-month-page-day.fhc-calendar-past:not(.fhc-calendar-month-page-day-focusday) {
|
||||
color: var(--fhc-calendar-text) !important;
|
||||
}
|
||||
|
||||
.fhc-calendar-lg .fhc-calendar-month-page-day,
|
||||
.fhc-calendar-md .fhc-calendar-month-page-day {
|
||||
border: solid 1px var(--fhc-calendar-border);
|
||||
}
|
||||
|
||||
.fhc-calendar-lg .fhc-calendar-month-page-weekday,
|
||||
.fhc-calendar-md .fhc-calendar-month-page-weekday{
|
||||
border: solid 1px var(--fhc-secondary);
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import BaseDraganddrop from './Base/DragAndDrop.js';
|
||||
import BaseHeader from './Base/Header.js';
|
||||
import BaseSlider from './Base/Slider.js';
|
||||
|
||||
import CalClick from '../../directives/Calendar/Click.js';
|
||||
|
||||
/**
|
||||
* TODO(chris):
|
||||
* - check emits
|
||||
* - event single mode (default for click:event)
|
||||
* - get focusDate/currentDate correct
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: "CalendarBase",
|
||||
components: {
|
||||
BaseDraganddrop,
|
||||
BaseHeader,
|
||||
BaseSlider
|
||||
},
|
||||
directives: {
|
||||
CalClick
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
locale: Vue.computed(() => this.locale),
|
||||
timezone: Vue.computed(() => this.timezone),
|
||||
timeGrid: Vue.computed(() => this.timeGrid),
|
||||
draggableEvents: Vue.computed(() => {
|
||||
if (!this.draggableEvents)
|
||||
return () => false;
|
||||
|
||||
if (Array.isArray(this.draggableEvents))
|
||||
return event => this.draggableEvents.includes(event.type);
|
||||
if (this.draggableEvents instanceof Function)
|
||||
return this.draggableEvents;
|
||||
|
||||
return () => true;
|
||||
}),
|
||||
dropableEvents: Vue.computed(() => {
|
||||
if (!this.onDrop)
|
||||
return () => false;
|
||||
|
||||
if (Array.isArray(this.dropableEvents))
|
||||
return item => this.dropableEvents.includes(item.type);
|
||||
if (this.dropableEvents instanceof Function)
|
||||
return this.dropableEvents;
|
||||
|
||||
return () => true;
|
||||
}),
|
||||
hasDragoverFunc: Vue.computed(() => this.onDragover),
|
||||
mode: Vue.computed(() => this.mode)
|
||||
};
|
||||
},
|
||||
props: {
|
||||
locale: {
|
||||
type: String,
|
||||
default: 'de'
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
date: {
|
||||
type: [Date, String, Number, luxon.DateTime],
|
||||
default: luxon.DateTime.local()
|
||||
},
|
||||
modes: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: {}
|
||||
// TODO(chris): verfication functions
|
||||
},
|
||||
mode: String,
|
||||
modeOptions: Object,
|
||||
events: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
backgrounds: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
showBtns: Boolean,
|
||||
btnMonth: {
|
||||
type: Boolean,
|
||||
default: undefined
|
||||
},
|
||||
btnWeek: {
|
||||
type: Boolean,
|
||||
default: undefined
|
||||
},
|
||||
btnDay: {
|
||||
type: Boolean,
|
||||
default: undefined
|
||||
},
|
||||
btnList: {
|
||||
type: Boolean,
|
||||
default: undefined
|
||||
},
|
||||
timeGrid: Array,
|
||||
draggableEvents: [Boolean, Array, Function],
|
||||
dropableEvents: [Boolean, Array, Function],
|
||||
onDragover: Function,
|
||||
onDrop: Function
|
||||
},
|
||||
emits: [
|
||||
"click:next",
|
||||
"click:prev",
|
||||
"click:mode",
|
||||
"click:event",
|
||||
"click:day",
|
||||
"click:week",
|
||||
"update:date",
|
||||
"update:mode",
|
||||
"update:range",
|
||||
"drop"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
internalView: null,
|
||||
internalDate: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
convertedEvents() {
|
||||
return this.events.map(orig => ({
|
||||
id: orig.type + orig[orig.type + '_id'],
|
||||
type: orig.type,
|
||||
start: luxon.DateTime.fromISO(orig.isostart).setZone(this.timezone),
|
||||
end: luxon.DateTime.fromISO(orig.isoend).setZone(this.timezone),
|
||||
orig
|
||||
}));
|
||||
},
|
||||
convertedBackgrounds() {
|
||||
return this.backgrounds.map(bg => {
|
||||
const res = { ...bg };
|
||||
if (res.start) {
|
||||
if (Number.isInteger(res.start))
|
||||
res.start = luxon.DateTime.fromMillis(res.start, { zone: this.timezone, locale: this.locale });
|
||||
else if (res.start instanceof Date)
|
||||
res.start = luxon.DateTime.fromJSDate(res.start, { zone: this.timezone, locale: this.locale });
|
||||
else if (typeof res.start ===
|
||||
'string' || res.start instanceof String)
|
||||
res.start = luxon.DateTime.fromISO(res.start, { zone: this.timezone, locale: this.locale });
|
||||
}
|
||||
if (res.end) {
|
||||
if (Number.isInteger(res.end))
|
||||
res.end = luxon.DateTime.fromMillis(res.end, { zone: this.timezone, locale: this.locale });
|
||||
else if (res.end instanceof Date)
|
||||
res.end = luxon.DateTime.fromJSDate(res.end, { zone: this.timezone, locale: this.locale });
|
||||
else if (typeof res.end ===
|
||||
'string' || res.end instanceof String)
|
||||
res.end = luxon.DateTime.fromISO(res.end, { zone: this.timezone, locale: this.locale });
|
||||
}
|
||||
return res;
|
||||
});
|
||||
},
|
||||
cDate: {
|
||||
get() {
|
||||
if (this.internalDate) {
|
||||
return this.internalDate.setLocale(this.locale);
|
||||
}
|
||||
return luxon.DateTime.fromJSDate(new Date(this.date)).setZone(this.timezone).setLocale(this.locale);
|
||||
},
|
||||
set(value) {
|
||||
this.internalDate = value;
|
||||
this.$emit('update:date', value);
|
||||
}
|
||||
},
|
||||
cMode: {
|
||||
get() {
|
||||
if (!this.internalView) {
|
||||
// choose default mode
|
||||
let mode = this.mode;
|
||||
if (!mode || !this.modes[mode])
|
||||
mode = Object.keys(this.modes).find(Boolean); // start with first entry as active mode
|
||||
return mode || '';
|
||||
}
|
||||
return this.internalView;
|
||||
},
|
||||
set(value) {
|
||||
this.internalView = value;
|
||||
this.$emit('update:mode', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickPrev() {
|
||||
const evt = new Event('click:prev', {cancelable: true});
|
||||
this.$emit('click:prev', evt);
|
||||
if (evt.defaultPrevented)
|
||||
return;
|
||||
|
||||
// default: switch page
|
||||
this.$refs.mode.prevPage();
|
||||
},
|
||||
clickNext() {
|
||||
const evt = new Event('click:next', {cancelable: true});
|
||||
this.$emit('click:next', evt);
|
||||
if (evt.defaultPrevented)
|
||||
return;
|
||||
|
||||
// default: switch page
|
||||
this.$refs.mode.nextPage();
|
||||
},
|
||||
handleClickDefaults(evt) {
|
||||
// TODO(chris): implement
|
||||
switch (evt.detail.source) {
|
||||
case 'day':
|
||||
if (this.cMode != 'day' && this.modes['day']) {
|
||||
evt.stopPropagation();
|
||||
this.cDate = evt.detail.value;
|
||||
this.cMode = 'day';
|
||||
}
|
||||
break;
|
||||
case 'week':
|
||||
if (this.cMode != 'week' && this.modes['week']) {
|
||||
evt.stopPropagation();
|
||||
this.cDate = luxon.DateTime.fromObject({
|
||||
localWeekNumber: evt.detail.value.number,
|
||||
localWeekYear: evt.detail.value.year
|
||||
}, {
|
||||
zone: this.cDate.zoneName,
|
||||
locale: this.cDate.locale
|
||||
});
|
||||
this.cMode = 'week';
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
onDropItem(evt, start, end) {
|
||||
this.$emit('drop', evt, start, end);
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div class="fhc-calendar-base h-100">
|
||||
<base-draganddrop
|
||||
class="card h-100"
|
||||
:events="convertedEvents"
|
||||
:backgrounds="convertedBackgrounds"
|
||||
@drop="onDropItem"
|
||||
v-cal-click:container
|
||||
@cal-click-default.capture="handleClickDefaults"
|
||||
>
|
||||
<base-header
|
||||
class="card-header"
|
||||
v-model:date="cDate"
|
||||
v-model:mode="cMode"
|
||||
@prev="clickPrev"
|
||||
@next="clickNext"
|
||||
@click:mode="$emit('click:mode', $event)"
|
||||
:btn-day="!!modes['day'] && (btnDay || (showBtns && btnDay !== false))"
|
||||
:btn-week="!!modes['week'] && (btnWeek || (showBtns && btnWeek !== false))"
|
||||
:btn-month="!!modes['month'] && (btnMonth || (showBtns && btnMonth !== false))"
|
||||
:btn-list="!!modes['list'] && (btnList || (showBtns && btnList !== false))"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</base-header>
|
||||
<component
|
||||
:is="modes ? modes[cMode] : null || 'div'"
|
||||
ref="mode"
|
||||
v-model:current-date="cDate"
|
||||
@update:range="$emit('update:range', $event)"
|
||||
v-bind="modeOptions ? modeOptions[cMode] : null || {}"
|
||||
>
|
||||
<template v-slot="slot"><slot v-bind="slot" /></template>
|
||||
</component>
|
||||
</base-draganddrop>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import DragAndDrop from '../../../helpers/DragAndDrop.js';
|
||||
|
||||
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
|
||||
|
||||
/**
|
||||
* TODO(chris): this needs serious rework!
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: "CalendarDragAndDrop",
|
||||
directives: {
|
||||
CalDnd
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
events: Vue.computed(() => this.correctedEvents),
|
||||
backgrounds: Vue.computed(() => this.backgrounds),
|
||||
dropAllowed: Vue.computed(() => this.dragging && this.dropAllowed)
|
||||
};
|
||||
},
|
||||
inject: {
|
||||
mode: "mode",
|
||||
dropableEvents: "dropableEvents"
|
||||
},
|
||||
props: {
|
||||
events: Array,
|
||||
backgrounds: Array
|
||||
},
|
||||
emits: [
|
||||
"drop"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
dragging: false,
|
||||
allowed: false,
|
||||
draggedInternalEvent: null,
|
||||
draggedExternalEvent: null,
|
||||
targetTimestamp: 0,
|
||||
targetGridEnds: null,
|
||||
dropAllowed: false,
|
||||
|
||||
shadowPreview: false // TODO(chris): IMPLEMENT! (use background instead of event as preview)
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
correctedEvents() {
|
||||
if (this.dragging) {
|
||||
if (this.draggedInternalEvent) {
|
||||
const index = this.events.findIndex(e => e.id == this.draggedInternalEvent.id);
|
||||
if (this.previewEvent && !this.shadowPreview)
|
||||
return this.events.toSpliced(index, 1, this.previewEvent);
|
||||
else
|
||||
return this.events.toSpliced(index, 1);
|
||||
}
|
||||
if (this.previewEvent && !this.shadowPreview)
|
||||
return [...this.events, this.previewEvent];
|
||||
}
|
||||
|
||||
return this.events;
|
||||
},
|
||||
correctedBackgrounds() {
|
||||
if (this.dragging) {
|
||||
if (this.shadowPreview) {
|
||||
// TODO(chris): how to get the length
|
||||
return [...this.backgrounds, {
|
||||
start: new Date(this.targetTimestamp),
|
||||
class: 'shadow-preview'
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
return this.backgrounds;
|
||||
},
|
||||
previewEvent() {
|
||||
if (!this.dragging || !this.dropAllowed)
|
||||
return null;
|
||||
if (!this.targetTimestamp)
|
||||
return null;
|
||||
|
||||
const event = this.draggedInternalEvent || this.draggedExternalEvent;
|
||||
|
||||
if (!event)
|
||||
return null;
|
||||
|
||||
// TODO(chris): calculate length correctly from orig
|
||||
let length = event.end - event.start;
|
||||
if (this.targetGridEnds)
|
||||
length = this.targetGridEnds.find(end => end >= this.targetTimestamp + length) - this.targetTimestamp;
|
||||
|
||||
return {
|
||||
orig: event.orig,
|
||||
start: this.targetTimestamp,
|
||||
end: this.targetTimestamp + length
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onDragstart(evt) {
|
||||
DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig);
|
||||
this.draggedInternalEvent = evt.detail.item;
|
||||
},
|
||||
onDragend() {
|
||||
this.draggedInternalEvent = null;
|
||||
this.dragging = false;
|
||||
},
|
||||
onDragenter(evt) {
|
||||
this.dragging = true;
|
||||
|
||||
if (!this.draggedInternalEvent) {
|
||||
const event = DragAndDrop.getValidTransferData(evt.detail.originalEvent);
|
||||
if (event) {
|
||||
this.draggedExternalEvent = {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
start: event.isostart
|
||||
? luxon.DateTime.fromISO(event.isostart).setZone(this.timezone)
|
||||
: luxon.DateTime.local().setZone(this.timezone),
|
||||
end: event.isoend
|
||||
? luxon.DateTime.fromISO(event.isoend).setZone(this.timezone)
|
||||
: luxon.DateTime.local().setZone(this.timezone),
|
||||
orig: event
|
||||
};
|
||||
} else {
|
||||
this.draggedExternalEvent = null;
|
||||
}
|
||||
this.dropAllowed = this.dropableEvents(event, this.mode);
|
||||
} else {
|
||||
this.dropAllowed = this.dropableEvents(this.draggedInternalEvent, this.mode);
|
||||
}
|
||||
},
|
||||
onDragleave() {
|
||||
this.dragging = false;
|
||||
},
|
||||
onDragchange(evt) {
|
||||
this.targetTimestamp = evt.detail.timestamp;
|
||||
|
||||
this.targetGridEnds = evt.detail.ends || null;
|
||||
},
|
||||
onDrop(evt) {
|
||||
if (!this.dragging || !this.dropAllowed)
|
||||
return;
|
||||
|
||||
this.$emit('drop', evt, this.previewEvent.start, this.previewEvent.end);
|
||||
this.dropAllowed = false;
|
||||
this.dragging = false;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="fhc-calendar-base-draganddrop"
|
||||
@calendar-dragstart="onDragstart"
|
||||
@calendar-dragend="onDragend"
|
||||
v-cal-dnd:dropcage
|
||||
@calendar-dragenter="onDragenter"
|
||||
@calendar-dragleave="onDragleave"
|
||||
@calendar-dragchange="onDragchange"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import GridLine from './Grid/Line.js';
|
||||
|
||||
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
|
||||
|
||||
export default {
|
||||
name: "CalendarGrid",
|
||||
components: {
|
||||
GridLine
|
||||
},
|
||||
directives: {
|
||||
CalDnd
|
||||
},
|
||||
inject: {
|
||||
originalEvents: "events",
|
||||
originalBackgrounds: "backgrounds",
|
||||
dropAllowed: "dropAllowed"
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
flipAxis: Vue.computed(() => this.flipAxis),
|
||||
axisRow: Vue.computed(() => this.axisRow)
|
||||
};
|
||||
},
|
||||
props: {
|
||||
axisMain: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator(value) {
|
||||
return value.every(item => item instanceof luxon.DateTime);
|
||||
}
|
||||
},
|
||||
axisParts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator(value) {
|
||||
return value.every(item =>
|
||||
item instanceof luxon.Duration
|
||||
|| Number.isInteger(item)
|
||||
|| (
|
||||
(
|
||||
item.start instanceof luxon.Duration
|
||||
|| Number.isInteger(item.start)
|
||||
) && (
|
||||
item.end instanceof luxon.Duration
|
||||
|| Number.isInteger(item.end)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
flipAxis: Boolean,
|
||||
allDayEvents: Boolean,
|
||||
axisMainCollapsible: Boolean,
|
||||
snapToGrid: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dragging: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
axisRow() {
|
||||
return this.flipAxis ? 'column' : 'row';
|
||||
},
|
||||
axisCol() {
|
||||
return this.flipAxis ? 'row' : 'column';
|
||||
},
|
||||
axisPartsWithBreaks() {
|
||||
return this.axisParts.reduce((res, tu, index) => {
|
||||
const start = tu.start || tu;
|
||||
const end = tu.end;
|
||||
|
||||
if (res.length) {
|
||||
const lastTuEnd = res.pop();
|
||||
if (Array.isArray(lastTuEnd)) {
|
||||
res.push({
|
||||
start: lastTuEnd[0],
|
||||
end: start,
|
||||
index: lastTuEnd[1]
|
||||
});
|
||||
} else if (lastTuEnd != start) {
|
||||
// add pause
|
||||
res.push({
|
||||
start: lastTuEnd,
|
||||
end: start
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!end) {
|
||||
res.push([start, index]);
|
||||
} else {
|
||||
res.push({
|
||||
start,
|
||||
end,
|
||||
index
|
||||
});
|
||||
res.push(end);
|
||||
}
|
||||
return res;
|
||||
}, []).slice(0, -1);
|
||||
},
|
||||
axisPartsSave() {
|
||||
if (!this.axisParts[this.axisParts.length - 1].end)
|
||||
return this.axisParts.slice(0, -1);
|
||||
return this.axisParts;
|
||||
},
|
||||
start() {
|
||||
return this.axisPartsWithBreaks[0].start;
|
||||
},
|
||||
end() {
|
||||
return this.axisPartsWithBreaks[this.axisPartsWithBreaks.length - 1].end;
|
||||
},
|
||||
ends() {
|
||||
const ends = [];
|
||||
const partsEnds = this.axisPartsWithBreaks
|
||||
.filter(p => p.index !== undefined)
|
||||
.map(p => p.end);
|
||||
for (var date of this.axisMain)
|
||||
for (var part of partsEnds)
|
||||
ends.push(date.plus(part));
|
||||
|
||||
return ends;
|
||||
},
|
||||
axisMainBorders() {
|
||||
const lastInMainAxis = this.axisMain[this.axisMain.length - 1];
|
||||
const extraLength = this.end;
|
||||
|
||||
return [...this.axisMain, lastInMainAxis.plus(extraLength)];
|
||||
},
|
||||
events() {
|
||||
return this.mapIntoMainAxis(this.originalEvents);
|
||||
},
|
||||
backgrounds() {
|
||||
return this.mapIntoMainAxis(this.originalBackgrounds);
|
||||
},
|
||||
hasValidEvents() {
|
||||
return this.events.find(e => e.length);
|
||||
},
|
||||
styleGridCols() {
|
||||
let cols = 'repeat(' + this.axisMain.length + ', 1fr)';
|
||||
if (this.axisMainCollapsible) {
|
||||
if (this.hasValidEvents)
|
||||
cols = this.events
|
||||
.map(e => e.length
|
||||
? '1fr'
|
||||
: 'var(--fhc-calendar-axis-collapsible, .5fr)')
|
||||
.join(' ');
|
||||
}
|
||||
return cols;
|
||||
},
|
||||
styleGridRows() {
|
||||
const gridlines = {};
|
||||
|
||||
this.axisPartsWithBreaks.forEach(part => {
|
||||
let ts = part.start.toMillis();
|
||||
if (!gridlines[ts])
|
||||
gridlines[ts] = ['t_' + ts];
|
||||
if (part.index !== undefined)
|
||||
gridlines[ts].push('ps_' + part.index);
|
||||
ts = part.end.toMillis();
|
||||
if (!gridlines[ts])
|
||||
gridlines[ts] = ['t_' + ts];
|
||||
if (part.index !== undefined)
|
||||
gridlines[ts].push('pe_' + part.index);
|
||||
});
|
||||
|
||||
this.events.forEach((events, mainIndex) => {
|
||||
let day = this.axisMain[mainIndex];
|
||||
events.forEach(event => {
|
||||
if (!event.startsHere && !event.endsHere)
|
||||
return;
|
||||
if (this.allDayEvents && event.orig.allDayEvent)
|
||||
return;
|
||||
if (event.startsHere) {
|
||||
let ts = event.start.diff(day).toMillis();
|
||||
if (!gridlines[ts])
|
||||
gridlines[ts] = ['t_' + ts, 'e_' + ts];
|
||||
}
|
||||
if (event.endsHere) {
|
||||
let ts = event.end.diff(day).toMillis();
|
||||
if (!gridlines[ts])
|
||||
gridlines[ts] = ['t_' + ts, 'e_' + ts];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return '[allday] auto ' + Object.keys(gridlines).sort((a,b) => parseInt(a)-parseInt(b)).map((start, i, keys) => {
|
||||
let end = keys[i + 1];
|
||||
if (!end) {
|
||||
gridlines[start].push('end');
|
||||
return '[' + gridlines[start].join(' ') + ']';
|
||||
}
|
||||
return '[' + gridlines[start].join(' ') + '] ' + (end - start) + 'fr';
|
||||
}).join(' ');
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
mapIntoMainAxis(target) {
|
||||
const result = Array.from({length: this.axisMain.length}, () => Array());
|
||||
|
||||
target.forEach(event => {
|
||||
// NOTE(chris): make new Date object to reset the time
|
||||
const start = event.start || this.axisMainBorders[0].plus(-1);
|
||||
const end = event.end || this.axisMainBorders[this.axisMainBorders.length - 1].plus(1);
|
||||
|
||||
for (var i = 0; i < this.axisMain.length; i++) {
|
||||
if (start < this.axisMainBorders[i + 1] && end > this.axisMainBorders[i]) {
|
||||
const startsHere = start >= this.axisMainBorders[i];
|
||||
const endsHere = end <= this.axisMainBorders[i+1];
|
||||
result[i].push({
|
||||
...event,
|
||||
startsHere,
|
||||
endsHere
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/* DRAG AND DROP */
|
||||
getPageTop(el) {
|
||||
let pageTop = el.offsetTop;
|
||||
if (el.offsetParent)
|
||||
pageTop += this.getPageTop(el.offsetParent);
|
||||
return pageTop;
|
||||
},
|
||||
getPageLeft(el) {
|
||||
let pageLeft = el.offsetLeft;
|
||||
if (el.offsetParent)
|
||||
pageLeft += this.getPageLeft(el.offsetParent);
|
||||
return pageLeft;
|
||||
},
|
||||
getTimestampFromMouse(evt, dayTimestamp) {
|
||||
let mouse, mouseFrac;
|
||||
if (this.flipAxis) {
|
||||
mouse = evt.pageX - this.getPageLeft(this.$refs.body) + this.$refs.main.scrollLeft;
|
||||
mouseFrac = mouse / this.$refs.body.offsetWidth;
|
||||
} else {
|
||||
mouse = evt.pageY - this.getPageTop(this.$refs.body) + this.$refs.main.scrollTop;
|
||||
mouseFrac = mouse / this.$refs.body.offsetHeight;
|
||||
}
|
||||
|
||||
return dayTimestamp + this.start + Math.floor((this.end - this.start) * mouseFrac);
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-grid"
|
||||
style="display:grid;width:100%;height:100%"
|
||||
:style="'grid-template-' + axisRow + 's:auto 1fr;grid-template-' + axisCol + 's:auto ' + styleGridCols"
|
||||
>
|
||||
<div
|
||||
class="grid-header"
|
||||
style="display:grid"
|
||||
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
|
||||
>
|
||||
<div
|
||||
v-for="(date, index) in axisMain"
|
||||
:key="index"
|
||||
class="main-header"
|
||||
:class="{'collapsed-header': axisMainCollapsible && hasValidEvents && !events[index].length}"
|
||||
:style="'grid-' + axisCol + ':' + (2+index)"
|
||||
>
|
||||
<slot name="main-header" v-bind="{ index, date }" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="display:grid;overflow:auto"
|
||||
:style="'grid-' + axisCol + ':1/-1;grid-template-' + axisCol + 's:subgrid'"
|
||||
>
|
||||
<div
|
||||
ref="main"
|
||||
class="grid-main"
|
||||
style="grid-column:1/-1;grid-row:1/-1;display:grid"
|
||||
:style="'grid-template-' + axisCol + 's:subgrid;grid-template-' + axisRow + 's:' + styleGridRows"
|
||||
>
|
||||
<div
|
||||
v-for="(part, index) in axisPartsSave"
|
||||
:key="index"
|
||||
class="part-header"
|
||||
:style="'grid-' + axisCol + ':1;grid-' + axisRow + ': ps_' + index + '/pe_' + index"
|
||||
>
|
||||
<slot name="part-header" v-bind="{ index, part }" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="body"
|
||||
class="grid-body"
|
||||
style="display:grid;grid-template-rows:subgrid;grid-template-columns:subgrid"
|
||||
:style="'grid-' + axisCol + ':2/-1;grid-' + axisRow + ':1/-1'"
|
||||
v-cal-dnd:dropcage
|
||||
@calendar-dragenter="dragging = true"
|
||||
@calendar-dragleave="dragging = false"
|
||||
@dragover="dropAllowed ? $event.preventDefault() : null"
|
||||
>
|
||||
<template
|
||||
v-for="(date, index) in axisMain"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
v-for="(part, i) in axisPartsSave"
|
||||
:key="i"
|
||||
class="part-body"
|
||||
style="position:relative"
|
||||
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
|
||||
>
|
||||
<slot name="part-body" v-bind="{ index, part }" />
|
||||
<div
|
||||
v-if="snapToGrid && dragging"
|
||||
style="position:absolute;inset:0;z-index:1"
|
||||
v-cal-dnd:dropzone.once="{date: date.plus(part.start || part), ends: ends.slice(ends.findIndex(end => end > date))}"
|
||||
></div>
|
||||
</div>
|
||||
<grid-line
|
||||
:start="date.plus(start)"
|
||||
:end="date.plus(end)"
|
||||
:date="date"
|
||||
:events="events[index]"
|
||||
:backgrounds="backgrounds[index]"
|
||||
style="position:relative"
|
||||
:style="'grid-' + axisRow + ':1/-1;grid-' + axisCol + ':' + (1+index)"
|
||||
:all-day-events="allDayEvents"
|
||||
>
|
||||
<template #event="slot">
|
||||
<slot name="event" v-bind="slot" />
|
||||
</template>
|
||||
<template #dropzone>
|
||||
<div
|
||||
v-if="!snapToGrid && dragging"
|
||||
style="position:absolute;inset:0;z-index:1"
|
||||
v-cal-dnd:dropzone="evt => getTimestampFromMouse(evt, date)"
|
||||
></div>
|
||||
</template>
|
||||
</grid-line>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import LineEvent from './Line/Event.js';
|
||||
import LineBackground from './Line/Background.js';
|
||||
|
||||
/**
|
||||
* TODO(chris):
|
||||
* Event overflow for Month mode (more-button)
|
||||
*/
|
||||
|
||||
export default {
|
||||
name: "GridLine",
|
||||
components: {
|
||||
LineEvent,
|
||||
LineBackground
|
||||
},
|
||||
inject: {
|
||||
axisRow: "axisRow"
|
||||
},
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
start: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
end: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
events: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
backgrounds: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
allDayEvents: Boolean
|
||||
},
|
||||
computed: {
|
||||
eventsAllDay() {
|
||||
if (!this.allDayEvents)
|
||||
return [];
|
||||
return this.events.filter(event => event.orig.allDayEvent);
|
||||
},
|
||||
eventsNormal() {
|
||||
if (!this.allDayEvents)
|
||||
return this.events;
|
||||
return this.events.filter(event => !event.orig.allDayEvent);
|
||||
},
|
||||
eventsWithRowInfo() {
|
||||
const events = [];
|
||||
this.eventsNormal.forEach(event => {
|
||||
const rows = [2, -1];
|
||||
if (event.startsHere) {
|
||||
rows[0] = 't_' + event.start.diff(this.date).toMillis();
|
||||
}
|
||||
if (event.endsHere) {
|
||||
rows[1] = 't_' + event.end.diff(this.date).toMillis();
|
||||
}
|
||||
|
||||
events.push({
|
||||
...event,
|
||||
rows
|
||||
});
|
||||
});
|
||||
return events;
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-grid-line"
|
||||
style="position:relative;display:grid;grid-auto-flow:dense"
|
||||
:style="'grid-template-' + axisRow + 's:subgrid'"
|
||||
>
|
||||
<line-background
|
||||
v-for="bg in backgrounds"
|
||||
:start="start"
|
||||
:end="end"
|
||||
:background="bg"
|
||||
></line-background>
|
||||
<div
|
||||
v-if="eventsAllDay.length"
|
||||
:style="'grid-' + axisRow + ': allday'"
|
||||
class="all-day-events"
|
||||
>
|
||||
<line-event
|
||||
v-for="(event, i) in eventsAllDay"
|
||||
:key="i"
|
||||
:event="event"
|
||||
>
|
||||
<template v-slot="slot">
|
||||
<slot name="event" v-bind="slot" />
|
||||
</template>
|
||||
</line-event>
|
||||
</div>
|
||||
<line-event
|
||||
v-for="(event, i) in eventsWithRowInfo"
|
||||
:key="i"
|
||||
:style="'grid-' + axisRow + ': ' + event.rows.join('/')"
|
||||
:event="event"
|
||||
>
|
||||
<template v-slot="slot">
|
||||
<slot name="event" v-bind="slot" />
|
||||
</template>
|
||||
</line-event>
|
||||
<slot name="dropzone" />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export default {
|
||||
name: "GridLineBackground",
|
||||
inject: {
|
||||
flipAxis: "flipAxis"
|
||||
},
|
||||
props: {
|
||||
start: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
end: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
background: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator(value) {
|
||||
if (!value.start && !value.end)
|
||||
return false;
|
||||
if (value.start && !(value.start instanceof luxon.DateTime))
|
||||
return false;
|
||||
if (value.end && !(value.end instanceof luxon.DateTime))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
styles() {
|
||||
if (!this.background.endsHere && !this.background.startsHere)
|
||||
return this.background.style;
|
||||
|
||||
const perc = (this.end.ts - this.start.ts) / 100;
|
||||
|
||||
let border = {};
|
||||
if (this.background.startsHere)
|
||||
border[this.flipAxis ? 'left' : 'top'] = (this.background.start.diff(this.start)) / perc + '%';
|
||||
if (this.background.endsHere)
|
||||
border[this.flipAxis ? 'right' : 'bottom'] = (this.end.diff(this.background.end)) / perc + '%';
|
||||
|
||||
if (!this.background.style)
|
||||
return border;
|
||||
|
||||
return [this.background.style, border];
|
||||
},
|
||||
classes() {
|
||||
if (!this.background.endsHere && !this.background.startsHere)
|
||||
return this.background.class;
|
||||
|
||||
const result = [];
|
||||
if (this.background.class)
|
||||
result.push(this.background.class);
|
||||
if (this.background.startsHere)
|
||||
result.push('bg-begin');
|
||||
if (this.background.endsHere)
|
||||
result.push('bg-end');
|
||||
return result;
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-grid-line-background"
|
||||
:class="classes"
|
||||
style="position:absolute;inset:0;z-index:0"
|
||||
:style="styles"
|
||||
:title="background.title"
|
||||
>
|
||||
<span v-if="background.label">{{ background.label }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import CalDnd from '../../../../../directives/Calendar/DragAndDrop.js';
|
||||
import CalClick from '../../../../../directives/Calendar/Click.js';
|
||||
|
||||
export default {
|
||||
name: "GridLineEvent",
|
||||
directives: {
|
||||
CalDnd,
|
||||
CalClick
|
||||
},
|
||||
inject: {
|
||||
draggableEvents: "draggableEvents",
|
||||
mode: "mode"
|
||||
},
|
||||
props: {
|
||||
event: {
|
||||
type: Object,
|
||||
required: true,
|
||||
validator(value) {
|
||||
return (value.start && value.end && value.orig);
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHeaderOrFooter() {
|
||||
return ['header', 'footer'].includes(this.event.orig);
|
||||
},
|
||||
draggable() {
|
||||
return !this.isHeaderOrFooter && this.draggableEvents(this.event.orig, this.mode);
|
||||
},
|
||||
classes() {
|
||||
const classes = [];
|
||||
if (this.isHeaderOrFooter) {
|
||||
classes.push('event-' + this.event.orig);
|
||||
} else {
|
||||
if (this.event.startsHere)
|
||||
classes.push('event-begin');
|
||||
if (this.event.endsHere)
|
||||
classes.push('event-end');
|
||||
}
|
||||
return classes
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-grid-line-event"
|
||||
:class="classes"
|
||||
style="z-index: 1"
|
||||
:draggable="draggable"
|
||||
v-cal-dnd:draggable="event"
|
||||
v-cal-click:event="isHeaderOrFooter ? event : event.orig"
|
||||
>
|
||||
<slot :event="isHeaderOrFooter ? event : event.orig">
|
||||
{{ event.orig }}
|
||||
</slot>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* TODO(chris): use click-directive
|
||||
*/
|
||||
import DatePicker from './Header/Datepicker.js';
|
||||
|
||||
export default {
|
||||
name: "CalendarHeader",
|
||||
components: {
|
||||
DatePicker
|
||||
},
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
btnMonth: Boolean,
|
||||
btnWeek: Boolean,
|
||||
btnDay: Boolean,
|
||||
btnList: Boolean
|
||||
},
|
||||
emits: [
|
||||
"next",
|
||||
"prev",
|
||||
"click:mode",
|
||||
"update:date",
|
||||
"update:mode"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
open: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clickMode(evt, mode) {
|
||||
this.$emit('click:mode', evt);
|
||||
if (!evt.defaultPrevented)
|
||||
this.$emit('update:mode', mode);
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div class="fhc-calendar-base-header">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<slot />
|
||||
</div>
|
||||
<div class="col-auto d-flex justify-content-center">
|
||||
<div class="btn-group" role="group">
|
||||
<button
|
||||
class="btn btn-outline-secondary border-0"
|
||||
@click="$emit('prev')"
|
||||
:disabled="open"
|
||||
>
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
<date-picker
|
||||
:mode="mode"
|
||||
:date="date"
|
||||
@update:date="$emit('update:date', $event)"
|
||||
@open="open = true"
|
||||
@closed="open = false"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary border-0"
|
||||
@click="$emit('next')"
|
||||
:disabled="open"
|
||||
>
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="d-flex gap-1 justify-content-end" role="group">
|
||||
<button
|
||||
v-if="btnMonth"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{active: mode === 'month'}"
|
||||
@click="clickMode($event, 'month')"
|
||||
>
|
||||
<i class="fa fa-calendar-days"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="btnWeek"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{active: mode === 'week'}"
|
||||
@click="clickMode($event, 'week')"
|
||||
>
|
||||
<i class="fa fa-calendar-week"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="btnDay"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{active: mode === 'day'}"
|
||||
@click="clickMode($event, 'day')"
|
||||
>
|
||||
<i class="fa fa-calendar-day"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="btnList"
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
:class="{active: mode === 'list'}"
|
||||
@click="clickMode($event, 'list')"
|
||||
>
|
||||
<i class="fa fa-table-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// TODO(chris): translate aria-labels
|
||||
|
||||
export default {
|
||||
name: "CalendarHeaderDatepicker",
|
||||
components: {
|
||||
VueDatePicker
|
||||
},
|
||||
inject: [
|
||||
"locale",
|
||||
"timezone"
|
||||
],
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
listLength: {
|
||||
type: Number,
|
||||
default: 7
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"update:date"
|
||||
],
|
||||
computed: {
|
||||
convertedDate() {
|
||||
// convert to target TZ then strip TZ Information
|
||||
// so the datepicker can work with local times
|
||||
return this.date.setZone(this.timezone).setZone('local', { keepLocalTime: true });
|
||||
},
|
||||
current() {
|
||||
switch (this.mode) {
|
||||
case "month":
|
||||
return {month: this.convertedDate.month-1, year: this.convertedDate.year};
|
||||
case "list":
|
||||
return [this.convertedDate.startOf('day').ts, this.convertedDate.startOf('day').plus({ days: this.listLength }).ts - 1];
|
||||
case "week":
|
||||
return [this.convertedDate.startOf('week', { useLocaleWeeks: true }).ts, this.convertedDate.endOf('week', { useLocaleWeeks: true }).ts];
|
||||
case "day":
|
||||
return this.convertedDate;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
title() {
|
||||
switch (this.mode) {
|
||||
case "month":
|
||||
return this.date.toLocaleString({ month: 'long', year: 'numeric' });
|
||||
case "week":
|
||||
const year = this.date.localWeekYear;
|
||||
const week = this.date.toFormat('nn');
|
||||
return this.$p.t('calendar/year_kw', { year, week });
|
||||
case "list":
|
||||
case "day":
|
||||
return this.date.toLocaleString(luxon.DateTime.DATE_FULL);
|
||||
default:
|
||||
return 'View not Supported';
|
||||
}
|
||||
},
|
||||
format() {
|
||||
const title = this.title;
|
||||
return `'${title}'`;
|
||||
},
|
||||
weekStart() {
|
||||
return luxon.Info.getStartOfWeek(this.date)%7;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update(value) {
|
||||
let date;
|
||||
switch (this.mode) {
|
||||
case "month":
|
||||
value.month++;
|
||||
date = luxon.DateTime.fromObject(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
|
||||
break;
|
||||
case "list":
|
||||
case "week":
|
||||
date = luxon.DateTime.fromJSDate(value[0]).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
|
||||
break;
|
||||
case "day":
|
||||
date = luxon.DateTime.fromJSDate(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
|
||||
break;
|
||||
default:
|
||||
return; // Don't update if the value is invalid!
|
||||
}
|
||||
this.$emit("update:date", date);
|
||||
},
|
||||
weekNumbers(date) {
|
||||
return luxon.DateTime.fromJSDate(date, { locale: this.locale }).localWeekNumber;
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<vue-date-picker
|
||||
:model-value="current"
|
||||
@update:model-value="update"
|
||||
:format="format"
|
||||
:month-picker="mode == 'month'"
|
||||
:week-picker="mode == 'week'"
|
||||
:range="mode == 'list' ? { autoRange: listLength } : false"
|
||||
:text-input="mode == 'day'"
|
||||
:week-start="weekStart"
|
||||
:week-numbers="{ type: weekNumbers }"
|
||||
:clearable="false"
|
||||
:enable-time-picker="false"
|
||||
:config="{ keepActionRow: mode != 'month' }"
|
||||
:action-row="{ showSelect: false, showCancel: false, showNow: mode != 'month', showPreview: false }"
|
||||
auto-apply
|
||||
six-weeks
|
||||
teleport
|
||||
:locale="locale"
|
||||
:now-button-label="$p.t('calendar/today')"
|
||||
:week-num-name="$p.t('calendar/kw')"
|
||||
/>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import CalClick from '../../../../directives/Calendar/Click.js';
|
||||
|
||||
export default {
|
||||
name: "LabelDay",
|
||||
directives: {
|
||||
CalClick
|
||||
},
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
titleFull() {
|
||||
return this.date.toLocaleString({day: 'numeric', month: 'long', year: 'numeric'});
|
||||
},
|
||||
titleLong() {
|
||||
return this.date.toLocaleString({day: '2-digit', month: '2-digit', year: 'numeric'});
|
||||
},
|
||||
titleShort() {
|
||||
return this.date.toLocaleString({day: 'numeric', month: 'numeric'});
|
||||
},
|
||||
titleNarrow() {
|
||||
return this.date.toLocaleString({day: 'numeric'});
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-label-day"
|
||||
v-cal-click:day="date"
|
||||
>
|
||||
<span class="full">{{ titleFull }}</span>
|
||||
<span class="long">{{ titleLong }}</span>
|
||||
<span class="short">{{ titleShort }}</span>
|
||||
<span class="narrow">{{ titleNarrow }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import CalClick from '../../../../directives/Calendar/Click.js';
|
||||
|
||||
export default {
|
||||
name: "LabelDow",
|
||||
directives: {
|
||||
CalClick
|
||||
},
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
titleLong() {
|
||||
return this.date.toLocaleString({weekday: 'long'});
|
||||
},
|
||||
titleShort() {
|
||||
return this.date.toLocaleString({weekday: 'short'});
|
||||
},
|
||||
titleNarrow() {
|
||||
return this.date.toLocaleString({weekday: 'narrow'});
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-label-dow"
|
||||
v-cal-click:dow="date"
|
||||
>
|
||||
<b class="long">{{ titleLong }}</b>
|
||||
<b class="short">{{ titleShort }}</b>
|
||||
<b class="narrow">{{ titleNarrow }}</b>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export default {
|
||||
name: "LabelTime",
|
||||
props: {
|
||||
part: {
|
||||
type: [luxon.Duration, Number, Object],
|
||||
required: true,
|
||||
validator(value) {
|
||||
if (value instanceof Object) {
|
||||
if (value instanceof luxon.Duration)
|
||||
return true;
|
||||
let start_ok = true;
|
||||
let end_ok = true;
|
||||
if (value.start) {
|
||||
start_ok = (
|
||||
value.start instanceof luxon.Duration
|
||||
|| Number.isInteger(value.start)
|
||||
);
|
||||
}
|
||||
if (value.end) {
|
||||
end_ok = (
|
||||
value.end instanceof luxon.Duration
|
||||
|| Number.isInteger(value.end)
|
||||
);
|
||||
}
|
||||
return start_ok && end_ok;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sanitizedTimestamps() {
|
||||
return this.part.start || this.part.end ? this.part : { start: this.part };
|
||||
},
|
||||
start() {
|
||||
if (!this.sanitizedTimestamps.start)
|
||||
return null;
|
||||
return this.formatTime(this.sanitizedTimestamps.start);
|
||||
},
|
||||
end() {
|
||||
if (!this.sanitizedTimestamps.end)
|
||||
return null;
|
||||
return this.formatTime(this.sanitizedTimestamps.end);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatTime(date) {
|
||||
return date.toISOTime({ suppressSeconds: true });
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="fhc-calendar-base-label-time">
|
||||
<span v-if="start">{{ start }}</span>
|
||||
<span v-if="end">-</span>
|
||||
<span v-if="end">{{ end }}</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import CalClick from '../../../../directives/Calendar/Click.js';
|
||||
|
||||
export default {
|
||||
name: "LabelWeek",
|
||||
directives: {
|
||||
CalClick
|
||||
},
|
||||
props: {
|
||||
date: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
weeks() {
|
||||
const firstDay = this.date.startOf('week', { useLocaleWeeks: true });
|
||||
const lastDay = this.date.endOf('week', { useLocaleWeeks: true });
|
||||
|
||||
const weeks = [
|
||||
{ number: firstDay.localWeekNumber, year: firstDay.localWeekYear },
|
||||
{ number: lastDay.localWeekNumber, year: lastDay.localWeekYear }
|
||||
];
|
||||
if (weeks[0].number == weeks[1].number)
|
||||
weeks.pop();
|
||||
return weeks;
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="fhc-calendar-base-label-week">
|
||||
<span
|
||||
v-for="week in weeks"
|
||||
v-cal-click:week="week"
|
||||
>
|
||||
{{ week.number }}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export default {
|
||||
name: 'CalendarSlider',
|
||||
inject: {
|
||||
time: {
|
||||
from: "sliderTime",
|
||||
default: ".3s"
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
'slid'
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
target: 0,
|
||||
extrasAfter: 0,
|
||||
extrasBefore: 0,
|
||||
running: false,
|
||||
promiseResolve: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
itemsAfter() {
|
||||
return [...Array(this.extrasAfter)].map((i, k) => 1+k);
|
||||
},
|
||||
itemsBefore() {
|
||||
return [...Array(this.extrasBefore)].map((i, k) => k-this.extrasBefore);
|
||||
},
|
||||
styleSlider() {
|
||||
const style = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
};
|
||||
if (this.running) {
|
||||
style.left = (-this.target * 100) + '%';
|
||||
style.transition = 'left ' + this.time + ' ease-in-out';
|
||||
}
|
||||
return style;
|
||||
},
|
||||
styleBefore() {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
right: '100%',
|
||||
width: (this.extrasBefore * 100) + '%'
|
||||
};
|
||||
},
|
||||
styleAfter() {
|
||||
return {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
left: '100%',
|
||||
width: (this.extrasAfter * 100) + '%'
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
return this.slidePages(-1);
|
||||
},
|
||||
nextPage() {
|
||||
return this.slidePages(1);
|
||||
},
|
||||
slidePages(dir) {
|
||||
return new Promise(resolve => {
|
||||
this.promiseResolve = resolve;
|
||||
this.running = true;
|
||||
const newTarget = this.target + dir;
|
||||
if (newTarget > 0) {
|
||||
if (this.extrasAfter < newTarget)
|
||||
this.extrasAfter = newTarget;
|
||||
} else if (newTarget < 0) {
|
||||
if (-this.extrasBefore > newTarget)
|
||||
this.extrasBefore = -newTarget;
|
||||
}
|
||||
this.target = newTarget;
|
||||
});
|
||||
},
|
||||
endSlide() {
|
||||
if (this.promiseResolve) {
|
||||
this.promiseResolve(this.target);
|
||||
this.promiseResolve = null;
|
||||
}
|
||||
this.$emit('slid', this.target);
|
||||
this.running = false;
|
||||
this.target = 0;
|
||||
this.extrasAfter = this.extrasBefore = 0;
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-base-slider h-100"
|
||||
style="position:relative;overflow:hidden"
|
||||
>
|
||||
<div
|
||||
:style="styleSlider"
|
||||
@transitionend="endSlide"
|
||||
>
|
||||
<div :style="styleBefore">
|
||||
<div
|
||||
v-for="i in itemsBefore"
|
||||
:key="i"
|
||||
style="height:100%;width:100%"
|
||||
>
|
||||
<slot :offset="i" />
|
||||
</div>
|
||||
</div>
|
||||
<div :style="styleAfter">
|
||||
<div
|
||||
v-for="i in itemsAfter"
|
||||
:key="i"
|
||||
style="height:100%;width:100%"
|
||||
>
|
||||
<slot :offset="i" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:100%;width:100%">
|
||||
<slot :offset="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import BaseSlider from '../Base/Slider.js';
|
||||
import DayView from './Day/View.js';
|
||||
|
||||
export default {
|
||||
name: "ModeDay",
|
||||
components: {
|
||||
BaseSlider,
|
||||
DayView
|
||||
},
|
||||
props: {
|
||||
currentDate: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"update:currentDate",
|
||||
"update:range",
|
||||
"click"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
focusDate: this.currentDate,
|
||||
rangeOffset: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
range() {
|
||||
let first = this.focusDate.startOf('day');
|
||||
let last = this.focusDate.endOf('day');
|
||||
|
||||
if (this.rangeOffset != 0) {
|
||||
if (this.rangeOffset < 0) {
|
||||
first = first.plus({ days: this.rangeOffset });
|
||||
} else {
|
||||
last = last.plus({ days: this.rangeOffset });
|
||||
}
|
||||
}
|
||||
|
||||
return luxon.Interval.fromDateTimes(first, last);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentDate() {
|
||||
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
|
||||
if (this.rangeOffset) {
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
this.rangeOffset = this.$refs.slider.target - 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.prevPage().then(this.updatePage);
|
||||
},
|
||||
nextPage() {
|
||||
this.rangeOffset = this.$refs.slider.target + 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.nextPage().then(this.updatePage);
|
||||
},
|
||||
updatePage(days) {
|
||||
const newFocusDate = this.focusDate.plus({ days });
|
||||
this.focusDate = newFocusDate;
|
||||
this.rangeOffset = 0;
|
||||
this.$emit('update:currentDate', this.focusDate);
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
viewAttrs(days) {
|
||||
const day = this.focusDate.plus({ days });
|
||||
return { day };
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="fhc-calendar-mode-day flex-grow-1 position-relative"
|
||||
>
|
||||
<base-slider ref="slider" v-slot="slot">
|
||||
<day-view v-bind="viewAttrs(slot.offset)">
|
||||
<template v-slot="slot"><slot v-bind="slot" /></template>
|
||||
</day-view>
|
||||
</base-slider>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import CalendarGrid from '../../Base/Grid.js';
|
||||
import LabelDay from '../../Base/Label/Day.js';
|
||||
import LabelDow from '../../Base/Label/Dow.js';
|
||||
import LabelTime from '../../Base/Label/Time.js';
|
||||
|
||||
export default {
|
||||
name: "DayView",
|
||||
components: {
|
||||
CalendarGrid,
|
||||
LabelDay,
|
||||
LabelDow,
|
||||
LabelTime
|
||||
},
|
||||
inject: {
|
||||
timeGrid: "timeGrid",
|
||||
originalEvents: "events"
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chosenEvent: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
axisMain() {
|
||||
return [this.day.startOf('day')];
|
||||
},
|
||||
axisParts() {
|
||||
if (this.timeGrid) {
|
||||
// create {start, end} array
|
||||
return this.timeGrid.map(tu => {
|
||||
return {
|
||||
start: luxon.Duration.fromISOTime(tu.start),
|
||||
end: luxon.Duration.fromISOTime(tu.end)
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// create 07:00-23:00
|
||||
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
|
||||
}
|
||||
},
|
||||
events() {
|
||||
return this.originalEvents
|
||||
.filter(event => event.start < this.day.plus({ days: 1 }) && event.end > this.day)
|
||||
.sort((a, b) => a.start.ts - b.start.ts)
|
||||
.map(evt => evt.orig);
|
||||
},
|
||||
currentEvent() {
|
||||
if (this.chosenEvent) {
|
||||
if (this.events.find(e => e == this.chosenEvent))
|
||||
return this.chosenEvent;
|
||||
}
|
||||
if (this.events)
|
||||
return this.events.find(Boolean); // undefined => none found
|
||||
return null; // null => loading
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClickDefaults(evt) {
|
||||
if (evt.detail.source == 'event') {
|
||||
this.chosenEvent = evt.detail.value;
|
||||
}
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-mode-day-view d-flex h-100"
|
||||
@cal-click-default.capture="handleClickDefaults"
|
||||
>
|
||||
<calendar-grid
|
||||
:axis-main="axisMain"
|
||||
:axis-parts="axisParts"
|
||||
:snap-to-grid="!!timeGrid"
|
||||
all-day-events
|
||||
>
|
||||
<template #main-header="{ date }">
|
||||
<label-dow
|
||||
@cal-click="evt => evt.detail.source = 'day'"
|
||||
v-bind="{ date }"
|
||||
/>
|
||||
<label-day
|
||||
v-bind="{ date }"
|
||||
/>
|
||||
</template>
|
||||
<template #part-header="{ part }">
|
||||
<label-time v-bind="{ part }" />
|
||||
</template>
|
||||
<template #event="slot">
|
||||
<slot v-bind="slot" mode="day" />
|
||||
</template>
|
||||
</calendar-grid>
|
||||
<div class="w-100">
|
||||
<div v-if="currentEvent === null">loading...</div>
|
||||
<slot v-else :event="currentEvent" mode="event" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import BaseSlider from '../Base/Slider.js';
|
||||
import ListView from './List/View.js';
|
||||
|
||||
export default {
|
||||
name: "ModeList",
|
||||
components: {
|
||||
BaseSlider,
|
||||
ListView
|
||||
},
|
||||
props: {
|
||||
currentDate: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
default: 7
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"update:currentDate",
|
||||
"update:range",
|
||||
"click"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
focusDate: this.currentDate,
|
||||
rangeOffset: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
range() {
|
||||
let first = this.focusDate;
|
||||
let last = first.plus({ days: this.length });
|
||||
|
||||
if (this.rangeOffset != 0) {
|
||||
if (this.rangeOffset < 0) {
|
||||
first = first.plus({ days: this.rangeOffset * this.length });
|
||||
} else {
|
||||
last = last.plus({ days: this.rangeOffset * this.length });
|
||||
}
|
||||
}
|
||||
|
||||
return luxon.Interval.fromDateTimes(first, last);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentDate() {
|
||||
this.rangeOffset = Math.floor(this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days / this.length);
|
||||
if (this.rangeOffset) {
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
this.rangeOffset = this.$refs.slider.target - 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.prevPage().then(this.updatePage);
|
||||
},
|
||||
nextPage() {
|
||||
this.rangeOffset = this.$refs.slider.target + 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.nextPage().then(this.updatePage);
|
||||
},
|
||||
updatePage(offset) {
|
||||
const newFocusDate = this.focusDate.plus({ days: offset * this.length });
|
||||
this.focusDate = newFocusDate;
|
||||
this.rangeOffset = 0;
|
||||
this.$emit('update:currentDate', this.focusDate);
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
viewAttrs(offset) {
|
||||
const day = this.focusDate.plus({ days: offset * this.length });
|
||||
return { day, length: this.length };
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="fhc-calendar-mode-list flex-grow-1 position-relative"
|
||||
>
|
||||
<base-slider ref="slider" v-slot="slot">
|
||||
<list-view v-bind="viewAttrs(slot.offset)">
|
||||
<template v-slot="slot"><slot v-bind="slot" /></template>
|
||||
</list-view>
|
||||
</base-slider>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import LabelDay from '../../Base/Label/Day.js';
|
||||
import LabelDow from '../../Base/Label/Dow.js';
|
||||
|
||||
// TODO(chris): drag and drop
|
||||
|
||||
export default {
|
||||
name: "ListView",
|
||||
components: {
|
||||
LabelDay,
|
||||
LabelDow
|
||||
},
|
||||
inject: {
|
||||
events: "events"
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
chosenEvent: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
days() {
|
||||
return Array.from({ length: this.length }, (e, days) => this.day.plus({ days }));
|
||||
},
|
||||
eventsPerDay() {
|
||||
const eventsPerDay = this.days.map(day => {
|
||||
return {
|
||||
day,
|
||||
events: this.events
|
||||
.filter(event => event.start < day.plus({ days: 1 }) && event.end > day)
|
||||
.sort((a, b) => a.start.ts - b.start.ts)
|
||||
};
|
||||
});
|
||||
return eventsPerDay.filter(day => day.events.length);
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div
|
||||
class="fhc-calendar-mode-list-view h-100"
|
||||
>
|
||||
<div v-if="!eventsPerDay.length">
|
||||
<slot :event="undefined" mode="list" />
|
||||
</div>
|
||||
<div v-for="{ day, events } in eventsPerDay" class="text-center">
|
||||
<label-dow :date="day" class="d-inline" />, <label-day :date="day" class="d-inline" />
|
||||
<div v-for="event in events">
|
||||
<slot :event="event.orig" mode="list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import BaseSlider from '../Base/Slider.js';
|
||||
import MonthView from './Month/View.js';
|
||||
|
||||
export default {
|
||||
name: "ModeMonth",
|
||||
components: {
|
||||
BaseSlider,
|
||||
MonthView
|
||||
},
|
||||
props: {
|
||||
currentDate: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"update:currentDate",
|
||||
"update:range",
|
||||
"click"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
focusDate: this.currentDate,
|
||||
rangeOffset: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
range() {
|
||||
let first = this.focusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
|
||||
let last = first.plus({ days: 41 }).endOf('day'); // NOTE(chris): 6 weeks minus 1 day
|
||||
|
||||
if (this.rangeOffset != 0) {
|
||||
const nextFocusDate = this.focusDate.plus({ months: this.rangeOffset});
|
||||
const nextRangeStart = nextFocusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
|
||||
if (this.rangeOffset < 0) {
|
||||
first = nextRangeStart;
|
||||
} else {
|
||||
last = nextRangeStart.plus({ days: 41 }).endOf('day');
|
||||
}
|
||||
}
|
||||
|
||||
return luxon.Interval.fromDateTimes(first, last);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentDate() {
|
||||
if (this.currentDate.locale != this.focusDate.locale) {
|
||||
this.focusDate = this.currentDate;
|
||||
this.$emit('update:range', this.range);
|
||||
} else {
|
||||
this.rangeOffset = this.currentDate.startOf('month').diff(this.focusDate.startOf('month'), 'months').months;
|
||||
if (this.rangeOffset) {
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
this.rangeOffset = this.$refs.slider.target - 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.prevPage().then(this.updatePage);
|
||||
},
|
||||
nextPage() {
|
||||
this.rangeOffset = this.$refs.slider.target + 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.nextPage().then(this.updatePage);
|
||||
},
|
||||
updatePage(months) {
|
||||
const newFocusDate = this.focusDate.plus({ months });
|
||||
this.focusDate = newFocusDate;
|
||||
this.rangeOffset = 0;
|
||||
this.$emit('update:currentDate', this.focusDate);
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
viewAttrs(months) {
|
||||
const day = this.focusDate.plus({ months });
|
||||
return { day };
|
||||
},
|
||||
handleClickDefaults(evt) {
|
||||
switch (evt.detail.source) {
|
||||
case 'week':
|
||||
// default: Move to week if not in month
|
||||
let dayInWeek = luxon.DateTime.fromObject({
|
||||
localWeekNumber: evt.detail.value.number,
|
||||
localWeekYear: evt.detail.value.year
|
||||
}, {
|
||||
zone: this.currentDate.zoneName,
|
||||
locale: this.currentDate.locale
|
||||
});
|
||||
|
||||
if (!this.focusDate.hasSame(dayInWeek.startOf('week', { useLocaleWeeks: true }), 'month')) {
|
||||
this.$emit('update:currentDate', dayInWeek.startOf('week', { useLocaleWeeks: true }));
|
||||
} else if (!this.focusDate.hasSame(dayInWeek.endOf('week', { useLocaleWeeks: true }), 'month')) {
|
||||
this.$emit('update:currentDate', dayInWeek.endOf('week', { useLocaleWeeks: true }));
|
||||
}
|
||||
break;
|
||||
case 'day':
|
||||
// default: Set current-date
|
||||
this.$emit('update:currentDate', evt.detail.value);
|
||||
break;
|
||||
case 'event':
|
||||
// TODO(chris): IMPLEMENT!
|
||||
// default: ???
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="fhc-calendar-mode-month flex-grow-1 position-relative"
|
||||
@cal-click-default.capture="handleClickDefaults"
|
||||
>
|
||||
<base-slider ref="slider" v-slot="slot">
|
||||
<month-view v-bind="viewAttrs(slot.offset)">
|
||||
<template v-slot="slot"><slot v-bind="slot" mode="month" /></template>
|
||||
</month-view>
|
||||
</base-slider>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import CalendarGrid from '../../Base/Grid.js';
|
||||
import LabelWeek from '../../Base/Label/Week.js';
|
||||
import LabelDow from '../../Base/Label/Dow.js';
|
||||
import LabelDay from '../../Base/Label/Day.js';
|
||||
|
||||
export default {
|
||||
name: "MonthView",
|
||||
components: {
|
||||
CalendarGrid,
|
||||
LabelWeek,
|
||||
LabelDow,
|
||||
LabelDay
|
||||
},
|
||||
provide() {
|
||||
return {
|
||||
// NOTE(chris): snap events to day
|
||||
events: Vue.computed(() => {
|
||||
//const events = [];
|
||||
const events = this.events.map(event => {
|
||||
const start = event.start.startOf('day');
|
||||
const end = event.end.plus({ days: 1 }).startOf('day');
|
||||
return {
|
||||
...event,
|
||||
start,
|
||||
end
|
||||
};
|
||||
});
|
||||
for (var w = 5; w > -1; w--) {
|
||||
for (var d = 6; d > -1; d--) {
|
||||
const startdate = this.axisMain[w].plus(this.axisParts[d]);
|
||||
events.unshift({
|
||||
start: startdate,
|
||||
end: startdate.plus({ days: 1 }),
|
||||
orig: 'header'
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
})
|
||||
};
|
||||
},
|
||||
inject: {
|
||||
events: "events"
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
axisMain() {
|
||||
const start = this.day.startOf('month').startOf('week', { useLocaleWeeks: true });
|
||||
return Array.from({ length: 6 }, (e, i) => start.plus({ weeks: i }));
|
||||
},
|
||||
axisParts() {
|
||||
return Array.from({ length: 8 }, (e, i) => luxon.Duration.fromObject({ days: i }));
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div class="fhc-calendar-mode-month-view h-100">
|
||||
<calendar-grid
|
||||
flip-axis
|
||||
:axis-main="axisMain"
|
||||
:axis-parts="axisParts"
|
||||
|
||||
snap-to-grid
|
||||
>
|
||||
<template #main-header="{ date }">
|
||||
<label-week v-bind="{ date }" />
|
||||
</template>
|
||||
<template #part-header="{ part }">
|
||||
<label-dow :date="axisMain[0].plus(part)" class="text-center" />
|
||||
</template>
|
||||
<template #event="slot">
|
||||
<label-day
|
||||
v-if="slot.event.orig == 'header'"
|
||||
:date="slot.event.start"
|
||||
class="text-center"
|
||||
:class="{ disabled: day.month != slot.event.start.month }"
|
||||
/>
|
||||
<slot v-else v-bind="slot" />
|
||||
</template>
|
||||
</calendar-grid>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import BaseSlider from '../Base/Slider.js';
|
||||
import WeekView from './Week/View.js';
|
||||
|
||||
export default {
|
||||
name: "ModeWeek",
|
||||
components: {
|
||||
BaseSlider,
|
||||
WeekView
|
||||
},
|
||||
props: {
|
||||
currentDate: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: [
|
||||
"update:currentDate",
|
||||
"update:range",
|
||||
"click"
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
focusDate: this.currentDate,
|
||||
rangeOffset: 0
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
range() {
|
||||
let first = this.focusDate.startOf('week', { useLocaleWeeks: true });
|
||||
let last = this.focusDate.endOf('week', { useLocaleWeeks: true });
|
||||
|
||||
if (this.rangeOffset != 0) {
|
||||
if (this.rangeOffset < 0) {
|
||||
first = first.plus({ weeks: this.rangeOffset });
|
||||
} else {
|
||||
last = last.plus({ weeks: this.rangeOffset });
|
||||
}
|
||||
}
|
||||
|
||||
return luxon.Interval.fromDateTimes(first, last);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentDate() {
|
||||
if (this.currentDate.locale != this.focusDate.locale) {
|
||||
console.log(this.focusDate.toISODate(), this.currentDate.toISODate());
|
||||
this.focusDate = this.currentDate;
|
||||
this.$emit('update:range', this.range);
|
||||
} else {
|
||||
this.rangeOffset = this.currentDate.startOf('week', { useLocaleWeeks: true }).diff(this.focusDate.startOf('week', { useLocaleWeeks: true }), 'weeks').weeks;
|
||||
if (this.rangeOffset) {
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prevPage() {
|
||||
this.rangeOffset = this.$refs.slider.target - 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.prevPage().then(this.updatePage);
|
||||
},
|
||||
nextPage() {
|
||||
this.rangeOffset = this.$refs.slider.target + 1;
|
||||
this.$emit('update:range', this.range);
|
||||
this.$refs.slider.nextPage().then(this.updatePage);
|
||||
},
|
||||
updatePage(weeks) {
|
||||
const newFocusDate = this.focusDate.plus({ weeks });
|
||||
this.focusDate = newFocusDate;
|
||||
this.rangeOffset = 0;
|
||||
this.$emit('update:currentDate', this.focusDate);
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
viewAttrs(weeks) {
|
||||
const day = this.focusDate.plus({ weeks });
|
||||
return { ...this.$attrs, day };
|
||||
},
|
||||
handleClickDefaults(evt) {
|
||||
switch (evt.detail.source) {
|
||||
case 'day':
|
||||
// default: Set current-date
|
||||
this.$emit('update:currentDate', evt.detail.value);
|
||||
break;
|
||||
case 'event':
|
||||
// TODO(chris): IMPLEMENT!
|
||||
// default: ???
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('update:range', this.range);
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="fhc-calendar-mode-week flex-grow-1 position-relative"
|
||||
@cal-click-default.capture="handleClickDefaults"
|
||||
>
|
||||
<base-slider ref="slider" v-slot="slot">
|
||||
<week-view v-bind="viewAttrs(slot.offset)">
|
||||
<template v-slot="slot"><slot v-bind="slot" mode="week" /></template>
|
||||
</week-view>
|
||||
</base-slider>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import CalendarGrid from '../../Base/Grid.js';
|
||||
import LabelDay from '../../Base/Label/Day.js';
|
||||
import LabelDow from '../../Base/Label/Dow.js';
|
||||
import LabelTime from '../../Base/Label/Time.js';
|
||||
|
||||
export default {
|
||||
name: "WeekView",
|
||||
components: {
|
||||
CalendarGrid,
|
||||
LabelDay,
|
||||
LabelDow,
|
||||
LabelTime
|
||||
},
|
||||
inject: {
|
||||
timeGrid: "timeGrid"
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
type: luxon.DateTime,
|
||||
required: true
|
||||
},
|
||||
collapseEmptyDays: Boolean
|
||||
},
|
||||
computed: {
|
||||
start() {
|
||||
return this.day.startOf('week', { useLocaleWeeks: true });
|
||||
},
|
||||
axisMain() {
|
||||
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
|
||||
},
|
||||
axisParts() {
|
||||
if (this.timeGrid) {
|
||||
// create {start, end} array
|
||||
return this.timeGrid.map(tu => {
|
||||
return {
|
||||
start: luxon.Duration.fromISOTime(tu.start),
|
||||
end: luxon.Duration.fromISOTime(tu.end)
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// create 07:00-23:00
|
||||
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
|
||||
}
|
||||
}
|
||||
},
|
||||
template: /* html */`
|
||||
<div class="fhc-calendar-mode-week-view h-100">
|
||||
<calendar-grid
|
||||
:axis-main="axisMain"
|
||||
:axis-parts="axisParts"
|
||||
:axis-main-collapsible="collapseEmptyDays"
|
||||
:snap-to-grid="!!timeGrid"
|
||||
all-day-events
|
||||
>
|
||||
<template #main-header="{ date }">
|
||||
<label-dow
|
||||
v-bind="{ date }"
|
||||
@cal-click="evt => evt.detail.source = 'day'"
|
||||
class="text-center"
|
||||
/>
|
||||
<label-day
|
||||
v-bind="{ date }"
|
||||
class="text-center"
|
||||
/>
|
||||
</template>
|
||||
<template #part-header="{ part }">
|
||||
<label-time v-bind="{ part }" />
|
||||
</template>
|
||||
<template #event="slot">
|
||||
<slot v-bind="slot" />
|
||||
</template>
|
||||
</calendar-grid>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const clickListeners = [];
|
||||
|
||||
function saveAddClickListener(el, source, value) {
|
||||
const index = clickListeners.findIndex(data => data.el == el);
|
||||
if (index >= 0) {
|
||||
el.removeEventListener('click', clickListeners[index].listener);
|
||||
clickListeners.splice(index, 1);
|
||||
}
|
||||
const listener = evt => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const customEvent = new CustomEvent('cal-click', {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
detail: { source, value }
|
||||
});
|
||||
evt.target.dispatchEvent(customEvent);
|
||||
}
|
||||
clickListeners.push({el, listener});
|
||||
el.addEventListener('click', listener);
|
||||
}
|
||||
|
||||
export default {
|
||||
mounted(el, binding, vnode) {
|
||||
if (binding.arg == 'container') {
|
||||
el.addEventListener('cal-click', evt => {
|
||||
const customEvent = new Event('click:' + evt.detail.source, {
|
||||
cancelable: true
|
||||
});
|
||||
binding.instance.$emit('click:' + evt.detail.source, customEvent, evt.detail.value);
|
||||
if (!customEvent.defaultPrevented) {
|
||||
const finalEvent = new CustomEvent('cal-click-default', {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
detail: evt.detail
|
||||
});
|
||||
evt.target.dispatchEvent(finalEvent);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
saveAddClickListener(el, binding.arg, binding.value);
|
||||
}
|
||||
},
|
||||
updated(el, binding, vnode, prevVnode) {
|
||||
if (binding.arg != 'container') {
|
||||
saveAddClickListener(el, binding.arg, binding.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* TODO(chris): This needs serious rework!!!
|
||||
*/
|
||||
export default {
|
||||
mounted(el, binding, vnode) {
|
||||
if (binding.arg == 'draggable') {
|
||||
el.addEventListener('update-my-value', evt => {
|
||||
evt.preventDefault();
|
||||
binding.value = evt.detail.item;
|
||||
});
|
||||
el.addEventListener('dragstart', evt => {
|
||||
el.dispatchEvent(new CustomEvent('calendar-dragstart', {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
detail: {
|
||||
item: binding.value,
|
||||
x: evt.offsetX / el.offsetWidth,
|
||||
y: evt.offsetY / el.offsetHeight,
|
||||
originalEvent: evt
|
||||
}
|
||||
}));
|
||||
});
|
||||
el.addEventListener('dragend', evt => {
|
||||
el.dispatchEvent(new CustomEvent('calendar-dragend', {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
detail: {
|
||||
item: binding.value,
|
||||
originalEvent: evt
|
||||
}
|
||||
}));
|
||||
});
|
||||
} else if (binding.arg == 'dropcage') {
|
||||
let hitbox = null;
|
||||
el.addEventListener('dragover', evt => {
|
||||
if (hitbox)
|
||||
return;
|
||||
hitbox = el.getBoundingClientRect();
|
||||
return el.dispatchEvent(new CustomEvent('calendar-dragenter', {
|
||||
detail: { originalEvent: evt }
|
||||
}));
|
||||
});
|
||||
window.addEventListener('dragleave', evt => {
|
||||
if (!hitbox)
|
||||
return;
|
||||
let pos;
|
||||
if (typeof evt.clientX === 'undefined')
|
||||
pos = {
|
||||
x: evt.pageX + document.documentElement.scrollLeft,
|
||||
y: evt.pageY + document.documentElement.scrollTop
|
||||
};
|
||||
else
|
||||
pos = {
|
||||
x: evt.clientX + document.body.scrollLeft + document.documentElement.scrollLeft,
|
||||
y: evt.clientY + document.body.scrollTop + document.documentElement.scrollTop
|
||||
};
|
||||
if (pos.x > hitbox.left + hitbox.width - 1 || pos.x < hitbox.left || pos.y > hitbox.top + hitbox.height - 1 || pos.y < hitbox.top) {
|
||||
hitbox = null;
|
||||
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
|
||||
detail: { originalEvent: evt }
|
||||
}));
|
||||
}
|
||||
});
|
||||
window.addEventListener('drop', evt => {
|
||||
if (!hitbox)
|
||||
return;
|
||||
|
||||
hitbox = null;
|
||||
return el.dispatchEvent(new CustomEvent('calendar-dragleave', {
|
||||
detail: { originalEvent: evt }
|
||||
}));
|
||||
});
|
||||
} else if (binding.arg == 'dropzone') {
|
||||
el.addEventListener(
|
||||
binding.modifiers.once ? 'dragenter' : 'dragover',
|
||||
evt => {
|
||||
const timestamp = binding.value instanceof Function
|
||||
? binding.value(evt)
|
||||
: binding.value;
|
||||
const detail = timestamp.timestamp ? timestamp : { timestamp };
|
||||
el.dispatchEvent(new CustomEvent('calendar-dragchange', {
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
detail
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
updated(el, binding, vnode, prevVnode) {
|
||||
if (binding.arg == 'draggable') {
|
||||
el.dispatchEvent(new CustomEvent('update-my-value', {
|
||||
cancelable: true,
|
||||
detail: {
|
||||
item: binding.value
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user