996 lines
35 KiB
HTML
996 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Calendar</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
:root {
|
|
--bg: #1a1a2e;
|
|
--bg2: #22223a;
|
|
--bg3: #2a2a44;
|
|
--text: #e0e0e0;
|
|
--text-dim: #888;
|
|
--text-bright: #fff;
|
|
--border: #333;
|
|
--accent: #6366f1;
|
|
--accent-hover: #818cf8;
|
|
--danger: #ef4444;
|
|
--success: #22c55e;
|
|
--warn: #f59e0b;
|
|
--radius: 8px;
|
|
--radius-sm: 4px;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
overflow-x: hidden;
|
|
}
|
|
/* Header */
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
background: var(--bg2);
|
|
border-bottom: 1px solid var(--border);
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.header-left { display: flex; align-items: center; gap: 8px; }
|
|
.header-title { font-size: 1.1rem; font-weight: 600; color: var(--text-bright); }
|
|
.nav-btn {
|
|
background: var(--bg3);
|
|
border: 1px solid var(--border);
|
|
color: var(--text);
|
|
padding: 6px 12px;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: background 0.15s;
|
|
}
|
|
.nav-btn:hover { background: var(--accent); color: #fff; }
|
|
.nav-btn.today-btn { font-weight: 600; }
|
|
.view-tabs { display: flex; gap: 2px; }
|
|
.view-tab {
|
|
padding: 6px 14px;
|
|
background: var(--bg3);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: all 0.15s;
|
|
}
|
|
.view-tab:first-child { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
|
|
.view-tab:last-child { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
|
|
.view-tab.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
.view-tab:hover:not(.active) { background: var(--bg3); color: var(--text); }
|
|
|
|
/* Month grid */
|
|
.month-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
gap: 1px;
|
|
background: var(--border);
|
|
margin: 0;
|
|
}
|
|
.day-header {
|
|
background: var(--bg3);
|
|
padding: 8px 4px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
}
|
|
.day-cell {
|
|
background: var(--bg2);
|
|
min-height: 90px;
|
|
padding: 4px 6px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
position: relative;
|
|
}
|
|
.day-cell:hover { background: var(--bg3); }
|
|
.day-cell.other-month { opacity: 0.35; }
|
|
.day-cell.today { background: rgba(99, 102, 241, 0.12); }
|
|
.day-cell.selected { background: rgba(99, 102, 241, 0.2); box-shadow: inset 0 0 0 1px var(--accent); }
|
|
.day-cell.drop-target { background: rgba(34, 197, 94, 0.15); box-shadow: inset 0 0 0 2px var(--success); }
|
|
.day-number {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 2px;
|
|
color: var(--text);
|
|
}
|
|
.day-cell.today .day-number {
|
|
color: var(--accent);
|
|
}
|
|
/* Events in month grid */
|
|
.month-events {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1px;
|
|
}
|
|
.month-event {
|
|
font-size: 11px;
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
cursor: pointer;
|
|
transition: opacity 0.1s;
|
|
color: #fff;
|
|
}
|
|
.month-event:hover { opacity: 0.85; }
|
|
.month-event.more-link {
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
font-size: 10px;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Week view */
|
|
.week-view {
|
|
display: grid;
|
|
grid-template-columns: 60px repeat(7, 1fr);
|
|
gap: 1px;
|
|
background: var(--border);
|
|
overflow-x: auto;
|
|
}
|
|
.hour-label {
|
|
background: var(--bg3);
|
|
padding: 4px;
|
|
text-align: right;
|
|
font-size: 11px;
|
|
color: var(--text-dim);
|
|
min-height: 40px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.hour-slot {
|
|
background: var(--bg2);
|
|
min-height: 40px;
|
|
border-bottom: 1px solid var(--border);
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
.hour-slot:hover { background: var(--bg3); }
|
|
.week-event {
|
|
position: absolute;
|
|
left: 2px;
|
|
right: 2px;
|
|
padding: 2px 4px;
|
|
font-size: 11px;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
color: #fff;
|
|
}
|
|
|
|
/* Day view */
|
|
.day-view {
|
|
padding: 12px 16px;
|
|
}
|
|
.day-events-list { display: flex; flex-direction: column; gap: 8px; }
|
|
.day-event-card {
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 10px 14px;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
border-left: 4px solid var(--accent);
|
|
}
|
|
.day-event-card:hover { background: var(--bg3); }
|
|
.day-event-time { font-size: 12px; color: var(--text-dim); }
|
|
.day-event-title { font-weight: 600; margin-top: 2px; }
|
|
.day-event-desc { font-size: 13px; color: var(--text-dim); margin-top: 4px; }
|
|
.day-event-category { font-size: 11px; margin-top: 4px; display: inline-block; padding: 1px 6px; border-radius: 3px; }
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: rgba(0,0,0,0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
padding: 20px;
|
|
}
|
|
.modal {
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
max-width: 480px;
|
|
width: 100%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
padding: 24px;
|
|
}
|
|
.modal h3 { margin-bottom: 16px; color: var(--text-bright); }
|
|
.form-group { margin-bottom: 14px; }
|
|
.form-group label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
|
|
.form-group input, .form-group textarea, .form-group select {
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
}
|
|
.form-group textarea { resize: vertical; min-height: 60px; }
|
|
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
|
|
outline: none; border-color: var(--accent);
|
|
}
|
|
.form-row { display: flex; gap: 12px; }
|
|
.form-row .form-group { flex: 1; }
|
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
|
|
.btn {
|
|
padding: 8px 18px;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
}
|
|
.btn-primary { background: var(--accent); color: #fff; }
|
|
.btn-primary:hover { background: var(--accent-hover); }
|
|
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
|
|
.btn-secondary:hover { background: var(--bg3); }
|
|
.btn-danger { background: var(--danger); color: #fff; }
|
|
.btn-danger:hover { opacity: 0.85; }
|
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
.checkbox-row { display: flex; align-items: center; gap: 8px; }
|
|
.checkbox-row input[type="checkbox"] { width: auto; }
|
|
|
|
/* Categories legend */
|
|
.categories-bar {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
background: var(--bg2);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
.cat-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 8px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
color: #fff;
|
|
}
|
|
.cat-tag:hover { opacity: 0.8; }
|
|
.cat-tag.active { box-shadow: 0 0 0 2px var(--text-bright); }
|
|
.cat-filter-all {
|
|
color: var(--text-dim);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
}
|
|
|
|
/* Toast */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
right: 16px;
|
|
background: var(--bg3);
|
|
border: 1px solid var(--border);
|
|
padding: 10px 16px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 13px;
|
|
color: var(--text-bright);
|
|
z-index: 2000;
|
|
animation: slideIn 0.25s ease;
|
|
}
|
|
@keyframes slideIn {
|
|
from { transform: translateY(20px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
/* Loading */
|
|
.loading-screen {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
color: var(--text-dim);
|
|
font-size: 16px;
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
|
|
/* Empty state */
|
|
.empty-day { color: var(--text-dim); padding: 20px; text-align: center; }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 600px) {
|
|
.header { flex-direction: column; align-items: stretch; }
|
|
.view-tabs { justify-content: center; }
|
|
.day-cell { min-height: 60px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div class="loading-screen">⏳ Загрузка календаря...</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ─── State ────────────────────────────────────────────────────────
|
|
const state = {
|
|
view: 'month', // month | week | day
|
|
currentDate: new Date(), // the "focus" date (what month/week/day we're looking at)
|
|
selectedDate: null, // clicked day (for modal)
|
|
events: [],
|
|
categories: [],
|
|
filterCatId: null, // null = all
|
|
eventsLoaded: false,
|
|
dropDate: null, // date string being dragged onto
|
|
panelReady: false,
|
|
};
|
|
|
|
// Cache DOM refs
|
|
let appEl;
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────
|
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
|
function fmtDate(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()); }
|
|
function fmtISO(d) { return fmtDate(d) + 'T00:00:00'; }
|
|
function fmtMonth(d) { return d.getFullYear() + '-' + pad(d.getMonth()+1); }
|
|
function dayOfWeek(d) { const w = d.getDay(); return w === 0 ? 7 : w; } // Mon=1..Sun=7
|
|
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
|
|
function parseDate(s) { const d = new Date(s); if (isNaN(d.getTime())) return null; return d; }
|
|
function shortTime(s) {
|
|
if (!s) return '';
|
|
const d = parseDate(s);
|
|
if (!d) return s;
|
|
return pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
}
|
|
function shortDate(s) {
|
|
if (!s) return '';
|
|
const d = parseDate(s);
|
|
if (!d) return s;
|
|
return pad(d.getDate()) + '.' + pad(d.getMonth()+1);
|
|
}
|
|
|
|
function fromNow(d) {
|
|
const now = new Date();
|
|
const diff = d.getTime() - now.getTime();
|
|
if (diff < 0) return 'прошло';
|
|
const mins = Math.round(diff / 60000);
|
|
if (mins < 60) return 'через ' + mins + ' мин';
|
|
const hours = Math.round(mins / 60);
|
|
if (hours < 24) return 'через ' + hours + ' ч';
|
|
const days = Math.round(hours / 24);
|
|
return 'через ' + days + ' дн';
|
|
}
|
|
|
|
// ─── Communication ─────────────────────────────────────────────────
|
|
function sendToParent(action, data) {
|
|
const msg = { source: 'calendar-plugin', action: action, data: data || {} };
|
|
if (window.parent && window.parent !== window) {
|
|
window.parent.postMessage(msg, '*');
|
|
}
|
|
}
|
|
|
|
// Request events from parent
|
|
function requestEvents() {
|
|
const start = getViewStart();
|
|
const end = getViewEnd();
|
|
sendToParent('get-events', { start: fmtISO(start), end: fmtISO(end) });
|
|
}
|
|
|
|
function getViewStart() {
|
|
const d = state.currentDate;
|
|
if (state.view === 'month') {
|
|
const first = new Date(d.getFullYear(), d.getMonth(), 1);
|
|
// Go back to Monday
|
|
const wd = dayOfWeek(first);
|
|
first.setDate(first.getDate() - (wd - 1));
|
|
return first;
|
|
}
|
|
if (state.view === 'week') {
|
|
const wd = dayOfWeek(d);
|
|
const monday = new Date(d);
|
|
monday.setDate(monday.getDate() - (wd - 1));
|
|
return monday;
|
|
}
|
|
return new Date(d);
|
|
}
|
|
|
|
function getViewEnd() {
|
|
const start = getViewStart();
|
|
const end = new Date(start);
|
|
if (state.view === 'month') end.setDate(end.getDate() + 42); // 6 weeks
|
|
else if (state.view === 'week') end.setDate(end.getDate() + 7);
|
|
else end.setDate(end.getDate() + 1);
|
|
return end;
|
|
}
|
|
|
|
// ─── Listen for parent messages ──────────────────────────────────────
|
|
window.addEventListener('message', function(e) {
|
|
const msg = e.data;
|
|
if (!msg || msg.source !== 'verstak') return;
|
|
|
|
switch (msg.type) {
|
|
case 'calendar-data':
|
|
try { window.parent.go.main.App.WriteDebugLog('[iframe] received calendar-data, events=' + (msg.events ? msg.events.length : 0) + ', categories=' + (msg.categories ? msg.categories.length : 0)); } catch(e) { try { console.log('iframe debug error: ' + e); } catch(e2) {} }
|
|
if (msg.events) state.events = msg.events;
|
|
if (msg.categories) state.categories = msg.categories;
|
|
state.eventsLoaded = true;
|
|
state.panelReady = true;
|
|
try { render(); } catch(e) { try { window.parent.go.main.App.WriteDebugLog('[iframe] render error: ' + String(e) + ' ' + JSON.stringify({message: e.message, stack: e.stack?.substring(0,200)})); } catch(e2) {} }
|
|
break;
|
|
|
|
case 'drop':
|
|
// Dragged from another section
|
|
if (msg.date) {
|
|
state.dropDate = msg.date;
|
|
openCreateModal(msg.date, {
|
|
node_id: msg.data?.node_id || '',
|
|
link_type: msg.data?.link_type || 'node',
|
|
title: msg.data?.title || '',
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'event-created':
|
|
case 'event-updated':
|
|
case 'event-deleted':
|
|
requestEvents();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// ─── Tell parent we're ready ─────────────────────────────────────────
|
|
function init() {
|
|
appEl = document.getElementById('app');
|
|
sendToParent('ready', { version: '1.0' });
|
|
// Request initial data
|
|
setTimeout(requestEvents, 100);
|
|
// Render immediately with empty state
|
|
render();
|
|
}
|
|
|
|
// ─── Render ──────────────────────────────────────────────────────────
|
|
function render() {
|
|
if (!appEl) {
|
|
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: appEl is null!'); } catch(e) {}
|
|
return;
|
|
}
|
|
try { window.parent.go.main.App.WriteDebugLog('[iframe] render: eventsLoaded=' + state.eventsLoaded + ', events=' + (state.events ? state.events.length : 'no-state') + ', cats=' + (state.categories ? state.categories.length : 'no-state')); } catch(e) {}
|
|
if (!state.eventsLoaded) {
|
|
appEl.innerHTML = '<div class="loading-screen">⏳ Загрузка календаря...</div>';
|
|
return;
|
|
}
|
|
try {
|
|
appEl.innerHTML = '<div class="test-msg">IT WORKS! events=' + (state.events ? state.events.length : 0) + ' cats=' + (state.categories ? state.categories.length : 0) + '</div>';
|
|
try { window.parent.go.main.App.WriteDebugLog('[iframe] innerHTML SET SUCCESSFULLY'); } catch(e) {}
|
|
} catch(e) {
|
|
try { window.parent.go.main.App.WriteDebugLog('[iframe] innerHTML error: ' + String(e)); } catch(e2) {}
|
|
}
|
|
}
|
|
|
|
function renderHeader() {
|
|
const d = state.currentDate;
|
|
const monthNames = ['Январь','Февраль','Март','Апрель','Май','Июнь',
|
|
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
|
let title = '';
|
|
if (state.view === 'month') title = monthNames[d.getMonth()] + ' ' + d.getFullYear();
|
|
else if (state.view === 'week') title = 'Неделя ' + fmtDate(getViewStart());
|
|
else title = fmtDate(d);
|
|
|
|
return `<div class="header">
|
|
<div class="header-left">
|
|
<button class="nav-btn" onclick="calendar.prevPeriod()">◀</button>
|
|
<button class="nav-btn today-btn" onclick="calendar.goToday()">Сегодня</button>
|
|
<button class="nav-btn" onclick="calendar.nextPeriod()">▶</button>
|
|
<span class="header-title">${title}</span>
|
|
</div>
|
|
<div class="view-tabs">
|
|
<button class="view-tab${state.view==='month'?' active':''}" onclick="calendar.setView('month')">Месяц</button>
|
|
<button class="view-tab${state.view==='week'?' active':''}" onclick="calendar.setView('week')">Неделя</button>
|
|
<button class="view-tab${state.view==='day'?' active':''}" onclick="calendar.setView('day')">День</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderCategories() {
|
|
const cats = state.categories;
|
|
if (!cats || cats.length === 0) return '';
|
|
let html = '<div class="categories-bar">';
|
|
html += `<span class="cat-filter-all" onclick="calendar.setFilter(null)">${state.filterCatId === null ? '●' : '○'} Все</span>`;
|
|
for (const c of cats) {
|
|
const active = c.id === state.filterCatId;
|
|
html += `<span class="cat-tag${active ? ' active' : ''}" style="background:${c.color}" onclick="calendar.setFilter('${c.id}')">${c.icon || ''} ${c.name}</span>`;
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function getEventsForDate(dateStr) {
|
|
const filtered = state.filterCatId
|
|
? state.events.filter(e => e.category_id === state.filterCatId)
|
|
: state.events;
|
|
return filtered.filter(e => {
|
|
const eStart = (e.start || '').substring(0, 10);
|
|
const eEnd = (e.end || e.start || '').substring(0, 10);
|
|
return dateStr >= eStart && dateStr <= eEnd;
|
|
});
|
|
}
|
|
|
|
function renderView() {
|
|
if (state.view === 'month') return renderMonth();
|
|
if (state.view === 'week') return renderWeek();
|
|
return renderDay();
|
|
}
|
|
|
|
function renderMonth() {
|
|
const d = state.currentDate;
|
|
const y = d.getFullYear(), m = d.getMonth();
|
|
const firstDay = new Date(y, m, 1);
|
|
const startDay = dayOfWeek(firstDay); // Mon=1..Sun=7
|
|
const totalDays = daysInMonth(y, m);
|
|
const todayStr = fmtDate(new Date());
|
|
const selectedStr = state.selectedDate ? fmtDate(state.selectedDate) : null;
|
|
|
|
// Previous month padding
|
|
const prevMonthDays = daysInMonth(y, m - 1);
|
|
let cells = [];
|
|
const startOffset = startDay - 1; // how many prev-month cells
|
|
|
|
// Day headers
|
|
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
|
let html = '<div class="month-grid">';
|
|
for (const n of dayNames) {
|
|
html += `<div class="day-header">${n}</div>`;
|
|
}
|
|
|
|
// Fill cells
|
|
const totalCells = Math.ceil((startOffset + totalDays) / 7) * 7;
|
|
|
|
for (let i = 0; i < totalCells; i++) {
|
|
let dayNum, cellDate, isOther = false;
|
|
if (i < startOffset) {
|
|
dayNum = prevMonthDays - startOffset + i + 1;
|
|
cellDate = new Date(y, m - 1, dayNum);
|
|
isOther = true;
|
|
} else if (i >= startOffset + totalDays) {
|
|
dayNum = i - startOffset - totalDays + 1;
|
|
cellDate = new Date(y, m + 1, dayNum);
|
|
isOther = true;
|
|
} else {
|
|
dayNum = i - startOffset + 1;
|
|
cellDate = new Date(y, m, dayNum);
|
|
}
|
|
|
|
const dateStr = fmtDate(cellDate);
|
|
const isToday = dateStr === todayStr;
|
|
const isSelected = selectedStr === dateStr;
|
|
const isDrop = state.dropDate === dateStr;
|
|
const events = getEventsForDate(dateStr);
|
|
const isWeekend = dayOfWeek(cellDate) >= 6;
|
|
|
|
let cls = 'day-cell';
|
|
if (isOther) cls += ' other-month';
|
|
if (isToday) cls += ' today';
|
|
if (isSelected) cls += ' selected';
|
|
if (isDrop) cls += ' drop-target';
|
|
|
|
html += `<div class="${cls}" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
|
|
html += `<div class="day-number">${dayNum}</div>`;
|
|
if (events.length > 0) {
|
|
html += '<div class="month-events">';
|
|
const maxShow = 3;
|
|
for (let j = 0; j < Math.min(events.length, maxShow); j++) {
|
|
const ev = events[j];
|
|
const color = ev.color || '#6366f1';
|
|
html += `<div class="month-event" style="background:${color}" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${ev.title}</div>`;
|
|
}
|
|
if (events.length > maxShow) {
|
|
html += `<div class="month-event more-link" onclick="event.stopPropagation(); calendar.setView('day'); state.currentDate = new Date('${dateStr}'); render();">+${events.length - maxShow} ещё</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderWeek() {
|
|
const start = getViewStart();
|
|
const todayStr = fmtDate(new Date());
|
|
let html = '<div class="week-view">';
|
|
|
|
// Corner label
|
|
html += '<div class="hour-label"></div>';
|
|
for (let d = 0; d < 7; d++) {
|
|
const day = new Date(start);
|
|
day.setDate(day.getDate() + d);
|
|
const dateStr = fmtDate(day);
|
|
const dayNames = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
|
html += `<div class="day-header" style="${dayOfWeek(day) >= 6 ? 'color:var(--danger)' : ''}">${dayNames[d]} ${day.getDate()}</div>`;
|
|
}
|
|
|
|
// Hours
|
|
for (let h = 0; h < 24; h++) {
|
|
html += `<div class="hour-label">${pad(h)}:00</div>`;
|
|
for (let d = 0; d < 7; d++) {
|
|
const day = new Date(start);
|
|
day.setDate(day.getDate() + d);
|
|
const dateStr = fmtDate(day);
|
|
const eventsOnHour = getEventsForDate(dateStr).filter(ev => {
|
|
const evH = parseInt((ev.start || '').substring(11, 13));
|
|
return evH === h;
|
|
});
|
|
let cellHtml = `<div class="hour-slot" onclick="calendar.selectDay('${dateStr}')" data-date="${dateStr}">`;
|
|
for (const ev of eventsOnHour) {
|
|
const color = ev.color || '#6366f1';
|
|
cellHtml += `<div class="week-event" style="background:${color}; top:2px" onclick="event.stopPropagation(); calendar.openEvent('${ev.id}')">${shortTime(ev.start)} ${ev.title}</div>`;
|
|
}
|
|
cellHtml += '</div>';
|
|
html += cellHtml;
|
|
}
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderDay() {
|
|
const d = state.currentDate;
|
|
const dateStr = fmtDate(d);
|
|
const events = getEventsForDate(dateStr);
|
|
const todayStr = fmtDate(new Date());
|
|
const isToday = dateStr === todayStr;
|
|
|
|
let html = `<div class="day-view">`;
|
|
html += `<h3 style="margin-bottom:12px">${fmtDate(d)}${isToday ? ' — Сегодня' : ''}</h3>`;
|
|
html += `<button class="btn btn-primary btn-sm" onclick="calendar.selectDay('${dateStr}')" style="margin-bottom:12px">+ Добавить событие</button>`;
|
|
|
|
if (events.length === 0) {
|
|
html += '<div class="empty-day">Нет событий на этот день</div>';
|
|
} else {
|
|
html += '<div class="day-events-list">';
|
|
for (const ev of events) {
|
|
const color = ev.category_color || ev.color || '#6366f1';
|
|
const catName = ev.category_name || '';
|
|
html += `<div class="day-event-card" style="border-left-color:${color}" onclick="calendar.openEvent('${ev.id}')">`;
|
|
if (ev.all_day == 1) {
|
|
html += `<div class="day-event-time">Весь день</div>`;
|
|
} else {
|
|
html += `<div class="day-event-time">${shortTime(ev.start)} — ${shortTime(ev.end)}</div>`;
|
|
}
|
|
html += `<div class="day-event-title">${ev.title}</div>`;
|
|
if (ev.description) {
|
|
html += `<div class="day-event-desc">${ev.description}</div>`;
|
|
}
|
|
if (catName) {
|
|
html += `<span class="day-event-category" style="background:${color}20; color:${color}">${catName}</span>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// ─── Event Modal ─────────────────────────────────────────────────────
|
|
function openCreateModal(dateStr, prefill) {
|
|
prefill = prefill || {};
|
|
const cats = state.categories;
|
|
const todayStr = fmtDate(new Date());
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal-overlay';
|
|
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
|
|
|
|
let catOptions = '<option value="">Без категории</option>';
|
|
for (const c of cats) {
|
|
catOptions += `<option value="${c.id}" style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
|
|
}
|
|
|
|
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
|
|
<h3>${prefill.node_id ? '📎 Событие из узла' : '📅 Новое событие'}</h3>
|
|
<div class="form-group">
|
|
<label>Название</label>
|
|
<input id="ev-title" value="${escapeHtml(prefill.title || '')}" placeholder="Введите название события">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Дата начала</label>
|
|
<input id="ev-start" type="date" value="${dateStr || todayStr}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Время</label>
|
|
<input id="ev-start-time" type="time" value="09:00">
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Дата окончания</label>
|
|
<input id="ev-end" type="date" value="${dateStr || todayStr}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Время</label>
|
|
<input id="ev-end-time" type="time" value="10:00">
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-row">
|
|
<input id="ev-allday" type="checkbox">
|
|
<label for="ev-allday">Весь день</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Описание</label>
|
|
<textarea id="ev-desc" placeholder="Описание события (необязательно)"></textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Категория</label>
|
|
<select id="ev-category">${catOptions}</select>
|
|
</div>
|
|
</div>
|
|
${prefill.node_id ? `<input type="hidden" id="ev-node-id" value="${escapeHtml(prefill.node_id)}">
|
|
<input type="hidden" id="ev-link-type" value="${escapeHtml(prefill.link_type || 'node')}">` : ''}
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
|
<button class="btn btn-primary" onclick="submitCreate()">Создать</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Toggle time fields when "all day" is checked
|
|
document.getElementById('ev-allday').addEventListener('change', function() {
|
|
document.getElementById('ev-start-time').disabled = this.checked;
|
|
document.getElementById('ev-end-time').disabled = this.checked;
|
|
});
|
|
}
|
|
|
|
// Expose to global for onclick handlers
|
|
window.closeModal = function() {
|
|
const modals = document.querySelectorAll('.modal-overlay');
|
|
modals.forEach(m => m.remove());
|
|
};
|
|
|
|
window.submitCreate = function() {
|
|
const title = document.getElementById('ev-title').value.trim();
|
|
if (!title) { showToast('Название обязательно'); return; }
|
|
|
|
const startDate = document.getElementById('ev-start').value;
|
|
const endDate = document.getElementById('ev-end').value;
|
|
const allDay = document.getElementById('ev-allday').checked;
|
|
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
|
|
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
|
|
const description = document.getElementById('ev-desc').value.trim();
|
|
const categoryId = document.getElementById('ev-category').value;
|
|
const nodeId = document.getElementById('ev-node-id')?.value || '';
|
|
const linkType = document.getElementById('ev-link-type')?.value || 'node';
|
|
|
|
sendToParent('create-event', {
|
|
title: title,
|
|
start: startDate + 'T' + startTime + ':00',
|
|
end: endDate + 'T' + endTime + ':00',
|
|
all_day: allDay,
|
|
description: description,
|
|
category_id: categoryId,
|
|
node_id: nodeId,
|
|
link_type: linkType,
|
|
});
|
|
|
|
closeModal();
|
|
showToast('✅ Событие создаётся...');
|
|
};
|
|
|
|
function openEditModal(eventId) {
|
|
const ev = state.events.find(e => e.id === eventId);
|
|
if (!ev) { showToast('Событие не найдено'); return; }
|
|
|
|
const cats = state.categories;
|
|
const startDate = (ev.start || '').substring(0, 10);
|
|
const startTime = (ev.start || '').substring(11, 16);
|
|
const endDate = (ev.end || ev.start || '').substring(0, 10);
|
|
const endTime = (ev.end || ev.start || '').substring(11, 16);
|
|
const allDay = ev.all_day == 1;
|
|
const completed = ev.completed == 1;
|
|
|
|
let catOptions = '<option value="">Без категории</option>';
|
|
for (const c of cats) {
|
|
catOptions += `<option value="${c.id}" ${c.id === ev.category_id ? 'selected' : ''} style="color:${c.color}">${c.icon || ''} ${c.name}</option>`;
|
|
}
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'modal-overlay';
|
|
modal.onclick = function(e) { if (e.target === modal) closeModal(); };
|
|
|
|
modal.innerHTML = `<div class="modal" onclick="event.stopPropagation()">
|
|
<h3>✏️ Редактировать событие</h3>
|
|
<div class="form-group">
|
|
<label>Название</label>
|
|
<input id="ev-title" value="${escapeHtml(ev.title)}">
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Дата начала</label>
|
|
<input id="ev-start" type="date" value="${startDate}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Время</label>
|
|
<input id="ev-start-time" type="time" value="${startTime}" ${allDay ? 'disabled' : ''}>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Дата окончания</label>
|
|
<input id="ev-end" type="date" value="${endDate}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Время</label>
|
|
<input id="ev-end-time" type="time" value="${endTime}" ${allDay ? 'disabled' : ''}>
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-row">
|
|
<input id="ev-allday" type="checkbox" ${allDay ? 'checked' : ''}>
|
|
<label for="ev-allday">Весь день</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Описание</label>
|
|
<textarea id="ev-desc">${escapeHtml(ev.description || '')}</textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Категория</label>
|
|
<select id="ev-category">${catOptions}</select>
|
|
</div>
|
|
</div>
|
|
<div class="checkbox-row">
|
|
<input id="ev-completed" type="checkbox" ${completed ? 'checked' : ''}>
|
|
<label for="ev-completed">Выполнено</label>
|
|
</div>
|
|
${ev.node_id ? `<div style="margin-top:8px;font-size:12px;color:var(--text-dim)">📎 Связано с узлом: ${escapeHtml(ev.node_id)}</div>` : ''}
|
|
<div class="modal-actions">
|
|
<button class="btn btn-danger" onclick="submitDelete('${ev.id}')">Удалить</button>
|
|
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
|
<button class="btn btn-primary" onclick="submitUpdate('${ev.id}')">Сохранить</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
document.getElementById('ev-allday').addEventListener('change', function() {
|
|
document.getElementById('ev-start-time').disabled = this.checked;
|
|
document.getElementById('ev-end-time').disabled = this.checked;
|
|
});
|
|
}
|
|
|
|
window.submitUpdate = function(id) {
|
|
const title = document.getElementById('ev-title').value.trim();
|
|
if (!title) { showToast('Название обязательно'); return; }
|
|
|
|
const allDay = document.getElementById('ev-allday').checked;
|
|
const startDate = document.getElementById('ev-start').value;
|
|
const startTime = allDay ? '00:00' : document.getElementById('ev-start-time').value;
|
|
const endDate = document.getElementById('ev-end').value;
|
|
const endTime = allDay ? '00:00' : document.getElementById('ev-end-time').value;
|
|
const completed = document.getElementById('ev-completed')?.checked || false;
|
|
|
|
sendToParent('update-event', {
|
|
id: id,
|
|
title: title,
|
|
start: startDate + 'T' + startTime + ':00',
|
|
end: endDate + 'T' + endTime + ':00',
|
|
all_day: allDay,
|
|
description: document.getElementById('ev-desc').value.trim(),
|
|
category_id: document.getElementById('ev-category').value,
|
|
completed: completed,
|
|
});
|
|
|
|
closeModal();
|
|
showToast('✅ Сохраняю...');
|
|
};
|
|
|
|
window.submitDelete = function(id) {
|
|
if (!confirm('Удалить это событие?')) return;
|
|
sendToParent('delete-event', { id: id });
|
|
closeModal();
|
|
showToast('🗑️ Удаляю...');
|
|
};
|
|
|
|
// ─── Navigation functions ───────────────────────────────────────────
|
|
window.calendar = {
|
|
prevPeriod: function() {
|
|
const d = state.currentDate;
|
|
if (state.view === 'month') d.setMonth(d.getMonth() - 1);
|
|
else if (state.view === 'week') d.setDate(d.getDate() - 7);
|
|
else d.setDate(d.getDate() - 1);
|
|
state.currentDate = d;
|
|
requestEvents();
|
|
render();
|
|
},
|
|
|
|
nextPeriod: function() {
|
|
const d = state.currentDate;
|
|
if (state.view === 'month') d.setMonth(d.getMonth() + 1);
|
|
else if (state.view === 'week') d.setDate(d.getDate() + 7);
|
|
else d.setDate(d.getDate() + 1);
|
|
state.currentDate = d;
|
|
requestEvents();
|
|
render();
|
|
},
|
|
|
|
goToday: function() {
|
|
state.currentDate = new Date();
|
|
requestEvents();
|
|
render();
|
|
},
|
|
|
|
setView: function(view) {
|
|
state.view = view;
|
|
requestEvents();
|
|
render();
|
|
},
|
|
|
|
setFilter: function(catId) {
|
|
state.filterCatId = catId;
|
|
render();
|
|
},
|
|
|
|
selectDay: function(dateStr) {
|
|
state.selectedDate = parseDate(dateStr);
|
|
openCreateModal(dateStr, {});
|
|
},
|
|
|
|
openEvent: function(eventId) {
|
|
openEditModal(eventId);
|
|
},
|
|
};
|
|
|
|
// ─── Toast ──────────────────────────────────────────────────────────
|
|
function showToast(msg) {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast';
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(() => { el.remove(); }, 3000);
|
|
}
|
|
|
|
// ─── Escape HTML ────────────────────────────────────────────────────
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
}
|
|
|
|
// ─── Init ───────────────────────────────────────────────────────────
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|