Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
e8fb7a1400 2 2026-03-30 11:27:33 +00:00
Flatlogic Bot
1b842dbef6 first 2026-03-30 11:13:22 +00:00
5 changed files with 1429 additions and 531 deletions

View File

@ -1,403 +1,543 @@
body { :root {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); --bg: #f8f9fb;
background-size: 400% 400%; --surface: #ffffff;
animation: gradient 15s ease infinite; --surface-muted: #fbfcfd;
color: #212529; --surface-soft: #ffffff;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --border: #d8dee4;
font-size: 14px; --border-strong: #b8c0c8;
margin: 0; --text: #1f2328;
min-height: 100vh; --text-muted: #5f6b76;
--text-soft: #7a8591;
--accent: #1f883d;
--accent-dark: #176f32;
--accent-contrast: #ffffff;
--accent-blue: #0969da;
--accent-blue-soft: #f5f9ff;
--nav-bg: #ffffff;
--nav-text: #1f2328;
--success: #1a7f37;
--danger: #cf222e;
--shadow: 0 1px 2px rgba(31, 35, 40, 0.03);
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 12px;
} }
.main-wrapper { html {
display: flex; scroll-behavior: smooth;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
text-rendering: optimizeLegibility;
font-size: 0.96rem;
}
.app-header {
background: #ffffff;
backdrop-filter: none;
border-bottom-color: var(--border) !important;
}
.navbar {
min-height: 56px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-height: 100vh; background: #ffffff;
width: 100%; color: var(--text-muted);
padding: 20px; font-size: 0.95rem;
box-sizing: border-box; border: 1px solid var(--border);
position: relative; box-shadow: none;
z-index: 1;
} }
@keyframes gradient { .brand-title {
0% { color: var(--nav-text);
background-position: 0% 50%; font-size: 0.96rem;
} font-weight: 700;
50% { letter-spacing: -0.02em;
background-position: 100% 50%; line-height: 1.1;
}
100% {
background-position: 0% 50%;
}
} }
.chat-container { .brand-subtitle {
width: 100%; display: none !important;
max-width: 600px; }
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3); .panel {
border-radius: 20px; background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: 1rem;
}
.panel-hero {
padding: 1.5rem;
}
.eyebrow {
color: var(--text-soft);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.68rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.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.35rem;
padding: 0.34rem 0.62rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid var(--border);
background: var(--surface-muted);
color: var(--text);
}
.stat-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 85vh; justify-content: space-between;
box-shadow: 0 20px 40px rgba(0,0,0,0.2); min-height: 100%;
backdrop-filter: blur(15px); }
-webkit-backdrop-filter: blur(15px);
.stat-card {
position: relative;
overflow: hidden; overflow: hidden;
} }
.chat-header { .stat-card::before {
padding: 1.5rem; content: '';
border-bottom: 1px solid rgba(0, 0, 0, 0.05); position: absolute;
background: rgba(255, 255, 255, 0.5); inset: 0 auto auto 0;
font-weight: 700; width: 100%;
font-size: 1.1rem; height: 1px;
display: flex; background: var(--border-strong);
justify-content: space-between;
align-items: center;
} }
.chat-messages { .stat-card-total::before {
flex: 1; background: #8c959f;
overflow-y: auto; }
padding: 1.5rem;
.stat-card-active::before {
background: var(--accent-blue);
}
.stat-card-done::before {
background: var(--accent);
}
.stat-card-urgent::before {
background: #fb8500;
}
.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.8rem;
color: var(--text-soft);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.panel-header-stack {
align-items: stretch;
}
.panel-title {
font-size: 1.05rem;
font-weight: 650;
}
.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: 42px;
background: #fff;
}
.form-control:focus,
.form-select:focus {
border-color: var(--accent-blue);
box-shadow: 0 0 0 0.14rem rgba(9, 105, 218, 0.1);
}
.btn {
font-weight: 600;
box-shadow: none !important;
border-width: 1px;
}
.btn-dark {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-contrast);
}
.btn-dark:hover,
.btn-dark:focus {
background: var(--accent-dark);
border-color: var(--accent-dark);
color: var(--accent-contrast);
}
.btn-light {
background: #fff;
border-color: var(--border);
color: var(--text);
}
.btn-light:hover,
.btn-light:focus {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
.filters-grid {
display: grid;
gap: 0.8rem;
}
.btn-filter {
border: 1px solid var(--border);
background: #fff;
color: var(--text);
padding-inline: 0.9rem;
}
.btn-filter:hover {
border-color: var(--border-strong);
color: var(--text);
background: #ffffff;
}
.btn-check:checked + .btn-filter {
background: #ffffff;
color: var(--accent-blue);
border-color: #b7c7da;
}
.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: 0.9rem;
background: #fff;
cursor: pointer;
transition: border-color 0.16s ease, background-color 0.16s 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(--border-strong);
box-shadow: none;
background: #fff;
}
.task-item-active {
border-color: #b6c8db;
box-shadow: inset 2px 0 0 var(--accent-blue);
background: #ffffff;
}
.task-item-done {
background: #ffffff;
}
.check-button {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text-muted);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.check-button:hover {
border-color: var(--accent);
color: var(--accent);
background: #fff;
}
.task-title-link {
font-weight: 600;
color: var(--text);
text-decoration: none;
font-size: 0.96rem;
}
.task-title-link:hover {
color: var(--accent-blue);
}
.task-snippet,
.detail-description {
color: var(--text-muted);
line-height: 1.5;
}
.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: #ffffff;
color: #b42318;
border-color: #f0c7c7;
}
.priority-medium {
background: #ffffff;
color: #9a6700;
border-color: #ead9a2;
}
.priority-low {
background: #ffffff;
color: #1a7f37;
border-color: #c9e7cf;
}
.status-active {
background: #ffffff;
color: var(--accent-blue);
border-color: #c7d7ea;
}
.status-done {
background: #ffffff;
color: var(--success);
border-color: #c9e7cf;
}
.icon-button {
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.detail-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem;
} }
/* Custom Scrollbar */ .detail-stack {
height: 100%;
}
.detail-title {
font-size: 1.2rem;
margin-bottom: 0.55rem;
}
.detail-block {
padding: 0.8rem 0.9rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: #ffffff;
}
.detail-value {
margin-top: 0.25rem;
font-weight: 500;
}
.empty-state {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: #ffffff;
padding: 1rem;
}
.empty-icon {
width: 42px;
height: 42px;
margin-inline: auto;
border-radius: 10px;
background: #fff;
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: var(--text-muted);
}
.toast {
border-radius: 10px;
box-shadow: 0 6px 14px rgba(31, 35, 40, 0.08);
}
.stretched-link-reset {
position: relative;
z-index: 2;
}
.toggle-form,
.dropdown form {
position: relative;
z-index: 2;
}
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 8px;
}
::-webkit-scrollbar-thumb {
background: #d4d4d8;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { @media (max-width: 1199.98px) {
background: rgba(255, 255, 255, 0.3); .task-list {
border-radius: 10px; max-height: none;
}
} }
::-webkit-scrollbar-thumb:hover { @media (max-width: 767.98px) {
background: rgba(255, 255, 255, 0.5); .panel,
.panel-hero {
padding: 0.9rem;
}
.hero-title {
font-size: 1.7rem;
}
.panel-header {
flex-direction: column;
align-items: stretch;
}
.sort-select {
width: 100%;
}
} }
.message { .detail-panel {
max-width: 85%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 248, 250, 0.98) 100%);
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);
} }
@keyframes fadeIn { .navbar .btn-dark {
from { opacity: 0; transform: translateY(20px) scale(0.95); } min-width: 132px;
to { opacity: 1; transform: translateY(0) scale(1); }
} }
.message.visitor { .btn-outline-danger {
align-self: flex-end; border-color: rgba(207, 34, 46, 0.28);
background: linear-gradient(135deg, #212529 0%, #343a40 100%); color: var(--danger);
color: #fff;
border-bottom-right-radius: 4px;
} }
.message.bot { .btn-outline-danger:hover,
align-self: flex-start; .btn-outline-danger:focus {
background: #ffffff; background: var(--danger);
color: #212529; border-color: var(--danger);
border-bottom-left-radius: 4px;
} }
.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;
}

View File

@ -1,39 +1,232 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); const toastEl = document.getElementById('appToast');
const chatInput = document.getElementById('chat-input'); if (toastEl && window.bootstrap) {
const chatMessages = document.getElementById('chat-messages'); const toast = new bootstrap.Toast(toastEl);
toast.show();
}
const appendMessage = (text, sender) => { document.querySelectorAll('.needs-validation').forEach((form) => {
const msgDiv = document.createElement('div'); form.addEventListener('submit', (event) => {
msgDiv.classList.add('message', sender); if (!form.checkValidity()) {
msgDiv.textContent = text; event.preventDefault();
chatMessages.appendChild(msgDiv); event.stopPropagation();
chatMessages.scrollTop = chatMessages.scrollHeight; }
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) => { const syncEditForm = (taskItem) => {
e.preventDefault(); if (!isEditingMode || !taskItem) {
const message = chatInput.value.trim(); return;
if (!message) return; }
appendMessage(message, 'visitor'); if (taskFormAction) {
chatInput.value = ''; taskFormAction.value = 'update';
}
try { if (taskFormTaskId) {
const response = await fetch('api/chat.php', { taskFormTaskId.value = taskItem.dataset.taskId || '';
method: 'POST', }
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }) if (taskFormSelectedTask) {
}); taskFormSelectedTask.value = taskItem.dataset.taskId || '';
const data = await response.json(); }
// Artificial delay for realism if (taskTitleInput) {
setTimeout(() => { taskTitleInput.value = taskItem.dataset.taskTitle || '';
appendMessage(data.reply, 'bot'); }
}, 500);
} catch (error) { if (taskDescriptionInput) {
console.error('Error:', error); taskDescriptionInput.value = taskItem.dataset.taskDescriptionRaw || '';
appendMessage("Sorry, something went wrong. Please try again.", 'bot'); }
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);
} }
}); });
}); });

5
cookies.txt Normal file
View File

@ -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

14
healthz.php Normal file
View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
header('Content-Type: text/plain; charset=utf-8');
header('X-Robots-Tag: noindex, nofollow', true);
try {
require_once __DIR__ . '/db/config.php';
db()->query('SELECT 1');
http_response_code(200);
echo "OK\n";
} catch (Throwable $e) {
http_response_code(500);
echo "DB_ERROR\n";
}

816
index.php
View File

@ -1,150 +1,696 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1'); session_start();
@error_reporting(E_ALL);
@date_default_timezone_set('UTC'); @date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/db/config.php';
$now = date('Y-m-d H:i:s');
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function utf8Length(string $value): int
{
if (function_exists('mb_strlen')) {
return mb_strlen($value, 'UTF-8');
}
if ($value === '') {
return 0;
}
preg_match_all('/./us', $value, $matches);
return count($matches[0]);
}
function utf8Excerpt(string $value, int $limit): string
{
$value = trim($value);
if ($value === '' || $limit < 1) {
return $value;
}
if (utf8Length($value) <= $limit) {
return $value;
}
if (function_exists('mb_substr')) {
return rtrim(mb_substr($value, 0, max(1, $limit - 1), 'UTF-8')) . '…';
}
preg_match_all('/./us', $value, $matches);
return rtrim(implode('', array_slice($matches[0], 0, max(1, $limit - 1)))) . '…';
}
function buildQuery(array $params): string
{
$filtered = [];
foreach ($params as $key => $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');
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title><?= h($pageTitle) ?></title>
<?php <meta name="description" content="<?= h($projectDescription) ?>" />
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?> <?php if ($projectDescription): ?>
<!-- Meta description --> <meta property="og:description" content="<?= h($projectDescription) ?>" />
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' /> <meta property="twitter:description" content="<?= h($projectDescription) ?>" />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php endif; ?>
<?php if ($projectImageUrl): ?> <?php if ($projectImageUrl): ?>
<!-- Open Graph image --> <meta property="og:image" content="<?= h($projectImageUrl) ?>" />
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= h($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <meta name="theme-color" content="#ffffff" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<style> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
:root { <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
--bg-color-start: #6a11cb; <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
--bg-color-end: #2575fc; <link rel="stylesheet" href="assets/css/custom.css?v=<?= h($assetVersion) ?>">
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body>
<main> <header class="border-bottom app-header sticky-top">
<div class="card"> <nav class="navbar navbar-expand-lg py-3">
<h1>Analyzing your requirements and generating your website…</h1> <div class="container-xxl px-3 px-lg-4">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <a class="navbar-brand d-flex align-items-center gap-2" href="index.php">
<span class="sr-only">Loading…</span> <span class="brand-mark"><i class="bi bi-check2-square"></i></span>
</div> <span>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p> <span class="brand-title d-block">Task Ledger</span>
<p class="hint">This page will update automatically as the plan is implemented.</p> <span class="brand-subtitle d-block">Personal todo workspace</span>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p> </span>
</div> </a>
</main> <div class="d-flex align-items-center gap-2 ms-auto">
<footer> <a href="#task-form" class="btn btn-dark btn-sm px-3">Новая задача</a>
Page updated: <?= htmlspecialchars($now) ?> (UTC) </div>
</footer> </div>
</nav>
</header>
<main class="container-xxl px-3 px-lg-4 py-4 py-lg-5">
<section class="row g-3 g-lg-4 align-items-stretch mb-4">
<div class="col-12 col-sm-6 col-xl-3">
<div class="panel stat-card stat-card-total h-100">
<span class="stat-label">Всего задач</span>
<strong class="stat-value"><?= h((string) ($stats['total_count'] ?? 0)) ?></strong>
<span class="stat-note">Личная база задач</span>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="panel stat-card stat-card-active h-100">
<span class="stat-label">Активные</span>
<strong class="stat-value"><?= h((string) ($stats['active_count'] ?? 0)) ?></strong>
<span class="stat-note">В работе сейчас</span>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="panel stat-card stat-card-done h-100">
<span class="stat-label">Выполнены</span>
<strong class="stat-value"><?= h((string) ($stats['completed_count'] ?? 0)) ?></strong>
<span class="stat-note">История прогресса</span>
</div>
</div>
<div class="col-12 col-sm-6 col-xl-3">
<div class="panel stat-card stat-card-urgent h-100">
<span class="stat-label">Высокий приоритет</span>
<strong class="stat-value"><?= h((string) ($stats['urgent_count'] ?? 0)) ?></strong>
<span class="stat-note">Требуют внимания</span>
</div>
</div>
</section>
<section class="row g-3 g-lg-4">
<div class="col-12 col-xl-4">
<div class="panel h-100" id="task-form">
<div class="panel-header">
<div>
<div class="eyebrow" id="taskFormEyebrow"><?= $editingTask ? 'Редактирование' : 'Новая задача' ?></div>
<h2 class="panel-title mb-0" id="taskFormTitle"><?= $editingTask ? 'Обновить задачу' : 'Быстрое добавление' ?></h2>
</div>
<?php if ($editingTask): ?>
<a href="index.php<?= h(buildQuery(array_merge($baseState, ['task' => $editingTask['id']]))) ?>" class="btn btn-light btn-sm" id="taskFormCancelLink">Отмена</a>
<?php endif; ?>
</div>
<form method="post" class="vstack gap-3 needs-validation" novalidate id="taskEditorForm" data-form-mode="<?= $editingTask ? 'update' : 'create' ?>">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<input type="hidden" name="action" value="<?= $editingTask ? 'update' : 'create' ?>" id="taskFormAction">
<input type="hidden" name="status" value="<?= h($status) ?>">
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<?php if ($editingTask): ?>
<input type="hidden" name="task_id" value="<?= h((string) $editingTask['id']) ?>" id="taskFormTaskId">
<input type="hidden" name="task" value="<?= h((string) $editingTask['id']) ?>" id="taskFormSelectedTask">
<?php endif; ?>
<div>
<label for="title" class="form-label">Название</label>
<input id="title" name="title" type="text" class="form-control form-control-lg" maxlength="140" required value="<?= h($editingTask['title'] ?? '') ?>" placeholder="Например, подготовить отчёт">
<div class="form-text">Короткое и понятное название задачи.</div>
</div>
<div>
<label for="description" class="form-label">Описание</label>
<textarea id="description" name="description" rows="5" class="form-control" maxlength="1000" placeholder="Контекст, заметки или следующий шаг"><?= h($editingTask['description'] ?? '') ?></textarea>
</div>
<div>
<label for="priority" class="form-label">Приоритет</label>
<select id="priority" name="priority" class="form-select">
<?php foreach (['high' => 'Высокий', 'medium' => 'Средний', 'low' => 'Низкий'] as $value => $label): ?>
<option value="<?= h($value) ?>" <?= (($editingTask['priority'] ?? 'medium') === $value) ? 'selected' : '' ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-dark btn-lg w-100">
<i class="bi <?= $editingTask ? 'bi-floppy' : 'bi-plus-lg' ?> me-2"></i>
<?= $editingTask ? 'Сохранить изменения' : 'Добавить задачу' ?>
</button>
</form>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="panel h-100">
<div class="panel-header panel-header-stack gap-3">
<div>
<div class="eyebrow">Управление</div>
<h2 class="panel-title mb-0">Список задач</h2>
</div>
<form method="get" class="filters-grid" autocomplete="off">
<div class="search-field input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" name="q" class="form-control" value="<?= h($q) ?>" placeholder="Поиск по задачам">
</div>
<div class="d-flex gap-2 flex-wrap">
<?php foreach (['all' => 'Все', 'active' => 'Активные', 'completed' => 'Выполненные'] as $filterValue => $filterLabel): ?>
<input type="radio" class="btn-check" name="status" id="status-<?= h($filterValue) ?>" value="<?= h($filterValue) ?>" <?= $status === $filterValue ? 'checked' : '' ?>>
<label class="btn btn-filter" for="status-<?= h($filterValue) ?>"><?= h($filterLabel) ?></label>
<?php endforeach; ?>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center justify-content-between">
<select class="form-select form-select-sm sort-select" name="sort" aria-label="Сортировка">
<option value="newest" <?= $sort === 'newest' ? 'selected' : '' ?>>Сначала новые</option>
<option value="oldest" <?= $sort === 'oldest' ? 'selected' : '' ?>>Сначала старые</option>
<option value="priority" <?= $sort === 'priority' ? 'selected' : '' ?>>По приоритету</option>
</select>
<div class="d-flex gap-2">
<button class="btn btn-dark btn-sm px-3" type="submit">Применить</button>
<a href="index.php" class="btn btn-light btn-sm px-3">Сбросить</a>
</div>
</div>
</form>
</div>
<?php if (empty($tasks)): ?>
<div class="empty-state text-center py-5">
<div class="empty-icon mb-3"><i class="bi bi-inbox"></i></div>
<h3 class="h5 mb-2">Ничего не найдено</h3>
<p class="text-secondary mb-3">Измените фильтры или добавьте первую задачу, чтобы начать список.</p>
<a href="#task-form" class="btn btn-dark">Создать задачу</a>
</div>
<?php else: ?>
<div class="task-list d-grid gap-2">
<?php foreach ($tasks as $task): ?>
<?php
$isDone = (int) $task['is_completed'] === 1;
$priorityClass = 'priority-' . $task['priority'];
$taskLink = buildQuery(array_merge($baseState, ['task' => $task['id']]));
$editLink = buildQuery(array_merge($baseState, ['task' => $task['id'], 'edit' => $task['id']]));
?>
<article
class="task-item <?= $selectedTask && (int) $selectedTask['id'] === (int) $task['id'] ? 'task-item-active' : '' ?> <?= $isDone ? 'task-item-done' : '' ?>"
data-task-id="<?= h((string) $task['id']) ?>"
data-task-title="<?= h($task['title']) ?>"
data-task-description="<?= h($task['description'] ?: 'Описание не добавлено. Используйте это поле для контекста, критериев готовности или коротких заметок.') ?>"
data-task-description-raw="<?= h((string) ($task['description'] ?? '')) ?>"
data-task-priority-value="<?= h($task['priority']) ?>"
data-task-priority-class="<?= h($priorityClass) ?>"
data-task-priority-label="<?= h(match ($task['priority']) {
'high' => 'Высокий приоритет',
'low' => 'Низкий приоритет',
default => 'Средний приоритет',
}) ?>"
data-task-status-class="<?= $isDone ? 'status-done' : 'status-active' ?>"
data-task-status-label="<?= $isDone ? 'Выполнена' : 'Активна' ?>"
data-task-created-label="<?= h(date('d.m.Y H:i', strtotime($task['created_at']))) ?>"
data-task-updated-label="<?= h(date('d.m.Y H:i', strtotime($task['updated_at']))) ?>"
data-task-completed-label="<?= h($task['completed_at'] ? date('d.m.Y H:i', strtotime($task['completed_at'])) : 'Ещё в работе') ?>"
data-task-edit-url="index.php<?= h($editLink) ?>"
data-task-toggle-label="<?= $isDone ? 'Вернуть в активные' : 'Отметить выполненной' ?>"
data-task-toggle-icon="<?= $isDone ? 'bi-arrow-counterclockwise' : 'bi-check2' ?>"
tabindex="0"
role="button"
aria-pressed="<?= $selectedTask && (int) $selectedTask['id'] === (int) $task['id'] ? 'true' : 'false' ?>"
>
<div class="d-flex gap-3 align-items-start">
<form method="post" class="toggle-form m-0">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="task_id" value="<?= h((string) $task['id']) ?>">
<input type="hidden" name="status" value="<?= h($status) ?>">
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<button type="submit" class="check-button" aria-label="<?= $isDone ? 'Сделать активной' : 'Отметить выполненной' ?>">
<i class="bi <?= $isDone ? 'bi-check-circle-fill' : 'bi-circle' ?>"></i>
</button>
</form>
<div class="flex-grow-1 min-w-0">
<div class="d-flex flex-wrap gap-2 align-items-center mb-1">
<a href="index.php<?= h($taskLink) ?>" class="task-title-link stretched-link-reset task-open-link" data-task-open="true"><?= h($task['title']) ?></a>
<span class="priority-badge <?= h($priorityClass) ?>"><?= h(match ($task['priority']) {
'high' => 'Высокий',
'low' => 'Низкий',
default => 'Средний',
}) ?></span>
</div>
<p class="task-snippet mb-2"><?= h($task['description'] !== null && $task['description'] !== '' ? utf8Excerpt($task['description'], 120) : 'Без дополнительного описания.') ?></p>
<div class="task-meta d-flex flex-wrap gap-3">
<span><i class="bi bi-calendar3"></i> <?= h(date('d M Y, H:i', strtotime($task['created_at']))) ?></span>
<span><i class="bi bi-activity"></i> <?= $isDone ? 'Выполнено' : 'Активно' ?></span>
</div>
</div>
<div class="dropdown">
<button class="btn btn-light btn-sm icon-button" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm border-0">
<li><a class="dropdown-item task-open-link" data-task-open="true" href="index.php<?= h($taskLink) ?>"><i class="bi bi-eye me-2"></i>Открыть</a></li>
<li><a class="dropdown-item dropdown-item-edit" href="index.php<?= h($editLink) ?>"><i class="bi bi-pencil me-2"></i>Редактировать</a></li>
<li>
<form method="post" onsubmit="return confirm('Удалить задачу?');">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="task_id" value="<?= h((string) $task['id']) ?>">
<input type="hidden" name="status" value="<?= h($status) ?>">
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<button type="submit" class="dropdown-item text-danger"><i class="bi bi-trash me-2"></i>Удалить</button>
</form>
</li>
</ul>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
<div class="col-12 col-xl-3">
<div class="panel h-100 detail-panel">
<div class="panel-header">
<div>
<div class="eyebrow">Детали</div>
<h2 class="panel-title mb-0">Карточка задачи</h2>
</div>
</div>
<?php if ($selectedTask): ?>
<?php $selectedDone = (int) $selectedTask['is_completed'] === 1; ?>
<div class="detail-stack d-grid gap-3" id="taskDetailContent" data-selected-task-id="<?= h((string) $selectedTask['id']) ?>">
<div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="priority-badge priority-<?= h($selectedTask['priority']) ?>" id="detailPriorityBadge"><?= h(match ($selectedTask['priority']) {
'high' => 'Высокий приоритет',
'low' => 'Низкий приоритет',
default => 'Средний приоритет',
}) ?></span>
<span class="status-badge <?= $selectedDone ? 'status-done' : 'status-active' ?>" id="detailStatusBadge"><?= $selectedDone ? 'Выполнена' : 'Активна' ?></span>
</div>
<h3 class="detail-title" id="detailTitle"><?= h($selectedTask['title']) ?></h3>
<p class="detail-description" id="detailDescription"><?= h($selectedTask['description'] ?: 'Описание не добавлено. Используйте это поле для контекста, критериев готовности или коротких заметок.') ?></p>
</div>
<div class="detail-block">
<div class="detail-label">Создана</div>
<div class="detail-value" id="detailCreated"><?= h(date('d.m.Y H:i', strtotime($selectedTask['created_at']))) ?></div>
</div>
<div class="detail-block">
<div class="detail-label">Последнее обновление</div>
<div class="detail-value" id="detailUpdated"><?= h(date('d.m.Y H:i', strtotime($selectedTask['updated_at']))) ?></div>
</div>
<div class="detail-block">
<div class="detail-label">Завершение</div>
<div class="detail-value" id="detailCompleted"><?= $selectedTask['completed_at'] ? h(date('d.m.Y H:i', strtotime($selectedTask['completed_at']))) : 'Ещё в работе' ?></div>
</div>
<div class="d-grid gap-2 mt-2">
<a href="index.php<?= h(buildQuery(array_merge($baseState, ['task' => $selectedTask['id'], 'edit' => $selectedTask['id']]))) ?>" class="btn btn-dark" id="detailEditLink">
<i class="bi bi-pencil-square me-2"></i>Редактировать
</a>
<form method="post" id="detailToggleForm">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="task_id" value="<?= h((string) $selectedTask['id']) ?>" id="detailToggleTaskId">
<input type="hidden" name="status" value="<?= h($status) ?>">
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<button type="submit" class="btn btn-light w-100" id="detailToggleButton">
<i class="bi <?= $selectedDone ? 'bi-arrow-counterclockwise' : 'bi-check2' ?> me-2" id="detailToggleIcon"></i>
<span id="detailToggleText"><?= $selectedDone ? 'Вернуть в активные' : 'Отметить выполненной' ?></span>
</button>
</form>
<form method="post" onsubmit="return confirm('Удалить задачу?');" id="detailDeleteForm">
<input type="hidden" name="csrf_token" value="<?= h($csrfToken) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="task_id" value="<?= h((string) $selectedTask['id']) ?>" id="detailDeleteTaskId">
<input type="hidden" name="status" value="<?= h($status) ?>">
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="q" value="<?= h($q) ?>">
<button type="submit" class="btn btn-outline-danger w-100">
<i class="bi bi-trash me-2"></i>Удалить задачу
</button>
</form>
</div>
</div>
<?php else: ?>
<div class="empty-state text-center py-5 my-auto">
<div class="empty-icon mb-3"><i class="bi bi-journal-text"></i></div>
<h3 class="h5 mb-2">Выберите задачу</h3>
<p class="text-secondary mb-0">После выбора здесь появятся детали и быстрые действия.</p>
</div>
<?php endif; ?>
</div>
</div>
</section>
</main>
<?php if ($flash): ?>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<div id="appToast" class="toast align-items-center text-bg-<?= h($flash['type'] === 'danger' ? 'danger' : ($flash['type'] === 'warning' ? 'warning' : 'dark')) ?> border-0" role="status" aria-live="polite" aria-atomic="true" data-bs-delay="2800">
<div class="d-flex">
<div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/js/main.js?v=<?= h((string) @filemtime(__DIR__ . '/assets/js/main.js')) ?>"></script>
</body> </body>
</html> </html>