diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..024b070 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,540 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; +:root { + --bg: #f6f8fa; + --surface: #ffffff; + --surface-muted: #f6f8fa; + --surface-soft: #fafff8; + --border: #d0d7de; + --border-strong: #8c959f; + --text: #1f2328; + --text-muted: #57606a; + --text-soft: #6e7781; + --accent: #2da44e; + --accent-dark: #1f883d; + --accent-contrast: #ffffff; + --accent-blue: #0969da; + --accent-blue-soft: #ddf4ff; + --nav-bg: #0d1117; + --nav-text: #f0f6fc; + --success: #1a7f37; + --danger: #cf222e; + --shadow: 0 1px 0 rgba(27, 31, 36, 0.04), 0 8px 24px rgba(140, 149, 159, 0.18); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; } -.main-wrapper { - display: flex; +html { + scroll-behavior: smooth; +} + +body { + background: + radial-gradient(circle at top left, rgba(45, 164, 78, 0.08), transparent 26%), + radial-gradient(circle at top right, rgba(9, 105, 218, 0.08), transparent 22%), + var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + text-rendering: optimizeLegibility; +} + +.app-header { + background: rgba(13, 17, 23, 0.92); + backdrop-filter: blur(12px); + border-bottom-color: rgba(240, 246, 252, 0.08) !important; +} + +.navbar { + min-height: 72px; +} + +.brand-mark { + width: 36px; + height: 36px; + border-radius: 11px; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-blue) 100%); + color: var(--accent-contrast); + font-size: 1rem; + box-shadow: 0 10px 22px rgba(9, 105, 218, 0.22); } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } +.brand-title { + color: var(--nav-text); + font-size: 0.96rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; } -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; +.brand-subtitle { + color: rgba(240, 246, 252, 0.72); + font-size: 0.76rem; + line-height: 1.2; +} + +.panel { + background: rgba(255, 255, 255, 0.94); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: 1.25rem; +} + +.panel-hero { + padding: 1.5rem; +} + +.eyebrow { + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.71rem; + font-weight: 700; + margin-bottom: 0.85rem; +} + +.hero-title, +.panel-title, +.detail-title { + letter-spacing: -0.04em; +} + +.hero-title { + font-size: clamp(1.9rem, 3vw, 2.6rem); + line-height: 1.05; + margin-bottom: 0.85rem; +} + +.hero-copy { + color: var(--text-muted); + max-width: 48ch; + margin-bottom: 0; +} + +.meta-chip, +.priority-badge, +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.42rem 0.7rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid var(--border); + background: var(--surface-muted); + color: var(--text); +} + +.stat-card { display: flex; flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); + justify-content: space-between; + min-height: 100%; +} + +.stat-card { + position: relative; overflow: hidden; } -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; - font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; +.stat-card::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--accent), var(--accent-blue)); } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; +.stat-card-active::before { + background: linear-gradient(90deg, var(--accent-blue), #54aeff); +} + +.stat-card-done::before { + background: linear-gradient(90deg, var(--accent), #7ee787); +} + +.stat-card-urgent::before { + background: linear-gradient(90deg, #fb8500, #f2cc60); +} + +.stat-label, +.stat-note, +.detail-label, +.task-meta, +.form-text { + color: var(--text-muted); +} + +.stat-label, +.detail-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 700; +} + +.stat-value { + font-size: clamp(1.8rem, 3vw, 2.35rem); + line-height: 1; + letter-spacing: -0.05em; + margin: 0.5rem 0 0.35rem; +} + +.stat-note { + font-size: 0.85rem; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.panel-header-stack { + align-items: stretch; +} + +.panel-title { + font-size: 1.15rem; + font-weight: 700; +} + +.form-control, +.form-select, +.input-group-text, +.btn, +.dropdown-menu { + border-radius: var(--radius-sm); +} + +.form-control, +.form-select, +.input-group-text { + border-color: var(--border); + min-height: 46px; + background: #fff; +} + +.form-control:focus, +.form-select:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 0.2rem rgba(9, 105, 218, 0.14); +} + +.btn { + font-weight: 600; + box-shadow: none !important; +} + +.btn-dark { + background: linear-gradient(180deg, #3fb950 0%, var(--accent) 100%); + border-color: var(--accent-dark); + color: var(--accent-contrast); +} + +.btn-dark:hover, +.btn-dark:focus { + background: linear-gradient(180deg, #2ea043 0%, var(--accent-dark) 100%); + border-color: #1a7f37; + color: var(--accent-contrast); +} + +.btn-light { + background: #f6f8fa; + border-color: var(--border); + color: var(--text); +} + +.btn-light:hover, +.btn-light:focus { + background: #eef2f6; + border-color: var(--border-strong); + color: var(--text); +} + +.filters-grid { + display: grid; + gap: 0.8rem; +} + +.btn-filter { + border: 1px solid var(--border); + background: var(--surface-muted); + color: var(--text); + padding-inline: 0.9rem; +} + +.btn-filter:hover { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.btn-check:checked + .btn-filter { + background: var(--accent-blue-soft); + color: var(--accent-blue); + border-color: #b6e3ff; +} + +.sort-select { + min-width: 180px; +} + +.task-list { + max-height: 70vh; + overflow: auto; + padding-right: 0.25rem; +} + +.task-item { + position: relative; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1rem; + background: #fff; + cursor: pointer; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease, background-color 0.18s ease; +} + +.task-item:focus-visible { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.16); +} + +.task-item:hover { + border-color: var(--accent-blue); + box-shadow: 0 12px 24px rgba(9, 105, 218, 0.08); + background: #fbfdff; + transform: translateY(-1px); +} + +.task-item-active { + border-color: var(--accent-blue); + box-shadow: inset 3px 0 0 var(--accent-blue), 0 0 0 1px rgba(9, 105, 218, 0.08); + background: #f9fcff; +} + +.task-item-done { + background: #f6fff8; +} + +.check-button { + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid var(--border); + background: #fff; + color: var(--text-muted); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.05rem; +} + +.check-button:hover { + border-color: var(--accent); + color: var(--accent); + background: #f6fff8; +} + +.task-title-link { + font-weight: 700; + color: var(--text); + text-decoration: none; + font-size: 0.97rem; +} + +.task-title-link:hover { + color: var(--accent-blue); +} + +.task-snippet, +.detail-description { + color: var(--text-muted); + line-height: 1.55; +} + +.task-item-done .task-title-link, +.task-item-done .task-snippet { + opacity: 0.72; +} + +.task-meta { + font-size: 0.78rem; +} + +.priority-high, +.priority-medium, +.priority-low, +.status-active, +.status-done { + border: 1px solid var(--border); +} + +.priority-high { + background: #fef2f2; + color: #991b1b; + border-color: #fecaca; +} + +.priority-medium { + background: #fff8c5; + color: #9a6700; + border-color: #eedb85; +} + +.priority-low { + background: #dafbe1; + color: #1a7f37; + border-color: #aceebb; +} + +.status-active { + background: var(--accent-blue-soft); + color: var(--accent-blue); + border-color: #b6e3ff; +} + +.status-done { + background: #dafbe1; + color: var(--success); + border-color: #aceebb; +} + +.icon-button { + width: 36px; + height: 36px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.detail-panel { display: flex; flex-direction: column; - gap: 1.25rem; } -/* Custom Scrollbar */ +.detail-stack { + height: 100%; +} + +.detail-title { + font-size: 1.35rem; + margin-bottom: 0.7rem; +} + +.detail-block { + padding: 0.9rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #ffffff 0%, #f6f8fa 100%); +} + +.detail-value { + margin-top: 0.3rem; + font-weight: 600; +} + +.empty-state { + border: 1px dashed var(--border-strong); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #ffffff 0%, #f6f8fa 100%); + padding: 1.25rem; +} + +.empty-icon { + width: 54px; + height: 54px; + margin-inline: auto; + border-radius: 50%; + background: #fff; + border: 1px solid var(--border); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; + color: var(--text-muted); +} + +.toast { + border-radius: 12px; + box-shadow: 0 18px 36px rgba(13, 17, 23, 0.16); +} + +.stretched-link-reset { + position: relative; + z-index: 2; +} + +.toggle-form, +.dropdown form { + position: relative; + z-index: 2; +} + ::-webkit-scrollbar { - width: 6px; + width: 10px; +} + +::-webkit-scrollbar-thumb { + background: #d4d4d8; + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; +@media (max-width: 1199.98px) { + .task-list { + max-height: none; + } } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); +@media (max-width: 767.98px) { + .panel, + .panel-hero { + padding: 1rem; + } + + .hero-title { + font-size: 1.7rem; + } + + .panel-header { + flex-direction: column; + align-items: stretch; + } + + .sort-select { + width: 100%; + } } -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +.detail-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 248, 250, 0.98) 100%); } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } +.navbar .btn-dark { + min-width: 132px; } -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; +.btn-outline-danger { + border-color: rgba(207, 34, 46, 0.28); + color: var(--danger); } -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; +.btn-outline-danger:hover, +.btn-outline-danger:focus { + background: var(--danger); + border-color: var(--danger); } - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} - -.header-container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; - gap: 1rem; -} - -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; - font-weight: 700; -} - -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; -} - -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; -} - -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; -} - -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; -} - -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); - padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); -} - -.history-table { - width: 100%; -} - -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; -} - -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; -} - -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; -} - -.no-messages { - text-align: center; - color: #777; -} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index d349598..d49a96f 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,39 +1,232 @@ document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const toastEl = document.getElementById('appToast'); + if (toastEl && window.bootstrap) { + const toast = new bootstrap.Toast(toastEl); + toast.show(); + } - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + document.querySelectorAll('.needs-validation').forEach((form) => { + form.addEventListener('submit', (event) => { + if (!form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } + form.classList.add('was-validated'); + }); + }); + + const activeItem = document.querySelector('.task-item-active'); + if (activeItem && window.innerWidth < 1200) { + activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + const taskList = document.querySelector('.task-list'); + const detailContent = document.getElementById('taskDetailContent'); + + if (!taskList || !detailContent) { + return; + } + + const detailPriorityBadge = document.getElementById('detailPriorityBadge'); + const detailStatusBadge = document.getElementById('detailStatusBadge'); + const detailTitle = document.getElementById('detailTitle'); + const detailDescription = document.getElementById('detailDescription'); + const detailCreated = document.getElementById('detailCreated'); + const detailUpdated = document.getElementById('detailUpdated'); + const detailCompleted = document.getElementById('detailCompleted'); + const detailEditLink = document.getElementById('detailEditLink'); + const detailToggleTaskId = document.getElementById('detailToggleTaskId'); + const detailDeleteTaskId = document.getElementById('detailDeleteTaskId'); + const detailToggleIcon = document.getElementById('detailToggleIcon'); + const detailToggleText = document.getElementById('detailToggleText'); + const taskEditorForm = document.getElementById('taskEditorForm'); + const taskFormAction = document.getElementById('taskFormAction'); + const taskFormTaskId = document.getElementById('taskFormTaskId'); + const taskFormSelectedTask = document.getElementById('taskFormSelectedTask'); + const taskFormTitle = document.getElementById('taskFormTitle'); + const taskFormEyebrow = document.getElementById('taskFormEyebrow'); + const taskFormCancelLink = document.getElementById('taskFormCancelLink'); + const taskTitleInput = document.getElementById('title'); + const taskDescriptionInput = document.getElementById('description'); + const taskPriorityInput = document.getElementById('priority'); + const isEditingMode = taskEditorForm?.dataset.formMode === 'update'; + + const syncActiveState = (selectedItem) => { + document.querySelectorAll('.task-item').forEach((item) => { + const isActive = item === selectedItem; + item.classList.toggle('task-item-active', isActive); + item.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const syncEditForm = (taskItem) => { + if (!isEditingMode || !taskItem) { + return; + } - appendMessage(message, 'visitor'); - chatInput.value = ''; + if (taskFormAction) { + taskFormAction.value = 'update'; + } - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + if (taskFormTaskId) { + taskFormTaskId.value = taskItem.dataset.taskId || ''; + } + + if (taskFormSelectedTask) { + taskFormSelectedTask.value = taskItem.dataset.taskId || ''; + } + + if (taskTitleInput) { + taskTitleInput.value = taskItem.dataset.taskTitle || ''; + } + + if (taskDescriptionInput) { + taskDescriptionInput.value = taskItem.dataset.taskDescriptionRaw || ''; + } + + if (taskPriorityInput) { + taskPriorityInput.value = taskItem.dataset.taskPriorityValue || 'medium'; + } + + if (taskFormTitle) { + taskFormTitle.textContent = 'Обновить задачу'; + } + + if (taskFormEyebrow) { + taskFormEyebrow.textContent = 'Редактирование'; + } + + if (taskFormCancelLink) { + const cancelUrl = new URL(taskItem.dataset.taskEditUrl || 'index.php', window.location.origin); + cancelUrl.searchParams.delete('edit'); + taskFormCancelLink.href = `${cancelUrl.pathname}${cancelUrl.search}${cancelUrl.hash}`; + } + }; + + const updateHistory = (taskId) => { + const url = new URL(window.location.href); + url.searchParams.set('task', taskId); + + if (isEditingMode) { + url.searchParams.set('edit', taskId); + } else { + url.searchParams.delete('edit'); + } + + history.replaceState({ taskId }, '', url); + }; + + const selectTask = (taskItem, shouldSyncHistory = true) => { + if (!taskItem) { + return; + } + + syncActiveState(taskItem); + detailContent.dataset.selectedTaskId = taskItem.dataset.taskId || ''; + + if (detailPriorityBadge) { + detailPriorityBadge.className = `priority-badge ${taskItem.dataset.taskPriorityClass || ''}`.trim(); + detailPriorityBadge.textContent = taskItem.dataset.taskPriorityLabel || ''; + } + + if (detailStatusBadge) { + detailStatusBadge.className = `status-badge ${taskItem.dataset.taskStatusClass || ''}`.trim(); + detailStatusBadge.textContent = taskItem.dataset.taskStatusLabel || ''; + } + + if (detailTitle) { + detailTitle.textContent = taskItem.dataset.taskTitle || ''; + } + + if (detailDescription) { + detailDescription.textContent = taskItem.dataset.taskDescription || ''; + } + + if (detailCreated) { + detailCreated.textContent = taskItem.dataset.taskCreatedLabel || ''; + } + + if (detailUpdated) { + detailUpdated.textContent = taskItem.dataset.taskUpdatedLabel || ''; + } + + if (detailCompleted) { + detailCompleted.textContent = taskItem.dataset.taskCompletedLabel || ''; + } + + if (detailEditLink) { + detailEditLink.href = taskItem.dataset.taskEditUrl || 'index.php'; + } + + if (detailToggleTaskId) { + detailToggleTaskId.value = taskItem.dataset.taskId || ''; + } + + if (detailDeleteTaskId) { + detailDeleteTaskId.value = taskItem.dataset.taskId || ''; + } + + if (detailToggleIcon) { + detailToggleIcon.className = `bi ${taskItem.dataset.taskToggleIcon || 'bi-check2'} me-2`; + } + + if (detailToggleText) { + detailToggleText.textContent = taskItem.dataset.taskToggleLabel || 'Отметить выполненной'; + } + + syncEditForm(taskItem); + + if (shouldSyncHistory && taskItem.dataset.taskId) { + updateHistory(taskItem.dataset.taskId); + } + }; + + taskList.addEventListener('click', (event) => { + const taskItem = event.target.closest('.task-item'); + if (!taskItem) { + return; + } + + if (event.target.closest('form') || event.target.closest('[data-bs-toggle="dropdown"]') || event.target.closest('.dropdown-item-edit')) { + return; + } + + if (event.target.closest('.dropdown-menu') && !event.target.closest('.task-open-link')) { + return; + } + + const clickedLink = event.target.closest('a'); + if (clickedLink && !event.target.closest('.task-open-link')) { + return; + } + + event.preventDefault(); + selectTask(taskItem); + }); + + taskList.addEventListener('keydown', (event) => { + const taskItem = event.target.closest('.task-item'); + if (!taskItem || event.target !== taskItem) { + return; + } + + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + selectTask(taskItem); + }); + + window.addEventListener('popstate', () => { + const taskId = new URL(window.location.href).searchParams.get('task'); + if (!taskId) { + return; + } + + const matchingItem = document.querySelector(`.task-item[data-task-id="${CSS.escape(taskId)}"]`); + if (matchingItem) { + selectTask(matchingItem, false); } }); }); diff --git a/cookies.txt b/cookies.txt new file mode 100644 index 0000000..1662aca --- /dev/null +++ b/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +127.0.0.1 FALSE / FALSE 0 PHPSESSID 8euaff5qvrd5g7nf2qa3vr4rbd diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..9450059 --- /dev/null +++ b/healthz.php @@ -0,0 +1,14 @@ +query('SELECT 1'); + http_response_code(200); + echo "OK\n"; +} catch (Throwable $e) { + http_response_code(500); + echo "DB_ERROR\n"; +} diff --git a/index.php b/index.php index 7205f3d..71a275d 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,696 @@ $value) { + if ($value === null || $value === '') { + continue; + } + $filtered[$key] = $value; + } + + return $filtered ? ('?' . http_build_query($filtered)) : ''; +} + +function redirectWithState(array $params): void +{ + header('Location: index.php' . buildQuery($params)); + exit; +} + +function setFlash(string $type, string $message): void +{ + $_SESSION['flash'] = ['type' => $type, 'message' => $message]; +} + +function getFlash(): ?array +{ + if (!isset($_SESSION['flash'])) { + return null; + } + + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return $flash; +} + +function ensureTodoTable(PDO $pdo): void +{ + $pdo->exec( + "CREATE TABLE IF NOT EXISTS todos ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(140) NOT NULL, + description TEXT NULL, + priority ENUM('low', 'medium', 'high') NOT NULL DEFAULT 'medium', + is_completed TINYINT(1) NOT NULL DEFAULT 0, + completed_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_completed (is_completed), + INDEX idx_priority (priority), + INDEX idx_created_at (created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + $count = (int) $pdo->query('SELECT COUNT(*) FROM todos')->fetchColumn(); + if ($count === 0) { + $seed = $pdo->prepare( + 'INSERT INTO todos (title, description, priority, is_completed, completed_at) VALUES (:title, :description, :priority, :is_completed, :completed_at)' + ); + + $demoTasks = [ + [ + 'title' => 'Подготовить список задач на неделю', + 'description' => 'Собрать важные личные и рабочие дела, чтобы всё было в одном месте.', + 'priority' => 'high', + 'is_completed' => 0, + 'completed_at' => null, + ], + [ + 'title' => 'Разобрать входящие письма', + 'description' => 'Оставить только действительно нужные письма и отметить follow-up.', + 'priority' => 'medium', + 'is_completed' => 0, + 'completed_at' => null, + ], + [ + 'title' => 'Забронировать тренировку', + 'description' => 'Выбрать время на этой неделе и подтвердить занятие.', + 'priority' => 'low', + 'is_completed' => 1, + 'completed_at' => date('Y-m-d H:i:s', strtotime('-1 day')), + ], + ]; + + foreach ($demoTasks as $task) { + $seed->execute([ + ':title' => $task['title'], + ':description' => $task['description'], + ':priority' => $task['priority'], + ':is_completed' => $task['is_completed'], + ':completed_at' => $task['completed_at'], + ]); + } + } +} + +$pdo = db(); +ensureTodoTable($pdo); + +if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +} +$csrfToken = $_SESSION['csrf_token']; + +$status = $_GET['status'] ?? 'all'; +$status = in_array($status, ['all', 'active', 'completed'], true) ? $status : 'all'; +$sort = $_GET['sort'] ?? 'newest'; +$sort = in_array($sort, ['newest', 'oldest', 'priority'], true) ? $sort : 'newest'; +$q = trim((string) ($_GET['q'] ?? '')); +$selectedTaskId = filter_input(INPUT_GET, 'task', FILTER_VALIDATE_INT) ?: null; +$editTaskId = filter_input(INPUT_GET, 'edit', FILTER_VALIDATE_INT) ?: null; +$baseState = [ + 'status' => $status, + 'sort' => $sort, + 'q' => $q, +]; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $postedToken = $_POST['csrf_token'] ?? ''; + $postStatus = $_POST['status'] ?? 'all'; + $postStatus = in_array($postStatus, ['all', 'active', 'completed'], true) ? $postStatus : 'all'; + $postSort = $_POST['sort'] ?? 'newest'; + $postSort = in_array($postSort, ['newest', 'oldest', 'priority'], true) ? $postSort : 'newest'; + $postQ = trim((string) ($_POST['q'] ?? '')); + $returnTask = filter_input(INPUT_POST, 'task', FILTER_VALIDATE_INT) ?: null; + $redirectState = ['status' => $postStatus, 'sort' => $postSort, 'q' => $postQ]; + if ($returnTask) { + $redirectState['task'] = $returnTask; + } + + if (!hash_equals($csrfToken, $postedToken)) { + setFlash('danger', 'Сессия устарела. Попробуйте ещё раз.'); + redirectWithState($redirectState); + } + + $action = $_POST['action'] ?? ''; + + try { + if ($action === 'create') { + $title = trim((string) ($_POST['title'] ?? '')); + $description = trim((string) ($_POST['description'] ?? '')); + $priority = $_POST['priority'] ?? 'medium'; + $priority = in_array($priority, ['low', 'medium', 'high'], true) ? $priority : 'medium'; + + if ($title === '' || utf8Length($title) > 140) { + throw new RuntimeException('Название задачи обязательно и должно быть короче 140 символов.'); + } + + if (utf8Length($description) > 1000) { + throw new RuntimeException('Описание должно быть короче 1000 символов.'); + } + + $stmt = $pdo->prepare('INSERT INTO todos (title, description, priority) VALUES (:title, :description, :priority)'); + $stmt->bindValue(':title', $title); + $stmt->bindValue(':description', $description !== '' ? $description : null, $description !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL); + $stmt->bindValue(':priority', $priority); + $stmt->execute(); + + $newId = (int) $pdo->lastInsertId(); + setFlash('success', 'Задача добавлена.'); + $redirectState['task'] = $newId; + redirectWithState($redirectState); + } + + if ($action === 'update') { + $taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT); + $title = trim((string) ($_POST['title'] ?? '')); + $description = trim((string) ($_POST['description'] ?? '')); + $priority = $_POST['priority'] ?? 'medium'; + $priority = in_array($priority, ['low', 'medium', 'high'], true) ? $priority : 'medium'; + + if (!$taskId) { + throw new RuntimeException('Не удалось определить задачу для редактирования.'); + } + if ($title === '' || utf8Length($title) > 140) { + throw new RuntimeException('Название задачи обязательно и должно быть короче 140 символов.'); + } + if (utf8Length($description) > 1000) { + throw new RuntimeException('Описание должно быть короче 1000 символов.'); + } + + $stmt = $pdo->prepare('UPDATE todos SET title = :title, description = :description, priority = :priority WHERE id = :id LIMIT 1'); + $stmt->bindValue(':title', $title); + $stmt->bindValue(':description', $description !== '' ? $description : null, $description !== '' ? PDO::PARAM_STR : PDO::PARAM_NULL); + $stmt->bindValue(':priority', $priority); + $stmt->bindValue(':id', $taskId, PDO::PARAM_INT); + $stmt->execute(); + + setFlash('success', 'Изменения сохранены.'); + $redirectState['task'] = $taskId; + redirectWithState($redirectState); + } + + if ($action === 'toggle') { + $taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT); + if (!$taskId) { + throw new RuntimeException('Не удалось определить задачу.'); + } + + $stmt = $pdo->prepare('SELECT is_completed FROM todos WHERE id = :id LIMIT 1'); + $stmt->execute([':id' => $taskId]); + $task = $stmt->fetch(); + if (!$task) { + throw new RuntimeException('Задача не найдена.'); + } + + $nextState = (int) !$task['is_completed']; + $update = $pdo->prepare('UPDATE todos SET is_completed = :is_completed, completed_at = :completed_at WHERE id = :id LIMIT 1'); + $update->bindValue(':is_completed', $nextState, PDO::PARAM_INT); + $update->bindValue(':completed_at', $nextState ? date('Y-m-d H:i:s') : null, $nextState ? PDO::PARAM_STR : PDO::PARAM_NULL); + $update->bindValue(':id', $taskId, PDO::PARAM_INT); + $update->execute(); + + setFlash('success', $nextState ? 'Задача отмечена как выполненная.' : 'Задача снова активна.'); + $redirectState['task'] = $taskId; + redirectWithState($redirectState); + } + + if ($action === 'delete') { + $taskId = filter_input(INPUT_POST, 'task_id', FILTER_VALIDATE_INT); + if (!$taskId) { + throw new RuntimeException('Не удалось определить задачу.'); + } + + $stmt = $pdo->prepare('DELETE FROM todos WHERE id = :id LIMIT 1'); + $stmt->bindValue(':id', $taskId, PDO::PARAM_INT); + $stmt->execute(); + + setFlash('success', 'Задача удалена.'); + unset($redirectState['task']); + redirectWithState($redirectState); + } + + setFlash('warning', 'Неизвестное действие.'); + redirectWithState($redirectState); + } catch (Throwable $exception) { + setFlash('danger', $exception->getMessage()); + redirectWithState($redirectState); + } +} + +$where = []; +$params = []; +if ($status === 'active') { + $where[] = 'is_completed = 0'; +} elseif ($status === 'completed') { + $where[] = 'is_completed = 1'; +} +if ($q !== '') { + $where[] = '(title LIKE :search OR description LIKE :search)'; + $params[':search'] = '%' . $q . '%'; +} +$whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; +$orderSql = match ($sort) { + 'oldest' => 'ORDER BY created_at ASC, id ASC', + 'priority' => "ORDER BY CASE priority WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END ASC, is_completed ASC, created_at DESC", + default => 'ORDER BY created_at DESC, id DESC', +}; + +$listStmt = $pdo->prepare("SELECT id, title, description, priority, is_completed, completed_at, created_at, updated_at FROM todos {$whereSql} {$orderSql}"); +foreach ($params as $key => $value) { + $listStmt->bindValue($key, $value); +} +$listStmt->execute(); +$tasks = $listStmt->fetchAll(); + +$stats = $pdo->query( + "SELECT + COUNT(*) AS total_count, + SUM(CASE WHEN is_completed = 0 THEN 1 ELSE 0 END) AS active_count, + SUM(CASE WHEN is_completed = 1 THEN 1 ELSE 0 END) AS completed_count, + SUM(CASE WHEN priority = 'high' AND is_completed = 0 THEN 1 ELSE 0 END) AS urgent_count + FROM todos" +)->fetch() ?: ['total_count' => 0, 'active_count' => 0, 'completed_count' => 0, 'urgent_count' => 0]; + +$selectedTask = null; +if ($selectedTaskId) { + $detailStmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id LIMIT 1'); + $detailStmt->execute([':id' => $selectedTaskId]); + $selectedTask = $detailStmt->fetch() ?: null; +} + +$editingTask = null; +if ($editTaskId) { + if ($selectedTask && (int) $selectedTask['id'] === $editTaskId) { + $editingTask = $selectedTask; + } else { + $editStmt = $pdo->prepare('SELECT * FROM todos WHERE id = :id LIMIT 1'); + $editStmt->execute([':id' => $editTaskId]); + $editingTask = $editStmt->fetch() ?: null; + } +} + +if (!$selectedTask && !empty($tasks)) { + $selectedTask = $tasks[0]; + $selectedTaskId = (int) $selectedTask['id']; +} + +$flash = getFlash(); +$projectName = trim((string) ($_SERVER['PROJECT_NAME'] ?? '')) ?: 'Task Ledger'; +$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Personal todo application with database persistence and a modern focused interface.'; +$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +$pageTitle = $projectName . ' — Personal Todo'; +$assetVersion = (string) @filemtime(__DIR__ . '/assets/css/custom.css'); ?> - + - - - New Style - + + + <?= h($pageTitle) ?> + - - - - - - + + - - - - + + - - - - + + + + + + + -
-
-

Analyzing your requirements and generating your website…

-
- Loading… -
-

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
-
- +
+ +
+ +
+
+
+
+ Всего задач + + Личная база задач +
+
+
+
+ Активные + + В работе сейчас +
+
+
+
+ Выполнены + + История прогресса +
+
+
+
+ Высокий приоритет + + Требуют внимания +
+
+
+ +
+
+
+
+
+
+

+
+ + Отмена + +
+ +
+ + + + + + + + + + +
+ + +
Короткое и понятное название задачи.
+
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+
+
+
+
Управление
+

Список задач

+
+
+
+ + +
+
+ 'Все', 'active' => 'Активные', 'completed' => 'Выполненные'] as $filterValue => $filterLabel): ?> + > + + +
+
+ +
+ + Сбросить +
+
+
+
+ + +
+
+

Ничего не найдено

+

Измените фильтры или добавьте первую задачу, чтобы начать список.

+ Создать задачу +
+ +
+ + $task['id']])); + $editLink = buildQuery(array_merge($baseState, ['task' => $task['id'], 'edit' => $task['id']])); + ?> +
+
+
+ + + + + + + +
+
+
+ + 'Высокий', + 'low' => 'Низкий', + default => 'Средний', + }) ?> +
+

+
+ + +
+
+ +
+
+ +
+ +
+
+ +
+
+
+
+
Детали
+

Карточка задачи

+
+
+ + + +
+
+
+ 'Высокий приоритет', + 'low' => 'Низкий приоритет', + default => 'Средний приоритет', + }) ?> + +
+

+

+
+ +
+
Создана
+
+
+
+
Последнее обновление
+
+
+
+
Завершение
+
+
+ +
+ + Редактировать + +
+ + + + + + + +
+
+ + + + + + + +
+
+
+ +
+
+

Выберите задачу

+

После выбора здесь появятся детали и быстрые действия.

+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+ + + +