39384-vm/index.php
Flatlogic Bot 1b842dbef6 first
2026-03-30 11:13:22 +00:00

697 lines
40 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
session_start();
@date_default_timezone_set('UTC');
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="ru">
<head>
<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 property="og:description" content="<?= h($projectDescription) ?>" />
<meta property="twitter:description" content="<?= h($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>" />
<?php endif; ?>
<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>
<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>