This commit is contained in:
Flatlogic Bot 2026-03-30 11:13:22 +00:00
parent 850fc607c8
commit 1b842dbef6
5 changed files with 1426 additions and 531 deletions

View File

@ -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;
}
.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;
.btn-outline-danger:hover,
.btn-outline-danger:focus {
background: var(--danger);
border-color: var(--danger);
}

View File

@ -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();
if (taskFormTaskId) {
taskFormTaskId.value = taskItem.dataset.taskId || '';
}
// 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 (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);
}
});
});

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
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
session_start();
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/db/config.php';
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>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= h($pageTitle) ?></title>
<meta name="description" content="<?= h($projectDescription) ?>" />
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<meta property="og:description" content="<?= h($projectDescription) ?>" />
<meta property="twitter:description" content="<?= h($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<meta property="og:image" content="<?= h($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--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>
<meta name="theme-color" content="#0d1117" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?= h($assetVersion) ?>">
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<header class="border-bottom app-header sticky-top">
<nav class="navbar navbar-expand-lg py-3">
<div class="container-xxl px-3 px-lg-4">
<a class="navbar-brand d-flex align-items-center gap-2" href="index.php">
<span class="brand-mark"><i class="bi bi-check2-square"></i></span>
<span>
<span class="brand-title d-block">Task Ledger</span>
<span class="brand-subtitle d-block">Personal todo workspace</span>
</span>
</a>
<div class="d-flex align-items-center gap-2 ms-auto">
<a href="#task-form" class="btn btn-dark btn-sm px-3">Новая задача</a>
</div>
</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>
</html>