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) + '<br>eventsType=' + typeof state.events + '<br>json=' + JSON.stringify(state.events).substring(0,100) + '</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>
|