initial
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Elements
|
||||
|
||||
Some repeating components of the UI.
|
||||
@@ -0,0 +1,196 @@
|
||||
const RsvCalendarPicker = (() => {
|
||||
|
||||
function get_first_day_of_month(date) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6
|
||||
}
|
||||
|
||||
function is_same_day(a, b) {
|
||||
return a.getUTCFullYear() === b.getUTCFullYear()
|
||||
&& a.getUTCMonth() === b.getUTCMonth()
|
||||
&& a.getUTCDate() === b.getUTCDate();
|
||||
}
|
||||
|
||||
function is_same_month(a, b) {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth();
|
||||
}
|
||||
|
||||
function clear_class(root, cls) {
|
||||
root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls));
|
||||
}
|
||||
|
||||
function set_cell(cell, date, outside) {
|
||||
cell.classList.toggle('dimm', outside);
|
||||
const iso = date.toISOString();
|
||||
cell.setAttribute('datetime', iso);
|
||||
cell.children[0].id = iso;
|
||||
cell.children[0].setAttribute('datetime', iso);
|
||||
cell.children[1].textContent = date.getUTCDate();
|
||||
cell.children[1].setAttribute('for', iso);
|
||||
}
|
||||
|
||||
function render(state, date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const first = get_first_day_of_month(date);
|
||||
const in_cur = new Date(year, month + 1, 0).getDate();
|
||||
const in_prev = new Date(year, month, 0).getDate();
|
||||
const today = new Date();
|
||||
const rows = state.body.querySelectorAll('tr');
|
||||
|
||||
clear_class(state.body, 'rsv-cal-cell-current');
|
||||
clear_class(state.body, 'rsv-cal-cell-today');
|
||||
|
||||
let idx = 0;
|
||||
for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month - 1, d));
|
||||
const cell = rows[0].children[idx];
|
||||
set_cell(cell, dt, true);
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
for (let i = 1; i <= in_cur; i++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month, i));
|
||||
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
|
||||
set_cell(cell, dt, false);
|
||||
if (is_same_day(dt, date)) cell.querySelector('input').checked = true;
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
for (let i = 1; idx < 42; i++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month + 1, i));
|
||||
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
|
||||
set_cell(cell, dt, true);
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
|
||||
state.month_el.textContent =
|
||||
new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year;
|
||||
}
|
||||
|
||||
function day_names() {
|
||||
// Generate short weekday names starting on Monday using the browser locale.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday
|
||||
.toLocaleDateString(navigator.language, { weekday: 'short' })
|
||||
);
|
||||
}
|
||||
|
||||
const ARROW_L = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/></svg>`;
|
||||
const ARROW_R = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/></svg>`;
|
||||
|
||||
function nav_btn(icon, handler) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = icon;
|
||||
btn.classList.add('rsv-cal-btn-nav');
|
||||
btn.addEventListener('click', handler);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function build_header(state) {
|
||||
const month_el = document.createElement('span');
|
||||
month_el.classList.add('rsv-cal-month');
|
||||
state.month_el = month_el;
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.classList.add('rsv-cal-controls');
|
||||
controls.append(
|
||||
nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))),
|
||||
month_el,
|
||||
nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate())))
|
||||
);
|
||||
|
||||
const ctrl_td = document.createElement('td');
|
||||
ctrl_td.colSpan = 7;
|
||||
ctrl_td.appendChild(controls);
|
||||
|
||||
const ctrl_row = document.createElement('tr');
|
||||
ctrl_row.appendChild(ctrl_td);
|
||||
|
||||
const names_row = document.createElement('tr');
|
||||
day_names().forEach(name => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = name;
|
||||
names_row.appendChild(th);
|
||||
});
|
||||
|
||||
const header = document.createElement('thead');
|
||||
header.classList.add('rsv-cal-header');
|
||||
header.append(ctrl_row, names_row);
|
||||
return header;
|
||||
}
|
||||
|
||||
function build_body(name, on_select) {
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.classList.add('rsv-cal-grid');
|
||||
|
||||
for (let y = 0; y < 6; y++) {
|
||||
const row = document.createElement('tr');
|
||||
for (let x = 0; x < 7; x++) {
|
||||
const radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = name + '.date';
|
||||
radio.hidden = true;
|
||||
radio.addEventListener('change', on_select);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('unselectable', 'true');
|
||||
|
||||
const cell = document.createElement('td');
|
||||
cell.classList.add('rsv-cal-cell');
|
||||
cell.append(radio, label);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
return tbody;
|
||||
}
|
||||
|
||||
return {
|
||||
create(container, name) {
|
||||
const state = {
|
||||
date: null,
|
||||
month_el: null,
|
||||
body: null,
|
||||
container,
|
||||
|
||||
set_date(date) {
|
||||
if (this.date !== null && is_same_day(date, this.date)) return;
|
||||
|
||||
const month_changed = this.date === null || !is_same_month(date, this.date);
|
||||
if (month_changed) {
|
||||
const prev = this.body.querySelector('input[type="radio"]:checked');
|
||||
if (prev) prev.checked = false;
|
||||
render(this, date);
|
||||
const next = this.body.querySelector(`input[id="${date.toISOString()}"]`);
|
||||
if (next) next.checked = true;
|
||||
}
|
||||
|
||||
const first = get_first_day_of_month(date);
|
||||
const cell_idx = first + date.getDate() - 1;
|
||||
const rows = this.body.querySelectorAll('tr');
|
||||
rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true;
|
||||
|
||||
this.date = date;
|
||||
this.container.value = date;
|
||||
this.container.dispatchEvent(
|
||||
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
container.classList.add('rsv-calendar');
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.appendChild(build_header(state));
|
||||
table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime')))));
|
||||
|
||||
state.body = table.querySelector('tbody');
|
||||
container.appendChild(table);
|
||||
state.set_date(new Date());
|
||||
return state;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* RSV Dynamic datagrid
|
||||
* Allows fetching with JS instead of page reload.
|
||||
*/
|
||||
window.RsvDataGrid = window.RsvDataGrid || {
|
||||
create_header(self, columns, has_actions) {
|
||||
let thead = document.createElement('thead');
|
||||
|
||||
thead.replaceChildren(...Object.entries(columns).map(([key, value]) => {
|
||||
let th = document.createElement('th');
|
||||
|
||||
if (value.width) {
|
||||
th.style.width = value.width + 'px';
|
||||
}
|
||||
|
||||
th.classList.add('manage-columns', 'column-' + key);
|
||||
if (value.is_sortable) {
|
||||
th.classList.add('sortable', key);
|
||||
let button = document.createElement('a');
|
||||
button.onclick = () => {
|
||||
self.sort_by(key);
|
||||
};
|
||||
button.innerHTML =
|
||||
`<span>${value.label}</span>
|
||||
<span class="sorting-indicators">
|
||||
<span class="sorting-indicator asc" aria-hidden="true"></span>
|
||||
<span class="sorting-indicator desc" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="screen-reader-text">Sort ascending.</span>
|
||||
`;
|
||||
|
||||
th.appendChild(button);
|
||||
} else {
|
||||
th.innerText = value.label;
|
||||
}
|
||||
return th;
|
||||
}));
|
||||
|
||||
if (has_actions) {
|
||||
let th = document.createElement('th');
|
||||
th.innerText = 'Actions';
|
||||
thead.appendChild(th);
|
||||
}
|
||||
|
||||
return thead;
|
||||
},
|
||||
|
||||
create_footer(self, columns, has_actions) {
|
||||
let tfoot = document.createElement('tfoot');
|
||||
|
||||
let trow = document.createElement('tr');
|
||||
|
||||
trow.replaceChildren(...Object.entries(columns).map(([key, value]) => {
|
||||
let th = document.createElement('th');
|
||||
|
||||
if (value.width) {
|
||||
th.style.width = value.width + 'px';
|
||||
}
|
||||
|
||||
th.classList.add('manage-columns', 'column-' + key);
|
||||
if (value.is_sortable) {
|
||||
th.classList.add('sortable', key);
|
||||
let button = document.createElement('a');
|
||||
button.onclick = () => {
|
||||
self.sort_by(key);
|
||||
};
|
||||
button.innerHTML =
|
||||
`<span>${value.label}</span>
|
||||
<span class="sorting-indicators">
|
||||
<span class="sorting-indicator asc" aria-hidden="true"></span>
|
||||
<span class="sorting-indicator desc" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="screen-reader-text">Sort ascending.</span>
|
||||
`;
|
||||
|
||||
th.appendChild(button);
|
||||
} else {
|
||||
th.innerText = value.label;
|
||||
}
|
||||
return th;
|
||||
}));
|
||||
|
||||
tfoot.appendChild(trow);
|
||||
|
||||
return tfoot;
|
||||
},
|
||||
|
||||
create_dg_row(self, data, index = 0) {
|
||||
let row = document.createElement('tr');
|
||||
row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry');
|
||||
|
||||
row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => {
|
||||
let td = document.createElement('td');
|
||||
if (self.mappings[key] != null) {
|
||||
td = self.mappings[key](self, row, data);
|
||||
} else {
|
||||
td.innerText = data[key];
|
||||
}
|
||||
|
||||
if(value.actions != null && Object.entries(value.actions).length > 0) {
|
||||
const visible_actions = Object.entries(value.actions).filter(([, action]) =>
|
||||
action.condition == null || action.condition(data, index)
|
||||
);
|
||||
if (visible_actions.length === 0) return td;
|
||||
|
||||
row.classList.add('has-row-actions')
|
||||
const action_cell = document.createElement('div');
|
||||
action_cell.classList.add('row-actions', 'visible');
|
||||
|
||||
const action_spans = visible_actions.map(([key, value]) => {
|
||||
if (value.is_link) {
|
||||
let span = document.createElement('span');
|
||||
let a = document.createElement('a');
|
||||
a.innerText = key;
|
||||
a.href = value.func(data);
|
||||
span.appendChild(a);
|
||||
return span;
|
||||
} else {
|
||||
let span = document.createElement('span');
|
||||
let button = document.createElement('a');
|
||||
button.onclick = function () { value.func(self, row, data) };
|
||||
button.innerText = key;
|
||||
span.appendChild(button);
|
||||
return span;
|
||||
}
|
||||
});
|
||||
const action_nodes = action_spans.flatMap((span, i) =>
|
||||
i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span]
|
||||
);
|
||||
action_cell.replaceChildren(...action_nodes);
|
||||
|
||||
td.appendChild(action_cell);
|
||||
}
|
||||
|
||||
return td;
|
||||
}));
|
||||
|
||||
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
|
||||
async render_data_grid(self) {
|
||||
const rows = self.fetch_resource()
|
||||
.then(x => { self.set_total(x.total); return x; })
|
||||
.then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i)))
|
||||
.then(x => self.body.replaceChildren(...x))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
return []; // empty the rows
|
||||
});
|
||||
},
|
||||
|
||||
link_action(func, condition = null) {
|
||||
return { is_link: true, func, condition };
|
||||
},
|
||||
|
||||
func_action(func, condition = null) {
|
||||
return { is_link: false, func, condition };
|
||||
},
|
||||
|
||||
edit_action(func, condition = null) {
|
||||
return { is_link: false, func, condition };
|
||||
},
|
||||
|
||||
action_column(label, is_sortable, actions) {
|
||||
return {
|
||||
label: label,
|
||||
is_sortable: is_sortable,
|
||||
actions: actions,
|
||||
};
|
||||
},
|
||||
|
||||
column(label, is_sortable = false, width = 0) {
|
||||
return {
|
||||
label: label,
|
||||
is_sortable: is_sortable,
|
||||
width: width
|
||||
};
|
||||
},
|
||||
|
||||
create_paging_button(text) {
|
||||
let button = document.createElement('a');
|
||||
button.classList.add('button');
|
||||
|
||||
let label = document.createElement('span');
|
||||
label.innerText = text;
|
||||
|
||||
button.appendChild(label);
|
||||
|
||||
return button;
|
||||
},
|
||||
|
||||
create_last_page_btn() {
|
||||
let btn = this.create_paging_button("»");
|
||||
btn.classList.add('last-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_next_page_btn() {
|
||||
let btn = this.create_paging_button("›");
|
||||
btn.classList.add('next-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_first_page_btn() {
|
||||
let btn = this.create_paging_button("«");
|
||||
btn.classList.add('first-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_prev_page_btn() {
|
||||
let btn = this.create_paging_button("‹");
|
||||
btn.classList.add('prev-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_paging_text() {
|
||||
let text = document.createElement('span');
|
||||
text.classList.add('paging-input');
|
||||
|
||||
let paging_text = document.createElement('span');
|
||||
paging_text.classList.add('tablenav-paging-text');
|
||||
|
||||
text.appendChild(paging_text);
|
||||
|
||||
const result = {
|
||||
container: text,
|
||||
paging_text: paging_text,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
create_paging_controls(self) {
|
||||
let nav = document.createElement('div');
|
||||
nav.classList.add('tablenav-pages');
|
||||
|
||||
let displaying_num = document.createElement('span');
|
||||
displaying_num.classList.add('displaying-num');
|
||||
|
||||
nav.appendChild(displaying_num);
|
||||
|
||||
let pagination_links = document.createElement('span');
|
||||
pagination_links.classList.add('pagination-links');
|
||||
|
||||
let first_page_btn = this.create_first_page_btn();
|
||||
first_page_btn.onclick = () => self.goto_first_page();
|
||||
let prev_page_btn = this.create_prev_page_btn();
|
||||
prev_page_btn.onclick = () => self.move_page(-1);
|
||||
let next_page_btn = this.create_next_page_btn();
|
||||
next_page_btn.onclick = () => self.move_page(1);
|
||||
let last_page_btn = this.create_last_page_btn();
|
||||
last_page_btn.onclick = () => self.goto_last_page();
|
||||
|
||||
pagination_links.appendChild(first_page_btn);
|
||||
pagination_links.appendChild(prev_page_btn);
|
||||
|
||||
let paging_text = this.create_paging_text();
|
||||
|
||||
pagination_links.appendChild(paging_text.container);
|
||||
|
||||
pagination_links.appendChild(next_page_btn);
|
||||
pagination_links.appendChild(last_page_btn);
|
||||
|
||||
// pagination_links.innerHTML = `
|
||||
// <span class="pagination-links">
|
||||
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">«</span>
|
||||
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">‹</span>
|
||||
// <span class="screen-reader-text">Current Page</span>
|
||||
// <span id="table-paging" class="paging-input">
|
||||
// <span class="tablenav-paging-text">1 of <span class="total-pages">2</span></span>
|
||||
// </span>
|
||||
// <a class="next-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&paged=2">
|
||||
// <span class="screen-reader-text">Next page</span>
|
||||
// <span aria-hidden="true">›</span>
|
||||
// </a>
|
||||
// <a class="last-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&paged=2">
|
||||
// <span class="screen-reader-text">Last page</span>
|
||||
// <span aria-hidden="true">»</span>
|
||||
// </a>
|
||||
// </span>`;
|
||||
|
||||
nav.appendChild(pagination_links);
|
||||
|
||||
const paging_controls = {
|
||||
container: nav,
|
||||
paging_text: paging_text.paging_text,
|
||||
display_num: displaying_num,
|
||||
};
|
||||
|
||||
return paging_controls;
|
||||
},
|
||||
|
||||
create_data_grid(container, resource, columns, actions) {
|
||||
let tbody = document.createElement('tbody');
|
||||
|
||||
let state = {
|
||||
columns: columns,
|
||||
resource: resource,
|
||||
actions: actions,
|
||||
body: tbody,
|
||||
mappings: {},
|
||||
params: {},
|
||||
total: 0,
|
||||
page: 0,
|
||||
page_size: 20,
|
||||
container: container,
|
||||
order_by: null,
|
||||
order: 0,
|
||||
fetch_resource() {
|
||||
const params = { ...this.params };
|
||||
if (this.order_by) {
|
||||
params.orderby = this.order_by;
|
||||
params.order = this.order === 0 ? 'desc' : 'asc';
|
||||
}
|
||||
return this.resource.get_page(this.page * this.page_size, this.page_size, params);
|
||||
},
|
||||
refresh() {
|
||||
RsvDataGrid.render_data_grid(this)
|
||||
},
|
||||
refresh_row(row, data) {
|
||||
let row2 = RsvDataGrid.create_dg_row(this, data);
|
||||
row.replaceWith(row2);
|
||||
},
|
||||
map_column(key, func) {
|
||||
this.mappings[key] = func;
|
||||
return this;
|
||||
},
|
||||
add_action(key, func) {
|
||||
this.actions[key] = func;
|
||||
return this;
|
||||
},
|
||||
set_param(key, value) {
|
||||
this.params[key] = value;
|
||||
},
|
||||
remove_param(key, do_refresh = true) {
|
||||
delete this.params[key];
|
||||
if (do_refresh) {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
sort_by(key, dir = null, do_refresh = true) {
|
||||
let ths = this.container.getElementsByClassName("column-" + key);
|
||||
if (ths.length > 0) {
|
||||
this.order = dir ?? (this.order_by === key ? 1 - this.order : 0);
|
||||
this.order_by = key;
|
||||
|
||||
let th = ths[0];
|
||||
if (this.order === 0) {
|
||||
th.classList.remove('asc');
|
||||
th.classList.add('desc');
|
||||
} else {
|
||||
th.classList.remove('desc');
|
||||
th.classList.add('asc');
|
||||
}
|
||||
|
||||
// if (th.classList.contains('asc')) {
|
||||
// th.classList.replace('asc', 'desc');
|
||||
// this.set_param('order', 'desc');
|
||||
// } else {
|
||||
// th.classList.remove('desc');
|
||||
// th.classList.add('asc');
|
||||
// this.set_param('order', 'asc');
|
||||
// }
|
||||
let sorted = th.parentElement.getElementsByClassName('sorted');
|
||||
if (sorted.length > 0)
|
||||
sorted[0].classList.replace('sorted', 'sortable');
|
||||
th.classList.replace('sortable', 'sorted');
|
||||
|
||||
if (do_refresh) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
set_total(count) {
|
||||
this.total = count;
|
||||
this.paging_controls.display_num.innerHTML = `${this.total} položek`;
|
||||
this.paging_controls.paging_text.innerHTML = `${this.page + 1} of <span class="total-pages">${Math.ceil(count / this.page_size)}</span>`;
|
||||
},
|
||||
get_total() {
|
||||
return this.total;
|
||||
},
|
||||
move_page(relative) {
|
||||
this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1));
|
||||
this.refresh();
|
||||
},
|
||||
goto_first_page() {
|
||||
this.move_page(-this.page);
|
||||
},
|
||||
goto_last_page() {
|
||||
this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1);
|
||||
},
|
||||
};
|
||||
|
||||
let paging_controls = this.create_paging_controls(state);
|
||||
|
||||
let footer = document.createElement('div');
|
||||
footer.classList.add('tablenav', 'bottom');
|
||||
footer.appendChild(paging_controls.container);
|
||||
|
||||
state.paging_controls = paging_controls;
|
||||
|
||||
let table = document.createElement('table');
|
||||
table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list');
|
||||
// const has_actions = Object.entries(actions).length > 0;
|
||||
table.appendChild(this.create_header(state, columns, false));
|
||||
|
||||
table.appendChild(tbody);
|
||||
|
||||
table.appendChild(this.create_footer(state, columns, false));
|
||||
|
||||
container.appendChild(table);
|
||||
|
||||
container.appendChild(footer);
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
class RsvReservationSelector extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['timetable-id', 'name', 'price-per-block'];
|
||||
}
|
||||
|
||||
// ---- Attribute accessors ------------------------------------------------
|
||||
|
||||
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
|
||||
get inputName() { return this.getAttribute('name') ?? 'reservation'; }
|
||||
get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; }
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._slots = [];
|
||||
this.classList.add('rsv-timetable-selector');
|
||||
this._build();
|
||||
}
|
||||
|
||||
attributeChangedCallback(_attr, oldVal, newVal) {
|
||||
if (oldVal === null || oldVal === newVal || !this.isConnected) return;
|
||||
this._build();
|
||||
}
|
||||
|
||||
// ---- Public API ---------------------------------------------------------
|
||||
|
||||
getValue() {
|
||||
return {
|
||||
timetable_id: this.timetableId,
|
||||
timetable_reservations: this._slots.map(s => s.start_utc),
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
this._slots = [];
|
||||
this._commit();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
this._slots = [];
|
||||
this.replaceChildren();
|
||||
|
||||
const tid = document.createElement('input');
|
||||
tid.type = 'hidden';
|
||||
tid.name = `${this.inputName}.timetable_id`;
|
||||
tid.value = this.timetableId;
|
||||
this.appendChild(tid);
|
||||
|
||||
const cal_el = document.createElement('div');
|
||||
cal_el.classList.add('rsv-calendar');
|
||||
|
||||
// Create rsv-timeline with timetable-id set before appending so
|
||||
// connectedCallback sees the correct attribute on first render.
|
||||
const time_el = document.createElement('rsv-timeline');
|
||||
time_el.setAttribute('timetable-id', this.timetableId);
|
||||
|
||||
this.append(cal_el, time_el);
|
||||
|
||||
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
|
||||
|
||||
// Date change: clear selection, then push new date to the timeline element.
|
||||
cal_el.addEventListener('change', () => {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
this._slots = [];
|
||||
this._commit();
|
||||
time_el.date = this._calendar.date;
|
||||
});
|
||||
|
||||
// Slot toggle: read selected slots from timeline, then commit.
|
||||
time_el.addEventListener('input', e => {
|
||||
e.stopPropagation();
|
||||
this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({
|
||||
start_utc: s.dataset.start_utc,
|
||||
end_utc: s.dataset.end_utc,
|
||||
}));
|
||||
this._commit();
|
||||
});
|
||||
|
||||
this._commit();
|
||||
}
|
||||
|
||||
_commit() {
|
||||
const name = this.inputName;
|
||||
|
||||
this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove());
|
||||
|
||||
let json = [];
|
||||
this._slots.forEach(slot => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = `${name}.timetable_reservations[]`;
|
||||
inp.value = slot.start_utc;
|
||||
this.appendChild(inp);
|
||||
json.push(slot.start_utc);
|
||||
});
|
||||
|
||||
this.value = JSON.stringify({
|
||||
"timetable_id": this.timetableId,
|
||||
"timetable_reservations": json
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent('rsv:slots-changed', {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
name,
|
||||
slots: this._slots,
|
||||
price_per_block: this.pricePerBlock,
|
||||
value: this.getValue(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-reservation-selector', RsvReservationSelector);
|
||||
@@ -0,0 +1,100 @@
|
||||
class RsvReservationSummary extends HTMLElement {
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._all_slots = new Map(); // name → { slots, price_per_block }
|
||||
this._form = this.closest('form');
|
||||
this._build();
|
||||
|
||||
if (this._form) {
|
||||
this._handler = e => {
|
||||
this._all_slots.set(e.detail.name, {
|
||||
slots: e.detail.slots,
|
||||
price_per_block: e.detail.price_per_block,
|
||||
});
|
||||
this._render();
|
||||
};
|
||||
this._form.addEventListener('rsv:slots-changed', this._handler);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._form && this._handler) {
|
||||
this._form.removeEventListener('rsv:slots-changed', this._handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
const s = ReservairStrings.summary;
|
||||
this.innerHTML = `
|
||||
<div class="rsv-summary-header">
|
||||
<span class="rsv-summary-title">${s.title}</span>
|
||||
<button type="button" class="rsv-summary-clear">${s.clear_all}</button>
|
||||
</div>
|
||||
<ul class="rsv-summary-list"></ul>
|
||||
<div class="rsv-summary-footer">
|
||||
<span class="rsv-summary-count"></span>
|
||||
<div class="rsv-summary-price"></div>
|
||||
</div>
|
||||
`;
|
||||
this.hidden = true;
|
||||
|
||||
this.querySelector('.rsv-summary-clear').addEventListener('click', () => {
|
||||
this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear());
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
||||
slots.map(s => ({ ...s, price_per_block }))
|
||||
);
|
||||
console.log(all_slots);
|
||||
|
||||
const n = all_slots.length;
|
||||
const list = this.querySelector('.rsv-summary-list');
|
||||
const count_el = this.querySelector('.rsv-summary-count');
|
||||
const price_el = this.querySelector('.rsv-summary-price');
|
||||
const s = ReservairStrings.summary;
|
||||
const locale = navigator.language;
|
||||
|
||||
this.hidden = n === 0;
|
||||
if (n === 0) {
|
||||
list.replaceChildren();
|
||||
count_el.textContent = '';
|
||||
price_el.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const time_opts = { hour: '2-digit', minute: '2-digit' };
|
||||
list.replaceChildren(...all_slots.map(slot => {
|
||||
const start = new Date(slot.start_utc);
|
||||
const end = new Date(slot.end_utc);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'rsv-summary-item';
|
||||
li.innerHTML = `
|
||||
<div class="rsv-summary-item-info">
|
||||
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
|
||||
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)}</span>
|
||||
</div>
|
||||
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
|
||||
`;
|
||||
return li;
|
||||
}));
|
||||
|
||||
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
|
||||
count_el.textContent = this._fmt_count(n);
|
||||
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
|
||||
}
|
||||
|
||||
_fmt_count(n) {
|
||||
const s = ReservairStrings.summary;
|
||||
if (n === 1) return s.count_one;
|
||||
if (n < 5) return s.count_few.replace('%d', n);
|
||||
return s.count_many.replace('%d', n);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-reservation-summary', RsvReservationSummary);
|
||||
@@ -0,0 +1,141 @@
|
||||
class RsvTimeline extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['timetable-id', 'date'];
|
||||
}
|
||||
|
||||
// ---- Attribute accessors ------------------------------------------------
|
||||
|
||||
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
|
||||
|
||||
get date() {
|
||||
const attr = this.getAttribute('date');
|
||||
// Parse as local midnight so setHours() in block rendering stays in local time.
|
||||
return attr ? new Date(attr + 'T12:00:00') : new Date();
|
||||
}
|
||||
|
||||
set date(value) {
|
||||
const d = new Date(value);
|
||||
const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.setAttribute('date', str);
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._version = 0;
|
||||
this.classList.add('rsv-slots-list');
|
||||
this.addEventListener('click', this._on_click.bind(this));
|
||||
this._render();
|
||||
}
|
||||
|
||||
attributeChangedCallback(_attr, oldVal, newVal) {
|
||||
if (oldVal === newVal || !this.isConnected) return;
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_on_click(event) {
|
||||
const slot = event.target.closest('.rsv-slots-slot');
|
||||
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
|
||||
slot.classList.toggle('rsv-slots-slot-selected');
|
||||
slot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async _render() {
|
||||
// Version guard: discard renders that were superseded by a newer call.
|
||||
const v = ++this._version;
|
||||
const s = ReservairStrings.timeline;
|
||||
|
||||
if (this.timetableId === null) {
|
||||
this.replaceChildren(this._notice(s.not_reservable));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
||||
if (v !== this._version) return;
|
||||
|
||||
if(occupancy.length === 0) {
|
||||
this.replaceChildren(this._notice(s.no_blocks));
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('rsv-slots-label');
|
||||
header.textContent = this.date.toLocaleDateString(navigator.language, {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
}).replace(',', '');
|
||||
|
||||
const blocks = [];
|
||||
|
||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||
if (from_minutes === to_minutes || block_occ.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
||||
|
||||
const time_slots = block_occ.map((occ, i) =>
|
||||
this._block(this.date, occ, block_size_in_minutes, from_block + i)
|
||||
);
|
||||
|
||||
const time_slot_group = document.createElement('div');
|
||||
time_slot_group.classList.add('rsv-slots-group');
|
||||
time_slot_group.replaceChildren(...time_slots);
|
||||
blocks.push(time_slot_group);
|
||||
}
|
||||
|
||||
this.replaceChildren(header, ...blocks);
|
||||
} catch (_e) {
|
||||
if (v !== this._version) return;
|
||||
this.replaceChildren(this._notice(s.no_blocks));
|
||||
}
|
||||
}
|
||||
|
||||
_block(date, left, block_size, idx) {
|
||||
const from = new Date(date);
|
||||
from.setHours(0, idx * block_size, 0, 0);
|
||||
|
||||
const to = new Date(from);
|
||||
to.setMinutes(to.getMinutes() + block_size);
|
||||
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
|
||||
cell.dataset.start_utc = from.toISOString();
|
||||
cell.dataset.end_utc = to.toISOString();
|
||||
if (left === 0) cell.classList.add('rsv-slots-slot-full');
|
||||
|
||||
const time_el = document.createElement('span');
|
||||
time_el.classList.add('rsv-slots-slot-time');
|
||||
time_el.textContent = `${this._fmt(from)} – ${this._fmt(to)}`;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.classList.add('rsv-slots-slot-badge');
|
||||
const remaining_seats = left;
|
||||
|
||||
if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available');
|
||||
|
||||
if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`;
|
||||
else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`;
|
||||
else badge.textContent = `${remaining_seats} míst`;
|
||||
|
||||
|
||||
cell.append(time_el, badge);
|
||||
return cell;
|
||||
}
|
||||
|
||||
_notice(text) {
|
||||
const p = document.createElement('p');
|
||||
p.classList.add('rsv-slots-notice');
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
_fmt(dt) {
|
||||
return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-timeline', RsvTimeline);
|
||||
Reference in New Issue
Block a user