697 lines
40 KiB
PHP
697 lines
40 KiB
PHP
<?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>
|