first
This commit is contained in:
parent
850fc607c8
commit
1b842dbef6
@ -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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.message.visitor {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
@media (max-width: 767.98px) {
|
||||
.panel,
|
||||
.panel-hero {
|
||||
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;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.table tr td:first-child { border-radius: 12px 0 0 12px; }
|
||||
.table tr td:last-child { border-radius: 0 12px 12px 0; }
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
.sort-select {
|
||||
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);
|
||||
.detail-panel {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 248, 250, 0.98) 100%);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.navbar .btn-dark {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.header-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
.btn-outline-danger {
|
||||
border-color: rgba(207, 34, 46, 0.28);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@ -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 })
|
||||
if (taskFormTaskId) {
|
||||
taskFormTaskId.value = taskItem.dataset.taskId || '';
|
||||
}
|
||||
|
||||
if (taskFormSelectedTask) {
|
||||
taskFormSelectedTask.value = taskItem.dataset.taskId || '';
|
||||
}
|
||||
|
||||
if (taskTitleInput) {
|
||||
taskTitleInput.value = taskItem.dataset.taskTitle || '';
|
||||
}
|
||||
|
||||
if (taskDescriptionInput) {
|
||||
taskDescriptionInput.value = taskItem.dataset.taskDescriptionRaw || '';
|
||||
}
|
||||
|
||||
if (taskPriorityInput) {
|
||||
taskPriorityInput.value = taskItem.dataset.taskPriorityValue || 'medium';
|
||||
}
|
||||
|
||||
if (taskFormTitle) {
|
||||
taskFormTitle.textContent = 'Обновить задачу';
|
||||
}
|
||||
|
||||
if (taskFormEyebrow) {
|
||||
taskFormEyebrow.textContent = 'Редактирование';
|
||||
}
|
||||
|
||||
if (taskFormCancelLink) {
|
||||
const cancelUrl = new URL(taskItem.dataset.taskEditUrl || 'index.php', window.location.origin);
|
||||
cancelUrl.searchParams.delete('edit');
|
||||
taskFormCancelLink.href = `${cancelUrl.pathname}${cancelUrl.search}${cancelUrl.hash}`;
|
||||
}
|
||||
};
|
||||
|
||||
const updateHistory = (taskId) => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('task', taskId);
|
||||
|
||||
if (isEditingMode) {
|
||||
url.searchParams.set('edit', taskId);
|
||||
} else {
|
||||
url.searchParams.delete('edit');
|
||||
}
|
||||
|
||||
history.replaceState({ taskId }, '', url);
|
||||
};
|
||||
|
||||
const selectTask = (taskItem, shouldSyncHistory = true) => {
|
||||
if (!taskItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncActiveState(taskItem);
|
||||
detailContent.dataset.selectedTaskId = taskItem.dataset.taskId || '';
|
||||
|
||||
if (detailPriorityBadge) {
|
||||
detailPriorityBadge.className = `priority-badge ${taskItem.dataset.taskPriorityClass || ''}`.trim();
|
||||
detailPriorityBadge.textContent = taskItem.dataset.taskPriorityLabel || '';
|
||||
}
|
||||
|
||||
if (detailStatusBadge) {
|
||||
detailStatusBadge.className = `status-badge ${taskItem.dataset.taskStatusClass || ''}`.trim();
|
||||
detailStatusBadge.textContent = taskItem.dataset.taskStatusLabel || '';
|
||||
}
|
||||
|
||||
if (detailTitle) {
|
||||
detailTitle.textContent = taskItem.dataset.taskTitle || '';
|
||||
}
|
||||
|
||||
if (detailDescription) {
|
||||
detailDescription.textContent = taskItem.dataset.taskDescription || '';
|
||||
}
|
||||
|
||||
if (detailCreated) {
|
||||
detailCreated.textContent = taskItem.dataset.taskCreatedLabel || '';
|
||||
}
|
||||
|
||||
if (detailUpdated) {
|
||||
detailUpdated.textContent = taskItem.dataset.taskUpdatedLabel || '';
|
||||
}
|
||||
|
||||
if (detailCompleted) {
|
||||
detailCompleted.textContent = taskItem.dataset.taskCompletedLabel || '';
|
||||
}
|
||||
|
||||
if (detailEditLink) {
|
||||
detailEditLink.href = taskItem.dataset.taskEditUrl || 'index.php';
|
||||
}
|
||||
|
||||
if (detailToggleTaskId) {
|
||||
detailToggleTaskId.value = taskItem.dataset.taskId || '';
|
||||
}
|
||||
|
||||
if (detailDeleteTaskId) {
|
||||
detailDeleteTaskId.value = taskItem.dataset.taskId || '';
|
||||
}
|
||||
|
||||
if (detailToggleIcon) {
|
||||
detailToggleIcon.className = `bi ${taskItem.dataset.taskToggleIcon || 'bi-check2'} me-2`;
|
||||
}
|
||||
|
||||
if (detailToggleText) {
|
||||
detailToggleText.textContent = taskItem.dataset.taskToggleLabel || 'Отметить выполненной';
|
||||
}
|
||||
|
||||
syncEditForm(taskItem);
|
||||
|
||||
if (shouldSyncHistory && taskItem.dataset.taskId) {
|
||||
updateHistory(taskItem.dataset.taskId);
|
||||
}
|
||||
};
|
||||
|
||||
taskList.addEventListener('click', (event) => {
|
||||
const taskItem = event.target.closest('.task-item');
|
||||
if (!taskItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('form') || event.target.closest('[data-bs-toggle="dropdown"]') || event.target.closest('.dropdown-item-edit')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.dropdown-menu') && !event.target.closest('.task-open-link')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedLink = event.target.closest('a');
|
||||
if (clickedLink && !event.target.closest('.task-open-link')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
selectTask(taskItem);
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
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
5
cookies.txt
Normal 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
14
healthz.php
Normal 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";
|
||||
}
|
||||
802
index.php
802
index.php
@ -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'] ?? '';
|
||||
?>
|
||||
<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; ?>
|
||||
<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;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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
|
||||
<?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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user