348 lines
14 KiB
HTML
348 lines
14 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ t(key="calendar-title", lang=lang) }}{% endblock title %}
|
||
|
||
{% block head %}
|
||
<style>
|
||
/* Sharpen the calendar grid. The daisyUI base tones give very low-contrast
|
||
hairline borders and dimmed text in both themes. Tying the borders and
|
||
the header tint to base-content keeps the table legible and easy to
|
||
follow on light and dark alike (public + admin calendars). */
|
||
#cal th,
|
||
#cal td {
|
||
border-color: hsl(var(--bc) / 0.25);
|
||
}
|
||
#cal thead tr,
|
||
#cal tbody td:first-child {
|
||
background-color: hsl(var(--bc) / 0.1);
|
||
}
|
||
#cal tbody td:first-child { opacity: 1; }
|
||
#cal thead .opacity-50 { opacity: 0.8; }
|
||
#cal td .opacity-30 { opacity: 0.7; }
|
||
#cal td a.opacity-30:hover { opacity: 1; }
|
||
|
||
/* Mobile: show a small window of days (2 on a narrow phone, 3 otherwise)
|
||
instead of the full week. JS marks the visible columns with .cal-day-on
|
||
and the day navigator slides the window; before JS runs, the first three
|
||
days stand in. Wider screens keep the full 7-day grid. */
|
||
@media (max-width: 767px) {
|
||
#cal thead { display: none; }
|
||
#cal .cal-day { display: none; }
|
||
#cal .cal-day-0,
|
||
#cal .cal-day-1,
|
||
#cal .cal-day-2 { display: table-cell; }
|
||
#cal.cal-js .cal-day { display: none; }
|
||
#cal.cal-js .cal-day.cal-day-on { display: table-cell; }
|
||
/* Slim the hour column to its compact label so bookings get the width. */
|
||
#cal tbody td:first-child {
|
||
width: 1%;
|
||
white-space: nowrap;
|
||
padding: 0.3rem 0.4rem;
|
||
font-size: 0.7rem;
|
||
line-height: 1.15;
|
||
}
|
||
}
|
||
</style>
|
||
{% if is_admin %}
|
||
<style>
|
||
/* Admin calendar — strict, content-independent column widths. A long
|
||
booking can never widen its column; it grows taller instead. (The public
|
||
calendar has merged free rows with a colspan, so it keeps auto layout.) */
|
||
#cal { table-layout: fixed; }
|
||
@media (max-width: 767px) {
|
||
#cal tbody td:first-child { width: 3.25rem; }
|
||
}
|
||
/* Detailed-view toggle. */
|
||
.cal-detail { display: none; }
|
||
#cal.cal--detailed .cal-chip {
|
||
position: static;
|
||
inset: auto;
|
||
margin: 0.25rem;
|
||
min-height: 3rem;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
justify-content: center;
|
||
}
|
||
#cal.cal--detailed .cal-name { font-weight: 600; }
|
||
#cal.cal--detailed .cal-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
margin-top: 2px;
|
||
font-weight: 400;
|
||
line-height: 1.25;
|
||
}
|
||
/* Detailed fields wrap to as many lines as needed — the full booking info
|
||
shows and the cell just grows taller, never wider. */
|
||
#cal.cal--detailed .cal-name,
|
||
#cal.cal--detailed .cal-detail > span {
|
||
white-space: normal;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
/* A long note is capped with an ellipsis so one booking can't run away. */
|
||
#cal.cal--detailed .cal-note {
|
||
display: -webkit-box;
|
||
-webkit-box-orient: vertical;
|
||
-webkit-line-clamp: 6;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|
||
{% endif %}
|
||
{% endblock head %}
|
||
|
||
{% block content %}
|
||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||
<h1 class="text-2xl font-bold">
|
||
{% if is_admin %}{{ t(key="admin-title", lang=lang) }}{% else %}{{ t(key="calendar-title", lang=lang) }}{% endif %}
|
||
</h1>
|
||
{% if has_courts %}
|
||
<form method="get" action="{{ base_path }}" class="flex items-center gap-2">
|
||
<label class="text-sm font-medium opacity-70">{{ t(key="court-label", lang=lang) }}</label>
|
||
<input type="hidden" name="week" value="{{ week }}">
|
||
<select name="court" onchange="this.form.submit()" class="select select-bordered select-sm">
|
||
{% for c in courts %}
|
||
<option value="{{ c.id }}" {% if c.selected %}selected{% endif %}>{{ c.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if has_courts %}
|
||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<div class="join">
|
||
{% if can_prev %}
|
||
<a href="{{ base_path }}?court={{ court_id }}&week={{ prev_week }}"
|
||
class="btn btn-sm join-item">«<span class="ml-1 hidden sm:inline">{{ t(key="prev-week", lang=lang) }}</span></a>
|
||
{% else %}
|
||
<span class="btn btn-sm join-item btn-disabled" aria-disabled="true">«<span
|
||
class="ml-1 hidden sm:inline">{{ t(key="prev-week", lang=lang) }}</span></span>
|
||
{% endif %}
|
||
<a href="{{ base_path }}?court={{ court_id }}&week={{ this_week }}"
|
||
class="btn btn-sm join-item">{{ t(key="this-week", lang=lang) }}</a>
|
||
<a href="{{ base_path }}?court={{ court_id }}&week={{ next_week }}"
|
||
class="btn btn-sm join-item"><span class="mr-1 hidden sm:inline">{{ t(key="next-week", lang=lang) }}</span>»</a>
|
||
</div>
|
||
{% if is_admin %}
|
||
<button type="button" id="cal-detail-toggle" class="btn btn-sm gap-1" aria-pressed="false"
|
||
title="{{ t(key='view-details', lang=lang) }}">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||
stroke="currentColor" class="h-4 w-4">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||
</svg>
|
||
<span class="hidden sm:inline">{{ t(key="view-details", lang=lang) }}</span>
|
||
</button>
|
||
<form method="get" action="{{ base_path }}" class="relative inline-flex">
|
||
<input type="hidden" name="court" value="{{ court_id }}">
|
||
<button type="button" id="cal-jump-btn" class="btn btn-sm gap-1"
|
||
title="{{ t(key='pick-week', lang=lang) }}">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||
stroke="currentColor" class="h-4 w-4">
|
||
<path stroke-linecap="round" stroke-linejoin="round"
|
||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />
|
||
</svg>
|
||
<span class="hidden sm:inline">{{ t(key="pick-week", lang=lang) }}</span>
|
||
</button>
|
||
<input type="date" name="week" id="cal-jump" value="{{ week }}"
|
||
onchange="this.form.submit()" tabindex="-1" aria-hidden="true"
|
||
class="pointer-events-none absolute inset-0 -z-10 opacity-0">
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
<div class="text-sm font-medium opacity-60">{{ court_name }} · {{ week_label }}</div>
|
||
</div>
|
||
|
||
<div id="cal-daynav" class="mb-3 flex items-center gap-2 md:hidden">
|
||
<button type="button" id="cal-day-prev" class="btn btn-square"
|
||
aria-label="{{ t(key='prev-day', lang=lang) }}">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||
stroke="currentColor" class="h-5 w-5">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
|
||
</svg>
|
||
</button>
|
||
<div id="cal-daynav-label" class="flex-1 text-center text-base font-semibold"></div>
|
||
<button type="button" id="cal-day-next" class="btn btn-square"
|
||
aria-label="{{ t(key='next-day', lang=lang) }}">
|
||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2"
|
||
stroke="currentColor" class="h-5 w-5">
|
||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto border border-base-300 bg-base-100 shadow-sm">
|
||
<table id="cal" data-day="{{ mobile_day }}" class="w-full border-collapse text-sm">
|
||
<thead>
|
||
<tr class="bg-base-200">
|
||
<th class="w-16 border border-base-300 p-2"></th>
|
||
{% for d in days %}
|
||
<th class="cal-day cal-day-{{ loop.index0 }} border border-base-300 p-2 text-center"
|
||
data-d="{{ t(key=d.key, lang=lang) }} {{ d.num }}">
|
||
<div class="font-semibold">{{ t(key=d.key, lang=lang) }}</div>
|
||
<div class="text-xs font-normal opacity-50">{{ d.num }}</div>
|
||
</th>
|
||
{% endfor %}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for row in rows %}
|
||
{% if row.free_group %}
|
||
<tr>
|
||
<td class="border border-base-300 bg-base-200 px-2 py-1 text-center text-xs font-medium">{{ row.hour_label }}</td>
|
||
<td colspan="7" class="border border-base-300 px-2 py-1">
|
||
<div class="text-center text-xs uppercase tracking-wide opacity-30">{{ t(key="free", lang=lang) }}</div>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr>
|
||
<td class="border border-base-300 bg-base-200 p-2 text-center font-medium opacity-70">{{ row.hour_label }}</td>
|
||
{% for cell in row.cells %}
|
||
<td class="cal-day cal-day-{{ loop.index0 }} relative h-14 border border-base-300">
|
||
{% if cell.booked %}
|
||
{% if is_admin %}
|
||
<a href="/admin/booking/{{ cell.booking_id }}"
|
||
class="cal-chip absolute inset-1 flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-white shadow-sm transition hover:opacity-90"
|
||
style="background-color: {{ cell.color }}">
|
||
<span class="cal-name min-w-0 truncate">{{ cell.name }}</span>
|
||
{% if cell.title or cell.contact or cell.note %}
|
||
<span class="cal-detail">
|
||
{%- if cell.title %}<span class="truncate opacity-95">{{ cell.title }}</span>{% endif -%}
|
||
{%- if cell.contact %}<span class="truncate opacity-80">{{ cell.contact }}</span>{% endif -%}
|
||
{%- if cell.note %}<span class="cal-note opacity-80">{{ cell.note }}</span>{% endif -%}
|
||
</span>
|
||
{% endif %}
|
||
</a>
|
||
{% else %}
|
||
<div class="absolute inset-1 flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-white shadow-sm"
|
||
style="background-color: {{ cell.color }}"{% if cell.title %} title="{{ cell.title }}"{% endif %}>
|
||
<span class="min-w-0 truncate">
|
||
{%- if cell.title %}{{ cell.title }}{% else %}{{ t(key="booked", lang=lang) }}{% endif -%}
|
||
</span>
|
||
</div>
|
||
{% endif %}
|
||
{% else %}
|
||
{% if is_admin %}
|
||
<a href="/admin/booking?court={{ court_id }}&date={{ cell.date }}&hour={{ cell.hour }}"
|
||
class="absolute inset-0 flex items-center justify-center text-lg opacity-30 transition hover:bg-base-200 hover:opacity-100">+</a>
|
||
{% else %}
|
||
<div class="absolute inset-0 flex items-center justify-center text-xs opacity-30">{{ t(key="free", lang=lang) }}</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
</td>
|
||
{% endfor %}
|
||
</tr>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{% else %}
|
||
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||
<div class="card-body items-center text-center opacity-60">{{ t(key="no-courts", lang=lang) }}</div>
|
||
</div>
|
||
{% endif %}
|
||
{% endblock content %}
|
||
|
||
{% block js %}
|
||
<script>
|
||
// Mobile: show a sliding window of days (2 on a narrow phone, 3 otherwise)
|
||
// rather than the whole week. The navigator buttons and a horizontal swipe
|
||
// slide the window; a resize re-fits it. Public and admin calendars alike.
|
||
(function () {
|
||
var cal = document.getElementById('cal');
|
||
var nav = document.getElementById('cal-daynav');
|
||
if (!cal || !nav) return;
|
||
var label = document.getElementById('cal-daynav-label');
|
||
var prevBtn = document.getElementById('cal-day-prev');
|
||
var nextBtn = document.getElementById('cal-day-next');
|
||
var dayLabels = [].slice.call(cal.querySelectorAll('thead th[data-d]'))
|
||
.map(function (th) { return th.getAttribute('data-d'); });
|
||
var start = parseInt(cal.getAttribute('data-day'), 10) || 0;
|
||
cal.classList.add('cal-js');
|
||
|
||
function windowSize() {
|
||
return window.innerWidth < 480 ? 2 : 3;
|
||
}
|
||
function apply() {
|
||
var size = windowSize();
|
||
var maxStart = 7 - size;
|
||
if (start > maxStart) start = maxStart;
|
||
if (start < 0) start = 0;
|
||
for (var d = 0; d < 7; d++) {
|
||
var on = d >= start && d < start + size;
|
||
var cells = cal.querySelectorAll('.cal-day-' + d);
|
||
for (var i = 0; i < cells.length; i++) {
|
||
cells[i].classList.toggle('cal-day-on', on);
|
||
}
|
||
}
|
||
var last = start + size - 1;
|
||
label.textContent = (dayLabels[start] || '') + ' – ' + (dayLabels[last] || '');
|
||
prevBtn.disabled = start <= 0;
|
||
nextBtn.disabled = start >= maxStart;
|
||
}
|
||
function slide(delta) { start += delta; apply(); }
|
||
|
||
prevBtn.addEventListener('click', function () { slide(-1); });
|
||
nextBtn.addEventListener('click', function () { slide(1); });
|
||
window.addEventListener('resize', apply);
|
||
|
||
var x0 = null, y0 = null;
|
||
cal.addEventListener('touchstart', function (e) {
|
||
x0 = e.touches[0].clientX;
|
||
y0 = e.touches[0].clientY;
|
||
}, { passive: true });
|
||
cal.addEventListener('touchend', function (e) {
|
||
if (x0 === null) return;
|
||
var dx = e.changedTouches[0].clientX - x0;
|
||
var dy = e.changedTouches[0].clientY - y0;
|
||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) slide(dx < 0 ? 1 : -1);
|
||
x0 = y0 = null;
|
||
}, { passive: true });
|
||
|
||
apply();
|
||
})();
|
||
</script>
|
||
{% if is_admin %}
|
||
<script>
|
||
// Detailed-view toggle for the admin calendar. The choice is remembered
|
||
// per browser; the public calendar never loads this script.
|
||
(function () {
|
||
var KEY = 'cal_detailed';
|
||
var tbl = document.getElementById('cal');
|
||
var btn = document.getElementById('cal-detail-toggle');
|
||
if (!tbl || !btn) return;
|
||
function apply(on) {
|
||
tbl.classList.toggle('cal--detailed', on);
|
||
btn.classList.toggle('btn-active', on);
|
||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||
}
|
||
apply(localStorage.getItem(KEY) === '1');
|
||
btn.addEventListener('click', function () {
|
||
var on = !tbl.classList.contains('cal--detailed');
|
||
localStorage.setItem(KEY, on ? '1' : '0');
|
||
apply(on);
|
||
});
|
||
})();
|
||
</script>
|
||
<script>
|
||
// "Go to week" — open the native date picker; picking any day loads the
|
||
// week it belongs to. Admin only.
|
||
(function () {
|
||
var btn = document.getElementById('cal-jump-btn');
|
||
var input = document.getElementById('cal-jump');
|
||
if (!btn || !input) return;
|
||
btn.addEventListener('click', function () {
|
||
if (typeof input.showPicker === 'function') {
|
||
input.showPicker();
|
||
} else {
|
||
input.focus();
|
||
input.click();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
{% endblock js %}
|