togethere.cloud/private_html/administration/index.php

4035 lines
161 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<?php
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/config.php';
require_once __DIR__ . '/includes/header.php';
require_once __DIR__ . '/includes/sidebar.php';
?>
<?php
// Proste helpery do zliczania wartości bez wysypywania całej strony
function safeCount(PDO $pdo, string $sql, array $params = []): int {
try {
$stmt = $pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v);
}
$stmt->execute();
return (int)$stmt->fetchColumn();
} catch (Throwable $e) {
// W dashboard pokazujemy 0 zamiast 500 jeśli SQL się nie powiedzie
return 0;
}
}
// Liczniki dashboardu
$liveMatches = safeCount($pdo, "SELECT COUNT(*) FROM matches WHERE LOWER(Status) = 'live'");
$plannedMatches = safeCount($pdo, "SELECT COUNT(*) FROM matches WHERE LOWER(Status) = 'planned'");
$activeUsers = safeCount($pdo, "SELECT COUNT(*) FROM users WHERE (disabled IS NULL OR disabled = 0)");
// Placeholder na zgłoszenia BOK brak tabeli w projekcie, więc ustawiamy 0
$supportTickets = 0;
?>
<div class="admin-content">
<style>
.admin-page-title {
font-size: 24px;
font-weight: 600;
color: #23282d;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #0073aa;
}
.admin-dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 30px;
}
.admin-stat-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
transition: all 0.3s;
}
.admin-stat-card:hover {
box-shadow: 0 3px 8px rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.admin-stat-title {
font-size: 13px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.admin-stat-value {
font-size: 32px;
font-weight: 700;
color: #0073aa;
margin-bottom: 5px;
}
.admin-stat-description {
font-size: 13px;
color: #888;
}
.admin-welcome-box {
background: #fff;
border-left: 4px solid #0073aa;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.admin-welcome-box h2 {
margin: 0 0 10px 0;
color: #23282d;
font-size: 20px;
}
.admin-welcome-box p {
margin: 0;
color: #666;
line-height: 1.6;
}
ul.admin-feature-list {
padding-left: 20px;
}
.admin-split-panel {
margin-top: 30px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
/* Dashboard: stała wysokość obu paneli + scroll w środku */
.admin-split-panel .admin-panel-box {
height: 1000px;
overflow: hidden;
display: flex;
flex-direction: column;
}
@media (max-width: 1100px) {
.admin-split-panel {
grid-template-columns: 1fr;
}
}
.admin-panel-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 16px;
}
.admin-panel-box h3 {
margin: 0 0 12px 0;
color: #23282d;
font-size: 16px;
display: flex;
align-items: center;
justify-content: space-between;
}
.admin-muted {
color: #666;
font-size: 12px;
}
.admin-form-row {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 12px;
}
.admin-form-row input[type="text"],
.admin-form-row textarea {
width: 100%;
min-height: 38px;
max-height: 500px;
border: 1px solid #ccd0d4;
border-radius: 4px;
padding: 10px;
font-size: 14px;
outline: none;
}
.admin-form-row textarea {
min-height: 90px;
resize: vertical;
}
.admin-task-filebar {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
padding: 2px 0 0 0;
min-width: 0;
}
.admin-task-filebtn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0 12px;
border: 1px solid #d0d7de;
border-radius: 10px;
background: #f6f8fa;
color: #24292f;
font-size: 13px;
font-weight: 700;
cursor: pointer;
user-select: none;
}
.admin-task-filebtn:hover {
background: #eef2f6;
}
.admin-task-filename {
font-size: 13px;
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-task-filehint {
white-space: nowrap;
}
.admin-task-files-list {
display: grid;
gap: 6px;
margin-top: 2px;
}
.admin-task-file-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid #d0d7de;
border-radius: 8px;
background: #f6f8fa;
padding: 6px 8px;
font-size: 12px;
color: #333;
min-width: 0;
}
.admin-task-file-item-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.admin-task-file-item-remove {
border: 0;
background: transparent;
color: #b32d2e;
cursor: pointer;
font-size: 14px;
font-weight: 700;
line-height: 1;
padding: 0 2px;
flex: 0 0 auto;
}
.admin-task-file-item-remove:hover {
opacity: 0.8;
}
@media (max-width: 560px) {
.admin-task-filebar {
grid-template-columns: 1fr;
align-items: flex-start;
gap: 6px;
}
.admin-task-filebtn {
width: 100%;
}
.admin-task-filehint {
white-space: normal;
}
}
.admin-btn {
height: 35px;
appearance: none;
border: 0;
border-radius: 4px;
padding: 10px 12px;
background: #0073aa;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.admin-btn:hover {
filter: brightness(1.03);
}
.admin-btn:active {
transform: translateY(1px);
}
.admin-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.admin-btn-small {
height: 30px;
padding: 8px 10px;
font-size: 12px;
border-radius: 6px;
}
.admin-btn-secondary {
background: #6c757d;
}
.admin-btn-danger {
background: #b32d2e;
}
.admin-btn-success {
background: #1e7e34;
}
.admin-list {
margin-top: 14px;
border-top: 1px solid #eee;
padding-top: 14px;
max-height: none;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.admin-empty {
padding: 16px;
border: 1px dashed rgba(0,0,0,0.18);
border-radius: 10px;
background: rgba(255,255,255,0.6);
color: #666;
text-align: center;
font-size: 13px;
}
.admin-item {
border: 1px solid #eee;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
background: #fafafa;
}
.admin-item.todo {
border-color: rgba(179, 45, 46, 0.35);
background: rgba(179, 45, 46, 0.06);
}
.admin-item.done {
border-color: rgba(30, 126, 52, 0.35);
background: rgba(30, 126, 52, 0.06);
}
.admin-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.admin-item-badges {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
flex: 0 0 auto;
}
.admin-badge {
font-size: 11px;
font-weight: 700;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
color: #23282d;
white-space: nowrap;
}
.admin-badge.todo {
background: rgba(179, 45, 46, 0.10);
color: #b32d2e;
border-color: rgba(179, 45, 46, 0.25);
}
.admin-badge.done {
background: rgba(30, 126, 52, 0.12);
color: #1e7e34;
border-color: rgba(30, 126, 52, 0.25);
}
.admin-task-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.admin-task-comments-wrap {
display: grid;
gap: 8px;
}
.admin-task-comments-list {
max-height: 340px;
overflow: auto;
border: 1px solid #e6e6e6;
border-radius: 8px;
background: #fafafa;
padding: 8px;
display: grid;
gap: 8px;
}
.admin-task-comment-item {
border: 1px solid #e1e4e8;
background: #fff;
border-radius: 8px;
padding: 8px;
display: grid;
gap: 6px;
}
.admin-task-comment-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
.admin-task-comment-text {
font-size: 13px;
color: #333;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.admin-task-comments-form {
display: grid;
gap: 8px;
}
.admin-task-comments-form textarea {
width: 100%;
min-height: 86px;
border: 1px solid #ccd0d4;
border-radius: 8px;
padding: 10px;
outline: none;
resize: vertical;
font-size: 13px;
}
.admin-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.55);
display: none;
align-items: center;
justify-content: center;
padding: 18px;
z-index: 99999;
backdrop-filter: blur(2px);
}
.admin-modal {
width: min(820px, 100%);
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.10);
box-shadow: 0 20px 60px rgba(0,0,0,0.28);
overflow: hidden;
animation: adminModalIn 0.14s ease-out;
}
@keyframes adminModalIn {
from {
transform: translateY(6px) scale(0.992);
opacity: 0.85;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
/* Nicer scrollbars (where supported) */
.admin-list,
.admin-chat-messages,
.admin-mention-box {
scrollbar-width: thin;
scrollbar-color: rgba(0,0,0,0.28) transparent;
}
.admin-list::-webkit-scrollbar,
.admin-chat-messages::-webkit-scrollbar,
.admin-mention-box::-webkit-scrollbar {
width: 12px;
}
.admin-list::-webkit-scrollbar-thumb,
.admin-chat-messages::-webkit-scrollbar-thumb,
.admin-mention-box::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.25);
border-radius: 999px;
border: 3px solid rgba(255,255,255,0.85);
background-clip: padding-box;
}
.admin-list::-webkit-scrollbar-thumb:hover,
.admin-chat-messages::-webkit-scrollbar-thumb:hover,
.admin-mention-box::-webkit-scrollbar-thumb:hover {
background-color: rgba(0,0,0,0.35);
}
.admin-chat-compose textarea,
.admin-modal-body textarea {
scrollbar-width: thin;
}
.admin-chat-compose textarea {
height: 35px;
min-height: 35px;
scrollbar-color: rgba(255,255,255,0.26) transparent;
}
.admin-chat-compose textarea::-webkit-scrollbar {
width: 12px;
}
.admin-chat-compose textarea::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.20);
border-radius: 999px;
border: 3px solid rgba(31,35,41,1);
background-clip: padding-box;
}
.admin-chat-compose textarea::-webkit-scrollbar-thumb:hover {
background-color: rgba(255,255,255,0.32);
}
.admin-modal-body textarea {
scrollbar-color: rgba(0,0,0,0.28) transparent;
}
.admin-modal-body textarea::-webkit-scrollbar {
width: 12px;
}
.admin-modal-body textarea::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.22);
border-radius: 999px;
border: 3px solid rgba(255,255,255,1);
background-clip: padding-box;
}
.admin-modal-body textarea::-webkit-scrollbar-thumb:hover {
background-color: rgba(0,0,0,0.32);
}
.admin-modal-header {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: linear-gradient(180deg, #ffffff 0%, #f7f9fb 100%);
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.admin-modal-title {
margin: 0;
font-size: 15px;
font-weight: 800;
color: #23282d;
}
.admin-modal-close {
width: 34px;
height: 34px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.10);
background: #fff;
cursor: pointer;
font-size: 18px;
line-height: 1;
font-weight: 800;
color: #23282d;
}
.admin-modal-close:hover {
background: #f2f4f7;
}
.admin-modal-body {
padding: 16px;
display: grid;
gap: 10px;
}
.admin-modal-body label {
font-size: 12px;
font-weight: 800;
color: #23282d;
}
.admin-modal-body input[type="text"],
.admin-modal-body textarea {
width: 100%;
border: 1px solid #ccd0d4;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
outline: none;
}
.admin-modal-body textarea {
min-height: 160px;
max-height: 500px;
overflow: auto;
resize: vertical;
white-space: pre-wrap;
}
.admin-modal-footer {
padding: 14px 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid rgba(0,0,0,0.08);
background: #fafbfc;
}
.admin-confirm-msg {
font-size: 13px;
color: #333;
line-height: 1.5;
}
.admin-confirm-danger {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(179, 45, 46, 0.20);
background: rgba(179, 45, 46, 0.06);
}
.admin-task-edit {
margin-top: 10px;
padding: 10px;
border: 1px dashed rgba(0,0,0,0.18);
border-radius: 8px;
background: rgba(255,255,255,0.65);
}
.admin-task-edit input[type="text"],
.admin-task-edit textarea {
width: 100%;
border: 1px solid #ccd0d4;
border-radius: 8px;
padding: 10px;
font-size: 14px;
outline: none;
}
.admin-task-edit textarea {
min-height: 90px;
max-height: 300px;
overflow: auto;
resize: vertical;
}
.admin-task-edit input[type="file"] {
width: 100%;
font-size: 13px;
color: #333;
}
.admin-task-edit input[type="file"]::file-selector-button {
height: 34px;
padding: 0 12px;
border: 1px solid #d0d7de;
border-radius: 10px;
background: #f6f8fa;
color: #24292f;
font-size: 13px;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
.admin-task-edit input[type="file"]::file-selector-button:hover {
background: #eef2f6;
}
.admin-task-edit input[type="file"]::-webkit-file-upload-button {
height: 34px;
padding: 0 12px;
border: 1px solid #d0d7de;
border-radius: 10px;
background: #f6f8fa;
color: #24292f;
font-size: 13px;
font-weight: 700;
cursor: pointer;
margin-right: 10px;
}
.admin-task-edit .row {
display: grid;
gap: 8px;
}
.admin-task-edit .admin-muted {
font-size: 12px;
}
.admin-item-title {
font-weight: 700;
color: #23282d;
margin-bottom: 4px;
overflow-wrap: anywhere;
word-break: break-word;
flex: 1 1 260px;
min-width: 0;
}
.admin-item-meta {
color: #777;
font-size: 12px;
margin-bottom: 8px;
}
.admin-item-desc {
color: #333;
font-size: 13px;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
}
.admin-chat-box {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 10px;
height: 1000px;
}
.admin-chat-messages {
border: 1px solid #e6e6e6;
border-radius: 10px;
background: linear-gradient(180deg, #fbfbfb 0%, #f5f7fa 100%);
overflow: auto;
min-height: 0;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Empty state (no messages yet) */
.admin-chat-messages:empty {
justify-content: center;
align-items: center;
}
.admin-chat-messages:empty::before {
content: "Brak wiadomości.\A Napisz pierwszą wiadomość lub dodaj załącznik.";
white-space: pre-line;
text-align: center;
color: #6b7280;
font-size: 13px;
line-height: 1.35;
padding: 16px 14px;
border-radius: 14px;
border: 1px dashed rgba(0,0,0,0.18);
background: rgba(255,255,255,0.72);
box-shadow: 0 8px 22px rgba(0,0,0,0.06);
max-width: 420px;
}
.admin-chat-message {
max-width: 86%;
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 10px 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
position: relative;
overflow-wrap: anywhere;
word-break: break-word;
}
.admin-chat-message.me {
align-self: flex-end;
background: #eaf6ff;
border-color: #cfe8f7;
}
.admin-chat-message.hearted {
border-color: rgba(179, 45, 46, 0.35);
box-shadow: 0 0 0 1px rgba(179, 45, 46, 0.08);
}
.admin-chat-heart-pin {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #b32d2e;
font-weight: 700;
}
/* Typing bubble (single) */
.admin-chat-message.typing {
align-self: flex-start;
max-width: 72%;
background: rgba(255,255,255,0.92);
border-color: rgba(0,0,0,0.06);
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.admin-chat-typing-row {
display: flex;
align-items: center;
gap: 10px;
padding-top: 2px;
}
.admin-chat-typing-name {
font-weight: 700;
font-size: 13px;
color: #23282d;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.admin-chat-typing-dots {
display: inline-flex;
align-items: center;
gap: 6px;
}
.admin-chat-typing-dots span {
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(0,0,0,0.28);
display: inline-block;
animation: adminTypingDot 1.05s infinite ease-in-out;
}
.admin-chat-typing-dots span:nth-child(2) { animation-delay: 0.15s; }
.admin-chat-typing-dots span:nth-child(3) { animation-delay: 0.30s; }
@keyframes adminTypingDot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
40% { transform: translateY(-4px); opacity: 0.95; }
}
.admin-chat-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.admin-chat-user {
font-weight: 700;
color: #23282d;
font-size: 13px;
}
.admin-chat-time {
color: #777;
font-size: 12px;
white-space: nowrap;
}
.admin-chat-text {
margin-top: 6px;
color: #333;
font-size: calc(13px + 0.2rem);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.45;
}
.admin-chat-link {
color: #0073aa;
text-decoration: underline;
word-break: break-all;
}
.admin-chat-link:hover {
color: #005f8d;
}
.admin-chat-compose {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid #2d333b;
border-radius: 14px;
background: #1f2329;
padding: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.16);
--chat-ctl-h: 56px;
}
.admin-chat-compose:focus-within {
border-color: #0073aa;
box-shadow: 0 0 0 4px rgba(0,115,170,0.10);
}
.admin-chat-icon-btn {
width: var(--chat-ctl-h);
height: var(--chat-ctl-h);
flex: 0 0 var(--chat-ctl-h);
align-self: flex-start;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 12px;
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.92);
font-size: 22px;
line-height: 1;
font-weight: 700;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
}
.admin-chat-icon-btn:hover {
background: rgba(255,255,255,0.10);
}
.admin-chat-icon-btn:active {
transform: translateY(1px);
}
.admin-chat-inputwrap {
flex: 1;
display: grid;
grid-template-columns: 1fr;
gap: 6px;
min-width: 0;
align-self: stretch;
position: relative;
}
.admin-chat-compose .admin-btn {
height: var(--chat-ctl-h);
box-sizing: border-box;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 12px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 1px 1px rgba(0,0,0,0.14);
}
.admin-chat-send-btn {
flex: 0 0 auto;
align-self: flex-start;
}
.admin-chat-compose textarea {
box-sizing: border-box;
min-height: var(--chat-ctl-h);
max-height: 150px;
height: var(--chat-ctl-h);
padding: 9px 10px;
outline: 0;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 12px;
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.92);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.18);
transition: border-color 220ms ease, box-shadow 220ms ease, background 220ms ease;
font-size: calc(14px + 0.2rem);
line-height: 1.35;
overflow-y: hidden;
}
.admin-chat-compose textarea::placeholder {
color: rgba(255,255,255,0.55);
}
/* Scrollbar: visually inset ~2px from right edge (WebKit/Blink) */
.admin-chat-compose textarea::-webkit-scrollbar {
width: 12px;
}
.admin-chat-compose textarea::-webkit-scrollbar-track {
background: transparent;
}
.admin-chat-compose textarea::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.28);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.admin-chat-compose textarea::-webkit-scrollbar-thumb:hover {
background-color: rgba(255,255,255,0.38);
}
.admin-chat-compose textarea:focus {
border-color: rgba(0,115,170,0.85);
background: rgba(255,255,255,0.06);
box-shadow: 0 0 0 3px rgba(0,115,170,0.12);
}
@keyframes adminPulseRedBorder {
0% {
border-color: rgba(255, 77, 77, 0.25);
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.0);
}
35% {
border-color: rgba(255, 77, 77, 0.95);
box-shadow: 0 0 0 5px rgba(255, 77, 77, 0.18);
}
100% {
border-color: rgba(255, 77, 77, 0.25);
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.0);
}
}
.admin-chat-compose textarea.admin-pulse-red,
.admin-chat-inline-textarea.admin-pulse-red {
animation: adminPulseRedBorder 1s ease-out;
}
.admin-chat-inputwrap > textarea {
resize: none;
}
.admin-chat-replybar {
border: 1px solid rgba(255,255,255,0.10);
border-radius: 10px;
background: rgba(255,255,255,0.06);
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-left: 4px solid #0073aa;
min-width: 0;
}
.admin-chat-replybar > div {
min-width: 0;
flex: 1 1 auto;
}
.admin-chat-replybar strong {
display: block;
font-size: 12px;
color: rgba(255,255,255,0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-chat-replybar span {
display: block;
font-size: 12px;
color: rgba(255,255,255,0.70);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.admin-chat-replybar button {
border: 0;
background: transparent;
color: rgba(255,255,255,0.85);
cursor: pointer;
font-weight: 700;
font-size: 18px;
line-height: 1;
flex: 0 0 auto;
}
.admin-chat-filechip {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 12px;
background: rgba(255,255,255,0.04);
min-width: 0;
}
.admin-chat-filechip-name {
font-size: 12px;
color: rgba(255,255,255,0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-chat-filechip-remove {
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid rgba(255,255,255,0.10);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.90);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.admin-chat-filechip-remove:hover {
background: rgba(255,255,255,0.10);
}
/* legacy file picker styles removed from layout; keeping mention box below input */
@media (max-width: 560px) {
.admin-chat-compose {
flex-wrap: wrap;
}
.admin-chat-send-btn {
width: 100%;
}
.admin-chat-icon-btn {
width: 100%;
flex: 1 1 auto;
}
}
.admin-chat-actions {
margin-top: 8px;
display: flex;
gap: 10px;
opacity: 0;
transition: opacity 0.15s ease;
}
.admin-chat-message:hover .admin-chat-actions {
opacity: 1;
}
.admin-chat-action-btn {
border: 0;
background: transparent;
color: #0073aa;
font-size: 12px;
cursor: pointer;
padding: 0;
}
.admin-chat-action-btn:hover {
text-decoration: underline;
}
.admin-chat-action-btn.active-heart {
color: #b32d2e;
font-weight: 700;
}
.admin-chat-inline-edit {
margin-top: 6px;
}
.admin-chat-inline-edit textarea {
width: 100%;
min-height: 90px;
max-height: 240px;
resize: vertical;
border: 1px solid #ccd0d4;
border-radius: 10px;
padding: 10px 12px;
font-size: calc(14px + 0.2rem);
line-height: 1.35;
outline: none;
}
.admin-chat-inline-edit-actions {
margin-top: 8px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* WhatsApp-like inline edit (minimal visual change) */
.admin-chat-message.editing .admin-chat-actions {
opacity: 1;
}
.admin-chat-inline-textarea {
width: 100%;
resize: none;
border: 0;
outline: none;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
padding: 0;
margin: 0;
overflow: hidden;
transition: box-shadow 220ms ease;
}
.admin-chat-inline-toolbar {
margin-top: 6px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.admin-chat-inline-filebar {
margin-top: 6px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: #6e7781;
}
.admin-chat-inline-filebar .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-chat-inline-filebar .actions {
display: flex;
gap: 12px;
flex: 0 0 auto;
}
.admin-chat-inline-toolbtn {
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
font-size: 12px;
color: #0073aa;
}
.admin-chat-inline-toolbtn:hover {
text-decoration: underline;
}
.admin-chat-inline-error {
margin-top: 6px;
font-size: 12px;
color: #b32d2e;
display: none;
}
.admin-chat-reply-quote {
margin-top: 8px;
border-left: 3px solid #0073aa;
padding: 6px 10px;
background: rgba(255,255,255,0.7);
border-radius: 10px;
font-size: 13px;
}
.admin-chat-reply-quote .admin-muted {
margin-bottom: 4px;
}
.admin-chat-message.recalled .admin-chat-text-main {
font-style: italic;
opacity: 0.85;
}
.admin-chat-attachment {
margin-top: 8px;
}
.admin-chat-attachment img {
display: block;
width: min(360px, 100%);
max-width: 100%;
height: auto;
max-height: 240px;
object-fit: contain;
border-radius: 6px;
border: 1px solid #e6e6e6;
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
cursor: pointer;
}
.admin-chat-attachment img:hover {
transform: translateY(-1px);
transition: transform 0.12s ease;
}
.admin-mention {
color: #0073aa;
font-weight: 700;
}
.admin-mention-box {
border: 1px solid #e6e6e6;
border-radius: 6px;
background: #fff;
overflow: auto;
max-height: 160px;
box-shadow: 0 8px 20px rgba(0,0,0,0.10);
}
.admin-mention-item {
padding: 8px 10px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid #f1f1f1;
}
.admin-mention-item:last-child {
border-bottom: 0;
}
.admin-mention-item.active {
background: #f1f7fb;
}
.admin-status {
margin-top: 8px;
font-size: 12px;
color: #666;
}
.admin-charhint {
margin-top: -6px;
margin-bottom: 4px;
font-size: 12px;
color: #6e7781;
text-align: right;
user-select: none;
}
.admin-charhint.warn {
color: #b54708;
font-weight: 600;
}
.admin-charhint.over {
color: #b32d2e;
font-weight: 700;
}
</style>
<h1 class="admin-page-title">Dashboard</h1>
<div class="admin-welcome-box">
<h2>👋 Witaj w panelu administracyjnym, <?php echo htmlspecialchars($_SESSION['username']); ?>!</h2>
<p>Zarządzaj swoją platformą togethere.cloud. Wybierz opcję z menu po lewej stronie.</p>
</div>
<div class="admin-dashboard-grid">
<div class="admin-stat-card">
<div class="admin-stat-title">Trwające mecze</div>
<div class="admin-stat-value"><?php echo $liveMatches; ?></div>
<div class="admin-stat-description">Aktualnie w trakcie</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">Zaplanowane mecze</div>
<div class="admin-stat-value"><?php echo $plannedMatches; ?></div>
<div class="admin-stat-description">Nadchodzące spotkania</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">Aktywni użytkownicy</div>
<div class="admin-stat-value"><?php echo $activeUsers; ?></div>
<div class="admin-stat-description">Zarejestrowani użytkownicy</div>
</div>
<div class="admin-stat-card">
<div class="admin-stat-title">Zgłoszenia BOK</div>
<div class="admin-stat-value"><?php echo $supportTickets; ?></div>
<div class="admin-stat-description">Oczekujące zgłoszenia</div>
</div>
</div>
<div class="admin-split-panel">
<div class="admin-panel-box">
<h3>
<span>KEEP (notatki / taski)</span>
<span class="admin-muted" id="adminTasksStatus">—</span>
</h3>
<form id="adminTaskForm" class="admin-form-row" enctype="multipart/form-data">
<input type="text" name="title" id="adminTaskTitle" placeholder="Tytuł (np. 'Do zrobienia dziś')" required maxlength="100" />
<div class="admin-charhint" id="adminTaskTitleHint" aria-live="polite"></div>
<textarea name="description" id="adminTaskDescription" placeholder="Opis / ważne informacje..." maxlength="1000"></textarea>
<div class="admin-charhint" id="adminTaskDescriptionHint" aria-live="polite"></div>
<div class="admin-task-filebar">
<label class="admin-task-filebtn" for="adminTaskFile" title="Dodaj pliki">Wybierz pliki</label>
<span class="admin-task-filename" id="adminTaskFileName">Nie wybrano plików</span>
<span class="admin-task-filehint admin-muted">zapis do DB • LONGBLOB</span>
<input type="file" name="files[]" id="adminTaskFile" multiple style="display:none;" />
</div>
<div class="admin-task-files-list" id="adminTaskFilesList"></div>
<button class="admin-btn" type="submit" id="adminTaskSubmit">Dodaj</button>
</form>
<div class="admin-list" id="adminTasksList"></div>
<div class="admin-status" id="adminTasksError" style="display:none;color:#b32d2e;"></div>
<div class="admin-muted" style="margin-top:10px;">
Uwaga: pliki zapisują się w bazie (LONGBLOB). Dla bardzo dużych plików trzeba podnieść limity PHP: upload_max_filesize i post_max_size.
</div>
</div>
<div class="admin-panel-box admin-chat-box">
<h3>
<span>Czat (stała historia)</span>
<span class="admin-muted" id="adminChatStatus">Ładowanie…</span>
</h3>
<form id="adminChatForm" class="admin-chat-compose" enctype="multipart/form-data">
<button type="button" class="admin-chat-icon-btn" id="adminChatAttachBtn" aria-label="Dodaj załącznik" title="Dodaj załącznik">+</button>
<input type="file" id="adminChatFile" name="file" accept="image/*,application/pdf,text/plain" style="display:none;" />
<div class="admin-chat-inputwrap">
<div id="adminChatReplyBar" class="admin-chat-replybar" style="display:none;">
<div>
<strong id="adminChatReplyTitle">Odpowiadasz…</strong>
<span id="adminChatReplySnippet"></span>
</div>
<button type="button" id="adminChatReplyCancel" aria-label="Anuluj odpowiedź">×</button>
</div>
<textarea id="adminChatInput" placeholder="Napisz wiadomość…" maxlength="1500"></textarea>
<div id="adminMentionBox" class="admin-mention-box" style="display:none;"></div>
<div class="admin-chat-filechip" id="adminChatFileChip" style="display:none;">
<span class="admin-chat-filechip-name" id="adminChatFileChipName">Załącznik</span>
<span class="admin-muted" style="white-space:nowrap;">max 5MB • jpg/png/gif/webp/pdf/txt</span>
<button type="button" class="admin-chat-filechip-remove" id="adminChatFileChipRemove" aria-label="Usuń załącznik" title="Usuń">×</button>
</div>
</div>
<button class="admin-btn admin-chat-send-btn" type="submit" id="adminChatSend">Wyślij</button>
</form>
<div class="admin-chat-messages" id="adminChatMessages" aria-label="Czat"></div>
<div class="admin-status" id="adminChatError" style="display:none;color:#b32d2e;"></div>
</div>
</div>
<div style="margin-top: 40px; padding: 20px; background: #fff; border: 1px solid #ddd; border-radius: 4px;">
<h3 style="margin-top: 0; color: #23282d;">🚀 Funkcjonalność w przygotowaniu</h3>
<p style="color: #666; line-height: 1.6;">
Panel administracyjny jest w fazie rozwoju. Wkrótce dodamy pełne funkcjonalności zarządzania:
</p>
<ul style="color: #666; line-height: 1.8;" class="admin-feature-list">
<li>Zarządzanie meczami i turniejami</li>
<li>Administracja użytkownikami</li>
<li>System ligowy</li>
<li>Obsługa zgłoszeń BOK</li>
<li>Statystyki i raporty</li>
</ul>
</div>
<script>
(function () {
const CHAT_URL = '/api/admin_chat_messages.php';
const CHAT_FILE_URL = '/api/admin_chat_file.php';
const TASKS_URL = '/api/admin_tasks.php';
const ADMINS_URL = '/api/admin_admins.php';
const TYPING_URL = '/api/admin_chat_typing.php';
const CURRENT_USERNAME = <?php echo json_encode($_SESSION['username'] ?? ''); ?>;
const chatBox = document.getElementById('adminChatMessages');
const chatStatus = document.getElementById('adminChatStatus');
const chatError = document.getElementById('adminChatError');
const chatForm = document.getElementById('adminChatForm');
const chatInput = document.getElementById('adminChatInput');
const chatSend = document.getElementById('adminChatSend');
const chatAttachBtn = document.getElementById('adminChatAttachBtn');
const chatFile = document.getElementById('adminChatFile');
const chatFileChip = document.getElementById('adminChatFileChip');
const chatFileChipName = document.getElementById('adminChatFileChipName');
const chatFileChipRemove = document.getElementById('adminChatFileChipRemove');
const mentionBox = document.getElementById('adminMentionBox');
const replyBar = document.getElementById('adminChatReplyBar');
const replyTitle = document.getElementById('adminChatReplyTitle');
const replySnippet = document.getElementById('adminChatReplySnippet');
const replyCancel = document.getElementById('adminChatReplyCancel');
const tasksStatus = document.getElementById('adminTasksStatus');
const tasksError = document.getElementById('adminTasksError');
const tasksList = document.getElementById('adminTasksList');
const taskForm = document.getElementById('adminTaskForm');
const taskSubmit = document.getElementById('adminTaskSubmit');
const taskFile = document.getElementById('adminTaskFile');
const taskFileName = document.getElementById('adminTaskFileName');
const taskFilesList = document.getElementById('adminTaskFilesList');
const taskTitleInput = document.getElementById('adminTaskTitle');
const taskDescInput = document.getElementById('adminTaskDescription');
const taskTitleHint = document.getElementById('adminTaskTitleHint');
const taskDescHint = document.getElementById('adminTaskDescriptionHint');
const chatInputHint = document.getElementById('adminChatInputHint');
const LIMITS = {
chatMessage: 1500,
taskTitle: 100,
taskDescription: 1000,
taskComment: 2000
};
const TASK_ATTACHMENTS = {
maxCount: 10,
maxFileBytes: 20 * 1024 * 1024
};
function bytesToMbText(bytes) {
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
function validateTaskFiles(filesArr) {
const files = Array.isArray(filesArr) ? filesArr : [];
if (files.length > TASK_ATTACHMENTS.maxCount) {
return 'Maksymalnie ' + String(TASK_ATTACHMENTS.maxCount) + ' załączników';
}
for (const f of files) {
const size = f && typeof f.size === 'number' ? f.size : 0;
if (size > TASK_ATTACHMENTS.maxFileBytes) {
return 'Plik "' + (f.name || 'załącznik') + '" ma ' + bytesToMbText(size) + ', a limit to 20 MB';
}
}
return null;
}
function updateInputFiles(inputEl, filesArr) {
if (!inputEl) return;
if (typeof DataTransfer === 'undefined') return;
const dt = new DataTransfer();
(filesArr || []).forEach(function (file) {
dt.items.add(file);
});
inputEl.files = dt.files;
}
function makeFilesController(inputEl, labelEl, listEl, emptyText) {
const state = {
files: []
};
function keyOf(file) {
return [file.name || '', file.size || 0, file.lastModified || 0].join('::');
}
function render() {
if (labelEl) {
if (state.files.length === 0) {
labelEl.textContent = emptyText;
} else {
labelEl.textContent = 'Wybrano (' + state.files.length + '): ' + state.files.map(function (f) {
return f.name;
}).join(', ');
}
}
if (!listEl) return;
listEl.innerHTML = '';
state.files.forEach(function (file, idx) {
const row = document.createElement('div');
row.className = 'admin-task-file-item';
const name = document.createElement('span');
name.className = 'admin-task-file-item-name';
name.textContent = file.name + ' (' + bytesToMbText(file.size) + ')';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'admin-task-file-item-remove';
removeBtn.setAttribute('aria-label', 'Usuń plik');
removeBtn.textContent = '×';
removeBtn.addEventListener('click', function () {
state.files.splice(idx, 1);
updateInputFiles(inputEl, state.files);
render();
});
row.appendChild(name);
row.appendChild(removeBtn);
listEl.appendChild(row);
});
}
function handleInputChange() {
const picked = inputEl && inputEl.files ? Array.from(inputEl.files) : [];
if (picked.length === 0) {
render();
return;
}
const existing = new Set(state.files.map(keyOf));
const merged = state.files.slice();
picked.forEach(function (f) {
const k = keyOf(f);
if (!existing.has(k)) {
merged.push(f);
existing.add(k);
}
});
const err = validateTaskFiles(merged);
if (err) {
showError(tasksError, err);
updateInputFiles(inputEl, state.files);
render();
return;
}
clearError(tasksError);
state.files = merged;
updateInputFiles(inputEl, state.files);
render();
}
if (inputEl) {
inputEl.addEventListener('change', handleInputChange);
}
return {
getFiles: function () { return state.files.slice(); },
clear: function () {
state.files = [];
if (inputEl) inputEl.value = '';
updateInputFiles(inputEl, state.files);
render();
},
render: render,
setFiles: function (filesArr) {
state.files = Array.isArray(filesArr) ? filesArr.slice() : [];
updateInputFiles(inputEl, state.files);
render();
}
};
}
function pulseRedBorder(inputEl) {
if (!inputEl) return;
try {
inputEl.classList.remove('admin-pulse-red');
void inputEl.offsetWidth; // restart animation
inputEl.classList.add('admin-pulse-red');
if (inputEl._pulseRedTimer) clearTimeout(inputEl._pulseRedTimer);
inputEl._pulseRedTimer = setTimeout(function () {
inputEl.classList.remove('admin-pulse-red');
}, 1000);
} catch (e) {
}
}
function wireCharHint(inputEl, hintEl, maxLen) {
if (!inputEl || !hintEl) return { update: function () { } };
function update() {
const val = (inputEl.value || '').toString();
const len = val.length;
const remaining = maxLen - len;
hintEl.classList.toggle('over', remaining < 0);
hintEl.classList.toggle('warn', remaining >= 0 && remaining <= Math.max(20, Math.floor(maxLen * 0.1)));
if (remaining < 0) {
hintEl.textContent = 'Za dużo znaków: ' + String(len) + '/' + String(maxLen) + ' (usuń ' + String(Math.abs(remaining)) + ')';
return;
}
if (remaining <= Math.max(20, Math.floor(maxLen * 0.1))) {
hintEl.textContent = 'Uwaga: kończy się miejsce — ' + String(len) + '/' + String(maxLen);
return;
}
hintEl.textContent = String(len) + '/' + String(maxLen);
}
inputEl.addEventListener('input', update);
update();
return { update };
}
const taskTitleCounter = wireCharHint(taskTitleInput, taskTitleHint, LIMITS.taskTitle);
const taskDescCounter = wireCharHint(taskDescInput, taskDescHint, LIMITS.taskDescription);
const chatCounter = wireCharHint(chatInput, chatInputHint, LIMITS.chatMessage);
const taskCreateFilesCtrl = makeFilesController(taskFile, taskFileName, taskFilesList, 'Nie wybrano plików');
taskCreateFilesCtrl.render();
let chatOldestId = null;
let chatNewestId = null;
let chatLoadingOlder = false;
let chatHasMore = true;
let chatPollingTimer = null;
let chatEditsPollingTimer = null;
let typingPollingTimer = null;
let chatBaseStatus = 'OK';
let chatSending = false;
let chatLastEditsPollAt = Math.max(0, Math.floor(Date.now() / 1000) - 10);
let replyTo = null;
let admins = [];
let mentionActiveIndex = 0;
let mentionVisible = false;
const audioNewMessage = new Audio('/sounds/newMessage.wav');
const audioTyping = new Audio('/sounds/typing.wav');
audioNewMessage.preload = 'auto';
audioTyping.preload = 'auto';
audioTyping.volume = 0.4;
let typingStopTimer = null;
let lastTypingPingAt = 0;
// Typing bubble state (single bubble, newest starter wins)
let typingBubbleEl = null;
let typingBubbleUserId = null;
let typingState = new Map(); // userId -> { username, seenAtMs }
let typingSuppressUntil = new Map(); // userId -> ms
function tryPlay(audio) {
try {
audio.currentTime = 0;
const p = audio.play();
if (p && typeof p.catch === 'function') p.catch(function () { });
} catch (e) { }
}
function playTypingSoundFor3s() {
tryPlay(audioTyping);
if (typingStopTimer) clearTimeout(typingStopTimer);
typingStopTimer = setTimeout(function () {
try {
audioTyping.pause();
audioTyping.currentTime = 0;
} catch (e) { }
}, 3000);
}
function renderTypingBubble(username) {
const wrap = document.createElement('div');
wrap.className = 'admin-chat-message typing';
wrap.dataset.id = 'typing';
const row = document.createElement('div');
row.className = 'admin-chat-typing-row';
const name = document.createElement('div');
name.className = 'admin-chat-typing-name';
name.textContent = username || 'admin';
const dots = document.createElement('div');
dots.className = 'admin-chat-typing-dots';
dots.innerHTML = '<span></span><span></span><span></span>';
row.appendChild(name);
row.appendChild(dots);
wrap.appendChild(row);
return wrap;
}
function hideTypingBubble() {
if (typingBubbleEl && typingBubbleEl.parentNode) {
typingBubbleEl.parentNode.removeChild(typingBubbleEl);
}
typingBubbleEl = null;
typingBubbleUserId = null;
}
function showTypingBubbleFor(userId, username) {
if (!userId) return;
if (typingBubbleUserId === userId && typingBubbleEl) {
const nameEl = typingBubbleEl.querySelector('.admin-chat-typing-name');
if (nameEl) nameEl.textContent = username || 'admin';
return;
}
hideTypingBubble();
typingBubbleUserId = userId;
typingBubbleEl = renderTypingBubble(username);
const stick = isNearTop(chatBox);
chatBox.insertBefore(typingBubbleEl, chatBox.firstChild);
if (stick) chatBox.scrollTop = 0;
}
function ensureTypingBubbleAtTop() {
if (!typingBubbleEl) return;
if (typingBubbleEl.parentNode !== chatBox) return;
if (chatBox.firstElementChild !== typingBubbleEl) {
chatBox.insertBefore(typingBubbleEl, chatBox.firstChild);
}
}
function refreshTypingBubbleFromState() {
if (!typingState || typingState.size === 0) {
hideTypingBubble();
return;
}
// pick newest starter
let pickId = null;
let pickName = null;
let pickSeen = -1;
typingState.forEach(function (v, k) {
if (v && typeof v.seenAtMs === 'number' && v.seenAtMs > pickSeen) {
pickSeen = v.seenAtMs;
pickId = k;
pickName = v.username;
}
});
if (!pickId) {
hideTypingBubble();
return;
}
showTypingBubbleFor(pickId, pickName);
ensureTypingBubbleAtTop();
}
function showError(el, msg) {
el.style.display = 'block';
el.textContent = msg;
}
function clearError(el) {
el.style.display = 'none';
el.textContent = '';
}
// Modal helpers (task edit + nicer confirms)
let modalOverlay = null;
let modalTitleEl = null;
let modalBodyEl = null;
let modalFooterEl = null;
let modalState = null;
function ensureModal() {
if (modalOverlay) return;
modalOverlay = document.createElement('div');
modalOverlay.className = 'admin-modal-overlay';
const card = document.createElement('div');
card.className = 'admin-modal';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
const header = document.createElement('div');
header.className = 'admin-modal-header';
modalTitleEl = document.createElement('h3');
modalTitleEl.className = 'admin-modal-title';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'admin-modal-close';
closeBtn.setAttribute('aria-label', 'Zamknij');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', function () {
if (modalState && typeof modalState.onCancel === 'function') {
modalState.onCancel();
} else {
closeModal();
}
});
header.appendChild(modalTitleEl);
header.appendChild(closeBtn);
modalBodyEl = document.createElement('div');
modalBodyEl.className = 'admin-modal-body';
modalFooterEl = document.createElement('div');
modalFooterEl.className = 'admin-modal-footer';
card.appendChild(header);
card.appendChild(modalBodyEl);
card.appendChild(modalFooterEl);
modalOverlay.appendChild(card);
document.body.appendChild(modalOverlay);
modalOverlay.addEventListener('mousedown', function (e) {
if (e.target !== modalOverlay) return;
if (modalState && modalState.locked) return;
if (modalState && typeof modalState.onCancel === 'function') {
modalState.onCancel();
} else {
closeModal();
}
});
document.addEventListener('keydown', function (e) {
if (!modalOverlay || modalOverlay.style.display !== 'flex') return;
if (e.key === 'Escape') {
if (modalState && modalState.locked) return;
if (modalState && typeof modalState.onCancel === 'function') {
modalState.onCancel();
} else {
closeModal();
}
}
});
}
function openModal(opts) {
ensureModal();
modalState = {
locked: !!(opts && opts.locked),
onCancel: (opts && typeof opts.onCancel === 'function') ? opts.onCancel : null
};
modalTitleEl.textContent = (opts && opts.title) ? String(opts.title) : '';
modalBodyEl.innerHTML = '';
modalFooterEl.innerHTML = '';
if (opts && opts.bodyEl) {
modalBodyEl.appendChild(opts.bodyEl);
}
const buttons = (opts && Array.isArray(opts.buttons)) ? opts.buttons : [];
buttons.forEach(function (b) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = b.className || 'admin-btn';
btn.textContent = b.text || 'OK';
if (b.disabled) btn.disabled = true;
btn.addEventListener('click', function () {
if (typeof b.onClick === 'function') b.onClick(btn);
});
modalFooterEl.appendChild(btn);
});
modalOverlay.style.display = 'flex';
setTimeout(function () {
try {
const focusEl = (opts && opts.initialFocusEl) ? opts.initialFocusEl : null;
if (focusEl && typeof focusEl.focus === 'function') {
focusEl.focus();
} else {
const first = modalBodyEl.querySelector('input, textarea, button, select, a[href]');
if (first && typeof first.focus === 'function') first.focus();
}
} catch (e) { }
}, 0);
}
function closeModal() {
if (!modalOverlay) return;
modalOverlay.style.display = 'none';
modalState = null;
if (modalBodyEl) modalBodyEl.innerHTML = '';
if (modalFooterEl) modalFooterEl.innerHTML = '';
}
function confirmModal(opts) {
return new Promise(function (resolve) {
const danger = document.createElement('div');
danger.className = 'admin-confirm-danger';
const msg = document.createElement('div');
msg.className = 'admin-confirm-msg';
msg.textContent = (opts && opts.message) ? String(opts.message) : 'Czy na pewno?';
danger.appendChild(msg);
const onCancel = function () {
closeModal();
resolve(false);
};
openModal({
title: (opts && opts.title) ? String(opts.title) : 'Potwierdź',
bodyEl: danger,
onCancel: onCancel,
buttons: [
{
text: (opts && opts.cancelText) ? String(opts.cancelText) : 'Anuluj',
className: 'admin-btn admin-btn-secondary',
onClick: onCancel
},
{
text: (opts && opts.confirmText) ? String(opts.confirmText) : 'Usuń',
className: (opts && opts.confirmClassName) ? String(opts.confirmClassName) : 'admin-btn admin-btn-danger',
onClick: function () {
closeModal();
resolve(true);
}
}
]
});
});
}
// Chat textarea auto-grow (min=button height, max=150px)
const CHAT_TEXTAREA_MAX_PX = 150;
function autosizeChatInput() {
if (!chatInput) return;
try {
const btnH = chatAttachBtn ? chatAttachBtn.getBoundingClientRect().height : 42;
const minPx = Math.max(36, Math.round(btnH || 42));
chatInput.style.height = minPx + 'px';
chatInput.style.overflowY = 'hidden';
chatInput.style.height = 'auto';
const scrollH = chatInput.scrollHeight || minPx;
const nextH = Math.max(minPx, Math.min(scrollH, CHAT_TEXTAREA_MAX_PX));
chatInput.style.height = nextH + 'px';
chatInput.style.overflowY = (scrollH > CHAT_TEXTAREA_MAX_PX) ? 'auto' : 'hidden';
} catch (e) { }
}
function isNearBottom(container, px = 60) {
return (container.scrollHeight - (container.scrollTop + container.clientHeight)) < px;
}
function isNearTop(container, px = 60) {
return (container.scrollTop || 0) < px;
}
function formatTime(ts) {
return ts ? ts.replace('T', ' ').slice(0, 19) : '';
}
function isEdited(msg) {
const u = (msg && msg.updated_at) ? String(msg.updated_at) : '';
const c = (msg && msg.created_at) ? String(msg.created_at) : '';
return !!u && u !== c;
}
function isHeartedMessage(msg) {
return !!(msg && (msg.is_hearted === true || intOrZero(msg.is_hearted) === 1));
}
function getHeartPinText(msg) {
const by = (msg && msg.hearted_by_username) ? String(msg.hearted_by_username) : '';
return by ? ('❤️ Przypięte przez ' + by) : '❤️ Przypięte';
}
function getHeartButtonText(msg) {
return isHeartedMessage(msg) ? 'Odepnij ❤️' : 'Przypnij ❤️';
}
function getMessageRenderSignature(msg) {
const message = (msg && msg.message) ? String(msg.message) : '';
const hasFile = !!(msg && msg.has_file);
const fileName = (msg && msg.file_name) ? String(msg.file_name) : '';
const fileMime = (msg && msg.file_mime) ? String(msg.file_mime) : '';
const fileSize = (msg && msg.file_size != null) ? String(msg.file_size) : '';
const updatedAt = (msg && msg.updated_at) ? String(msg.updated_at) : '';
const isHearted = isHeartedMessage(msg) ? '1' : '0';
const heartedBy = (msg && msg.hearted_by_username) ? String(msg.hearted_by_username) : '';
const heartedAt = (msg && msg.hearted_at) ? String(msg.hearted_at) : '';
return [message, hasFile ? '1' : '0', fileName, fileMime, fileSize, updatedAt, isHearted, heartedBy, heartedAt].join('|');
}
function updateMessageInDom(msg) {
if (!msg || !msg.id) return false;
const messageId = intOrZero(msg.id);
if (!messageId) return false;
const el = chatBox.querySelector('.admin-chat-message[data-id="' + String(messageId) + '"]');
if (!el) return false;
const incomingUpdatedAt = msg.updated_at ? String(msg.updated_at) : '';
const currentUpdatedAt = el.dataset && el.dataset.updatedAt ? String(el.dataset.updatedAt) : '';
if (incomingUpdatedAt && currentUpdatedAt && incomingUpdatedAt === currentUpdatedAt) {
return false;
}
const incomingSig = getMessageRenderSignature(msg);
const currentSig = el.dataset && el.dataset.renderSig ? String(el.dataset.renderSig) : '';
if (incomingSig && currentSig && incomingSig === currentSig) {
return false;
}
function isPreviewableImageAttachment(m) {
const mime = ((m && m.file_mime) ? String(m.file_mime) : '').toLowerCase();
if (mime === 'image/jpeg' || mime === 'image/jpg' || mime === 'image/png' || mime === 'image/gif') return true;
const name = ((m && m.file_name) ? String(m.file_name) : '').toLowerCase();
return /\.(jpe?g|png|gif)$/.test(name);
}
// Don't overwrite while the local user is editing
if (el.dataset.editing === '1') return false;
const recalledText = 'Wiadomość cofnięta';
const isRecalled = ((msg.message || '').toString() === recalledText);
el.classList.toggle('recalled', isRecalled);
const isHearted = isHeartedMessage(msg);
el.classList.toggle('hearted', isHearted);
let heartPinEl = el.querySelector('.admin-chat-heart-pin');
if (isHearted) {
if (!heartPinEl) {
heartPinEl = document.createElement('div');
heartPinEl.className = 'admin-chat-heart-pin';
const headerEl = el.querySelector('.admin-chat-header');
const insertAfterHeader = headerEl && headerEl.nextSibling ? headerEl.nextSibling : null;
if (insertAfterHeader) {
el.insertBefore(heartPinEl, insertAfterHeader);
} else {
el.appendChild(heartPinEl);
}
}
heartPinEl.textContent = getHeartPinText(msg);
} else if (heartPinEl) {
heartPinEl.remove();
}
let textEl = el.querySelector('.admin-chat-text-main');
const wantsText = ((msg.message || '').toString() !== '');
if (!textEl && wantsText) {
textEl = document.createElement('div');
textEl.className = 'admin-chat-text admin-chat-text-main';
const existingAtt = el.querySelector('.admin-chat-attachment');
const actionsEl = el.querySelector('.admin-chat-actions');
const insertBefore = existingAtt || actionsEl || null;
el.insertBefore(textEl, insertBefore);
}
if (textEl && wantsText) {
textEl.innerHTML = '';
textEl.appendChild(renderTextWithMentions(msg.message || ''));
}
const timeEl = el.querySelector('.admin-chat-time');
if (timeEl) {
const base = formatTime(msg.created_at);
timeEl.textContent = base + (isEdited(msg) ? ' • edytowano' : '');
}
// Attachment update
const actionsEl = el.querySelector('.admin-chat-actions');
const existingAtt = el.querySelector('.admin-chat-attachment');
if (msg.has_file) {
const newAtt = document.createElement('div');
newAtt.className = 'admin-chat-attachment';
const fileUrl = CHAT_FILE_URL + '?id=' + encodeURIComponent(msg.id);
if (isPreviewableImageAttachment(msg)) {
const a = document.createElement('a');
a.href = fileUrl;
a.download = msg.file_name ? String(msg.file_name) : 'zalacznik';
const img = document.createElement('img');
img.alt = msg.file_name ? ('Załącznik: ' + msg.file_name) : 'Załącznik';
img.src = fileUrl + '&inline=1';
a.appendChild(img);
newAtt.appendChild(a);
} else {
const a = document.createElement('a');
a.href = fileUrl;
a.download = msg.file_name ? String(msg.file_name) : 'zalacznik';
a.textContent = 'Pobierz plik' + (msg.file_name ? (': ' + msg.file_name) : '');
newAtt.appendChild(a);
}
if (existingAtt) {
existingAtt.replaceWith(newAtt);
} else {
if (actionsEl) {
el.insertBefore(newAtt, actionsEl);
} else {
el.appendChild(newAtt);
}
}
} else {
if (existingAtt) existingAtt.remove();
}
// Action buttons state
const editBtn = el.querySelector('.admin-chat-action-btn[data-action="edit"]');
const recallBtn = el.querySelector('.admin-chat-action-btn[data-action="recall"]');
const heartBtn = el.querySelector('.admin-chat-action-btn[data-action="heart"]');
if (editBtn) editBtn.style.display = isRecalled ? 'none' : '';
if (recallBtn) recallBtn.style.display = isRecalled ? 'none' : '';
if (heartBtn) {
heartBtn.textContent = getHeartButtonText(msg);
heartBtn.classList.toggle('active-heart', isHearted);
}
el.dataset.updatedAt = msg.updated_at ? String(msg.updated_at) : '';
el.dataset.renderSig = incomingSig;
return true;
}
function renderMentionsOnly(text) {
const frag = document.createDocumentFragment();
const s = (text || '').toString();
const re = /(@[A-Za-z0-9._-]+)/g;
let last = 0;
let m;
while ((m = re.exec(s)) !== null) {
if (m.index > last) {
frag.appendChild(document.createTextNode(s.slice(last, m.index)));
}
const span = document.createElement('span');
span.className = 'admin-mention';
span.textContent = m[1];
frag.appendChild(span);
last = m.index + m[1].length;
}
if (last < s.length) {
frag.appendChild(document.createTextNode(s.slice(last)));
}
return frag;
}
function renderTextWithMentions(text) {
const frag = document.createDocumentFragment();
const s = (text || '').toString();
const urlRe = /(https?:\/\/[^\s<>"'`]+|www\.[^\s<>"'`]+)/gi;
function splitTrailingPunctuation(urlText) {
let clean = urlText;
let trailing = '';
while (clean && /[.,!?;:)]$/.test(clean)) {
trailing = clean.slice(-1) + trailing;
clean = clean.slice(0, -1);
}
return { clean, trailing };
}
let lastIndex = 0;
let match;
while ((match = urlRe.exec(s)) !== null) {
const start = match.index;
const raw = match[0] || '';
if (start > lastIndex) {
frag.appendChild(renderMentionsOnly(s.slice(lastIndex, start)));
}
const parts = splitTrailingPunctuation(raw);
const linkText = parts.clean;
const trailing = parts.trailing;
if (linkText) {
const href = /^https?:\/\//i.test(linkText) ? linkText : ('https://' + linkText);
const a = document.createElement('a');
a.className = 'admin-chat-link';
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = linkText;
frag.appendChild(a);
}
if (trailing) {
frag.appendChild(document.createTextNode(trailing));
}
lastIndex = start + raw.length;
}
if (lastIndex < s.length) {
frag.appendChild(renderMentionsOnly(s.slice(lastIndex)));
}
return frag;
}
function setReplyToMessage(msg) {
replyTo = msg && msg.id ? msg.id : null;
if (!replyTo) {
replyBar.style.display = 'none';
return;
}
const username = (msg && msg.username) ? String(msg.username) : 'admin';
replyTitle.textContent = 'Odpowiadasz do: ' + username;
const hasAttachment = !!(msg && msg.has_file);
if (hasAttachment) {
replySnippet.textContent = 'odpowiedź na wiadomość ' + username;
} else {
const snipRaw = (msg && msg.message ? String(msg.message) : '').replace(/\s+/g, ' ').trim();
if (!snipRaw) {
replySnippet.textContent = '(bez tekstu)';
} else {
replySnippet.textContent = snipRaw.length > 150 ? (snipRaw.slice(0, 150) + '...') : snipRaw;
}
}
replyBar.style.display = 'flex';
chatInput.focus();
}
function clearReply() {
replyTo = null;
replyBar.style.display = 'none';
replyTitle.textContent = 'Odpowiadasz…';
replySnippet.textContent = '';
}
function renderChatMessage(msg) {
const wrap = document.createElement('div');
wrap.className = 'admin-chat-message';
wrap.dataset.id = msg.id;
wrap.dataset.updatedAt = msg.updated_at ? String(msg.updated_at) : '';
wrap.dataset.renderSig = getMessageRenderSignature(msg);
const recalledText = 'Wiadomość cofnięta';
const isRecalled = ((msg.message || '').toString() === recalledText);
if (isRecalled) wrap.classList.add('recalled');
if (CURRENT_USERNAME && msg.username && msg.username === CURRENT_USERNAME) {
wrap.classList.add('me');
}
const header = document.createElement('div');
header.className = 'admin-chat-header';
const user = document.createElement('div');
user.className = 'admin-chat-user';
user.textContent = msg.username || 'admin';
const time = document.createElement('div');
time.className = 'admin-chat-time';
time.textContent = formatTime(msg.created_at) + (isEdited(msg) ? ' • edytowano' : '');
header.appendChild(user);
header.appendChild(time);
wrap.appendChild(header);
if (isHeartedMessage(msg)) {
const pin = document.createElement('div');
pin.className = 'admin-chat-heart-pin';
pin.textContent = getHeartPinText(msg);
wrap.appendChild(pin);
}
if (msg.reply_to_id && (msg.reply_username || msg.reply_message)) {
const quote = document.createElement('div');
quote.className = 'admin-chat-reply-quote';
const meta = document.createElement('div');
meta.className = 'admin-muted';
meta.textContent = 'Odpowiedź do: ' + (msg.reply_username || '');
const q = document.createElement('div');
q.className = 'admin-chat-text admin-chat-text-quote';
q.style.marginTop = '0';
q.textContent = (msg.reply_message || '').toString().slice(0, 220);
quote.appendChild(meta);
quote.appendChild(q);
wrap.appendChild(quote);
}
if ((msg.message || '') !== '') {
const text = document.createElement('div');
text.className = 'admin-chat-text admin-chat-text-main';
text.appendChild(renderTextWithMentions(msg.message));
wrap.appendChild(text);
}
function isPreviewableImageAttachment(m) {
const mime = ((m && m.file_mime) ? String(m.file_mime) : '').toLowerCase();
if (mime === 'image/jpeg' || mime === 'image/jpg' || mime === 'image/png' || mime === 'image/gif') return true;
const name = ((m && m.file_name) ? String(m.file_name) : '').toLowerCase();
return /\.(jpe?g|png|gif)$/.test(name);
}
if (msg.has_file) {
const att = document.createElement('div');
att.className = 'admin-chat-attachment';
const fileUrl = CHAT_FILE_URL + '?id=' + encodeURIComponent(msg.id);
if (isPreviewableImageAttachment(msg)) {
const a = document.createElement('a');
a.href = fileUrl;
a.download = msg.file_name ? String(msg.file_name) : 'zalacznik';
const img = document.createElement('img');
img.alt = msg.file_name ? ('Załącznik: ' + msg.file_name) : 'Załącznik';
img.src = fileUrl + '&inline=1';
a.appendChild(img);
att.appendChild(a);
} else {
const a = document.createElement('a');
a.href = fileUrl;
a.download = msg.file_name ? String(msg.file_name) : 'zalacznik';
a.textContent = 'Pobierz plik' + (msg.file_name ? (': ' + msg.file_name) : '');
att.appendChild(a);
}
wrap.appendChild(att);
}
const actions = document.createElement('div');
actions.className = 'admin-chat-actions';
const replyBtn = document.createElement('button');
replyBtn.type = 'button';
replyBtn.className = 'admin-chat-action-btn';
replyBtn.dataset.action = 'reply';
replyBtn.textContent = 'Odpowiedz';
replyBtn.addEventListener('click', function () {
setReplyToMessage(msg);
});
actions.appendChild(replyBtn);
const heartBtn = document.createElement('button');
heartBtn.type = 'button';
heartBtn.className = 'admin-chat-action-btn';
heartBtn.dataset.action = 'heart';
heartBtn.textContent = getHeartButtonText(msg);
heartBtn.classList.toggle('active-heart', isHeartedMessage(msg));
heartBtn.addEventListener('click', function () {
toggleMessageHeart(wrap, msg);
});
actions.appendChild(heartBtn);
if (!isRecalled && CURRENT_USERNAME && msg.username && msg.username === CURRENT_USERNAME && (msg.message || '').toString().trim() !== '') {
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'admin-chat-action-btn';
editBtn.dataset.action = 'edit';
editBtn.textContent = 'Edytuj';
editBtn.addEventListener('click', function () {
openInlineEdit(wrap, msg);
});
actions.appendChild(editBtn);
}
if (!isRecalled && CURRENT_USERNAME && msg.username && msg.username === CURRENT_USERNAME) {
const recallBtn = document.createElement('button');
recallBtn.type = 'button';
recallBtn.className = 'admin-chat-action-btn';
recallBtn.dataset.action = 'recall';
recallBtn.textContent = 'Cofnij';
recallBtn.addEventListener('click', function () {
recallChatMessage(wrap, msg);
});
actions.appendChild(recallBtn);
}
wrap.appendChild(actions);
return wrap;
}
let activeInlineEditId = null;
async function toggleMessageHeart(messageEl, msg) {
try {
const id = msg && msg.id ? intOrZero(msg.id) : 0;
if (!id) return;
const heartBtn = messageEl ? messageEl.querySelector('.admin-chat-action-btn[data-action="heart"]') : null;
if (heartBtn) heartBtn.disabled = true;
const res = await fetch(CHAT_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle_heart', id: id })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd zmiany serduszka');
const updated = json.data;
if (updated) {
msg.is_hearted = !!updated.is_hearted;
msg.hearted_by_user_id = updated.hearted_by_user_id;
msg.hearted_by_username = updated.hearted_by_username;
msg.hearted_at = updated.hearted_at;
msg.updated_at = updated.updated_at;
updateMessageInDom(updated);
}
} catch (e) {
showError(chatError, 'Czat: ' + (e && e.message ? e.message : 'nieznany błąd'));
} finally {
const heartBtn = messageEl ? messageEl.querySelector('.admin-chat-action-btn[data-action="heart"]') : null;
if (heartBtn) heartBtn.disabled = false;
}
}
async function recallChatMessage(messageEl, msg) {
try {
const ok = await confirmModal({
title: 'Cofnij wiadomość',
message: 'Treść wiadomości i załącznik zostaną usunięte. Pozostanie tylko: "Wiadomość cofnięta". Kontynuować?',
confirmText: 'Cofnij',
confirmClassName: 'admin-btn admin-btn-danger'
});
if (!ok) return;
if (messageEl && messageEl.dataset && messageEl.dataset.editing === '1') {
closeInlineEdit(messageEl);
}
const id = msg && msg.id ? (intOrZero(msg.id)) : 0;
if (!id) return;
const res = await fetch(CHAT_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'recall', id: id })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd cofania');
const updated = json.data;
if (updated) {
msg.message = updated.message;
msg.updated_at = updated.updated_at;
msg.created_at = updated.created_at;
msg.has_file = !!updated.has_file;
msg.file_name = updated.file_name;
msg.file_mime = updated.file_mime;
msg.file_size = updated.file_size;
updateMessageInDom(updated);
}
} catch (e) {
showError(chatError, 'Czat: ' + (e && e.message ? e.message : 'nieznany błąd'));
}
}
function intOrZero(v) {
const n = parseInt(String(v), 10);
return Number.isFinite(n) ? n : 0;
}
function openInlineEdit(messageEl, msg) {
if (!messageEl || !msg || !msg.id) return;
const id = msg.id;
if (activeInlineEditId && activeInlineEditId !== id) {
const other = chatBox.querySelector('.admin-chat-message[data-id="' + String(activeInlineEditId) + '"]');
if (other) closeInlineEdit(other);
}
activeInlineEditId = id;
if (messageEl.dataset.editing === '1') return;
messageEl.dataset.editing = '1';
messageEl.classList.add('editing');
const textEl = messageEl.querySelector('.admin-chat-text-main');
if (!textEl) return;
const original = (msg.message || '').toString();
messageEl._inlineEditOriginal = original;
messageEl._inlineEditOriginalHtml = textEl.innerHTML;
let selectedFile = null;
let clearExistingFile = false;
const ta = document.createElement('textarea');
ta.className = 'admin-chat-inline-textarea';
ta.value = original;
ta.maxLength = LIMITS.chatMessage;
ta.rows = 1;
ta.addEventListener('keydown', function (e) {
const isPrintable = e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
if (!isPrintable) return;
const maxLen = LIMITS.chatMessage;
const val = (ta.value || '').toString();
const selStart = typeof ta.selectionStart === 'number' ? ta.selectionStart : val.length;
const selEnd = typeof ta.selectionEnd === 'number' ? ta.selectionEnd : val.length;
const replacing = selEnd > selStart;
if (!replacing && val.length >= maxLen) {
pulseRedBorder(ta);
}
});
ta.addEventListener('paste', function () {
setTimeout(function () {
const maxLen = LIMITS.chatMessage;
const val = (ta.value || '').toString();
if (val.length >= maxLen) pulseRedBorder(ta);
}, 0);
});
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*,application/pdf,text/plain';
fileInput.style.display = 'none';
const toolbar = document.createElement('div');
toolbar.className = 'admin-chat-inline-toolbar';
const btnCancel = document.createElement('button');
btnCancel.type = 'button';
btnCancel.className = 'admin-chat-inline-toolbtn';
btnCancel.textContent = 'Anuluj';
const btnSave = document.createElement('button');
btnSave.type = 'button';
btnSave.className = 'admin-chat-inline-toolbtn';
btnSave.textContent = 'Zapisz';
toolbar.appendChild(btnCancel);
toolbar.appendChild(btnSave);
const err = document.createElement('div');
err.className = 'admin-chat-inline-error';
const filebar = document.createElement('div');
filebar.className = 'admin-chat-inline-filebar';
const fileName = document.createElement('div');
fileName.className = 'name';
const fileActions = document.createElement('div');
fileActions.className = 'actions';
const btnPickFile = document.createElement('button');
btnPickFile.type = 'button';
btnPickFile.className = 'admin-chat-inline-toolbtn';
btnPickFile.textContent = (msg.has_file ? 'Zmień plik' : 'Dodaj plik');
const btnRemoveFile = document.createElement('button');
btnRemoveFile.type = 'button';
btnRemoveFile.className = 'admin-chat-inline-toolbtn';
btnRemoveFile.textContent = 'Usuń plik';
fileActions.appendChild(btnPickFile);
fileActions.appendChild(btnRemoveFile);
filebar.appendChild(fileName);
filebar.appendChild(fileActions);
function refreshFileUi() {
const hasOrig = !!msg.has_file;
const hasSel = !!selectedFile;
const cleared = !!clearExistingFile;
if (hasSel) {
fileName.textContent = 'Nowy: ' + (selectedFile.name || 'plik');
} else if (hasOrig && !cleared) {
fileName.textContent = 'Aktualny: ' + (msg.file_name || 'załącznik');
} else if (hasOrig && cleared) {
fileName.textContent = 'Załącznik zostanie usunięty';
} else {
fileName.textContent = 'Brak załącznika';
}
btnRemoveFile.style.display = (hasOrig || hasSel) ? 'inline' : 'none';
btnPickFile.textContent = (hasOrig || hasSel) ? 'Zmień plik' : 'Dodaj plik';
}
textEl.innerHTML = '';
textEl.appendChild(ta);
textEl.appendChild(fileInput);
textEl.appendChild(toolbar);
textEl.appendChild(filebar);
textEl.appendChild(err);
refreshFileUi();
btnPickFile.addEventListener('click', function () {
fileInput.click();
});
btnRemoveFile.addEventListener('click', function () {
selectedFile = null;
try { fileInput.value = ''; } catch (e) { }
if (msg.has_file) {
clearExistingFile = true;
}
refreshFileUi();
clearInlineError();
});
fileInput.addEventListener('change', function () {
const f = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
if (!f) return;
selectedFile = f;
clearExistingFile = false;
refreshFileUi();
clearInlineError();
});
function autosizeInline() {
try {
ta.style.height = 'auto';
const h = Math.min(150, Math.max(24, ta.scrollHeight || 24));
ta.style.height = h + 'px';
} catch (e) { }
}
function showInlineError(message) {
err.style.display = 'block';
err.textContent = message;
}
function clearInlineError() {
err.style.display = 'none';
err.textContent = '';
}
async function save() {
clearInlineError();
const newMsg = (ta.value || '').toString();
const trimmed = newMsg.trim();
if (!trimmed) {
showInlineError('Wiadomość nie może być pusta');
return;
}
if (newMsg.length > LIMITS.chatMessage) {
showInlineError('Wiadomość jest zbyt długa (max ' + String(LIMITS.chatMessage) + ')');
return;
}
const fileChanged = !!selectedFile || !!clearExistingFile;
if (newMsg === original && !fileChanged) {
closeInlineEdit(messageEl);
return;
}
btnSave.disabled = true;
btnCancel.disabled = true;
btnPickFile.disabled = true;
btnRemoveFile.disabled = true;
try {
let res;
if (selectedFile || clearExistingFile) {
const fd = new FormData();
fd.append('action', 'update');
fd.append('id', String(id));
fd.append('message', newMsg);
if (clearExistingFile) fd.append('clear_file', '1');
if (selectedFile) fd.append('file', selectedFile, selectedFile.name);
res = await fetch(CHAT_URL, { method: 'POST', credentials: 'same-origin', body: fd });
} else {
res = await fetch(CHAT_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update', id: id, message: newMsg })
});
}
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd edycji');
const updated = json.data;
if (updated) {
msg.message = updated.message;
msg.updated_at = updated.updated_at;
msg.created_at = updated.created_at;
msg.has_file = !!updated.has_file;
msg.file_name = updated.file_name;
msg.file_mime = updated.file_mime;
msg.file_size = updated.file_size;
}
closeInlineEdit(messageEl);
if (updated) updateMessageInDom(updated);
} catch (e) {
showInlineError('Nie udało się zapisać: ' + (e && e.message ? e.message : 'błąd'));
} finally {
btnSave.disabled = false;
btnCancel.disabled = false;
btnPickFile.disabled = false;
btnRemoveFile.disabled = false;
}
}
btnCancel.addEventListener('click', function () {
closeInlineEdit(messageEl);
});
btnSave.addEventListener('click', function () {
save();
});
ta.addEventListener('input', function () {
autosizeInline();
clearInlineError();
});
ta.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
e.preventDefault();
closeInlineEdit(messageEl);
return;
}
// Enter saves, Shift+Enter adds newline
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
save();
}
});
setTimeout(function () {
try {
autosizeInline();
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
} catch (e) { }
}, 0);
}
function closeInlineEdit(messageEl) {
if (!messageEl) return;
if (messageEl.dataset.editing !== '1') return;
const textEl = messageEl.querySelector('.admin-chat-text-main');
if (textEl) {
// restore original HTML to keep bubble looking the same
if (typeof messageEl._inlineEditOriginalHtml === 'string') {
textEl.innerHTML = messageEl._inlineEditOriginalHtml;
} else {
textEl.innerHTML = '';
textEl.appendChild(renderTextWithMentions((messageEl._inlineEditOriginal || '').toString()));
}
}
messageEl._inlineEditOriginal = null;
messageEl._inlineEditOriginalHtml = null;
messageEl.dataset.editing = '0';
messageEl.classList.remove('editing');
activeInlineEditId = null;
}
function appendMessages(msgs) {
if (!Array.isArray(msgs) || msgs.length === 0) return;
const wasNearTop = isNearTop(chatBox);
const ordered = msgs.slice().sort(function (a, b) {
return intOrZero(a && a.id) - intOrZero(b && b.id);
});
let hasOther = false;
for (const m of ordered) {
if (!m || !m.id) continue;
const messageId = intOrZero(m.id);
if (!messageId) continue;
m.id = messageId;
// If the typer sends a message, remove their typing bubble immediately
if (typingBubbleUserId && m.user_id && String(m.user_id) === String(typingBubbleUserId)) {
typingSuppressUntil.set(String(typingBubbleUserId), Date.now() + 3500);
typingState.delete(String(typingBubbleUserId));
hideTypingBubble();
}
if (chatBox.querySelector('.admin-chat-message[data-id="' + String(messageId) + '"]')) {
updateMessageInDom(m);
} else {
if (m.username && CURRENT_USERNAME && m.username !== CURRENT_USERNAME) {
hasOther = true;
}
const messageEl = renderChatMessage(m);
if (typingBubbleEl && typingBubbleEl.parentNode === chatBox) {
if (typingBubbleEl.nextSibling) {
chatBox.insertBefore(messageEl, typingBubbleEl.nextSibling);
} else {
chatBox.appendChild(messageEl);
}
} else {
chatBox.insertBefore(messageEl, chatBox.firstChild);
}
}
chatNewestId = (!chatNewestId || messageId > chatNewestId) ? messageId : chatNewestId;
if (chatOldestId === null) chatOldestId = messageId;
}
if (hasOther) {
tryPlay(audioNewMessage);
}
ensureTypingBubbleAtTop();
if (wasNearTop) {
chatBox.scrollTop = 0;
}
}
function prependMessages(msgsOldToNew) {
if (!Array.isArray(msgsOldToNew) || msgsOldToNew.length === 0) return;
const prevScrollTop = chatBox.scrollTop;
const frag = document.createDocumentFragment();
const oldestId = intOrZero(msgsOldToNew[0] && msgsOldToNew[0].id);
if (oldestId) chatOldestId = oldestId;
for (const m of msgsOldToNew) {
const messageId = intOrZero(m && m.id);
if (!messageId) continue;
m.id = messageId;
frag.appendChild(renderChatMessage(m));
if (chatNewestId === null) chatNewestId = messageId;
}
chatBox.appendChild(frag);
chatBox.scrollTop = prevScrollTop;
}
async function loadInitialChat() {
clearError(chatError);
chatStatus.textContent = 'Ładowanie…';
try {
const res = await fetch(CHAT_URL, { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd API');
const rowsDesc = json.data || [];
const rows = rowsDesc.slice().reverse();
chatBox.innerHTML = '';
chatOldestId = null;
chatNewestId = null;
chatHasMore = !!json.hasMore;
appendMessages(rows);
chatBox.scrollTop = 0;
chatBaseStatus = 'OK';
chatStatus.textContent = chatBaseStatus;
} catch (e) {
chatBaseStatus = 'Błąd';
chatStatus.textContent = chatBaseStatus;
showError(chatError, 'Czat: ' + (e && e.message ? e.message : 'nieznany błąd'));
}
}
async function loadOlderChat() {
if (chatLoadingOlder || !chatHasMore || !chatOldestId) return;
chatLoadingOlder = true;
chatStatus.textContent = 'Wczytywanie starszych…';
try {
const url = CHAT_URL + '?before_id=' + encodeURIComponent(chatOldestId) + '&limit=100';
const res = await fetch(url, { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd API');
const rowsDesc = json.data || [];
prependMessages(rowsDesc);
chatHasMore = !!json.hasMore;
chatBaseStatus = chatHasMore ? 'OK' : 'Początek historii';
chatStatus.textContent = chatBaseStatus;
} catch (e) {
chatBaseStatus = 'Błąd';
chatStatus.textContent = chatBaseStatus;
showError(chatError, 'Czat: ' + (e && e.message ? e.message : 'nieznany błąd'));
} finally {
chatLoadingOlder = false;
}
}
async function pollNewChat() {
if (!chatNewestId) return;
try {
const url = CHAT_URL + '?after_id=' + encodeURIComponent(chatNewestId) + '&limit=200';
const res = await fetch(url, { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) return;
const rows = json.data || [];
if (rows.length > 0) {
clearError(chatError);
const shouldStickToTop = isNearTop(chatBox);
appendMessages(rows);
if (shouldStickToTop) {
chatBox.scrollTop = 0;
}
}
} catch (e) {
}
}
async function pollEditedChat() {
let nextPollCursor = chatLastEditsPollAt;
try {
const url = CHAT_URL + '?updated_after=' + encodeURIComponent(String(chatLastEditsPollAt)) + '&limit=200';
const res = await fetch(url, { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) return;
const rows = json.data || [];
if (rows.length > 0) {
for (const r of rows) {
updateMessageInDom(r);
const ts = r && r.updated_at_ts ? intOrZero(r.updated_at_ts) : 0;
if (ts > nextPollCursor) nextPollCursor = ts;
}
}
} catch (e) {
} finally {
chatLastEditsPollAt = Math.max(chatLastEditsPollAt, nextPollCursor);
}
}
function getMentionQuery() {
const value = (chatInput.value || '').toString();
const pos = chatInput.selectionStart || 0;
const before = value.slice(0, pos);
const at = before.lastIndexOf('@');
if (at < 0) return null;
if (at > 0 && !/\s/.test(before.charAt(at - 1))) return null;
const tail = before.slice(at + 1);
if (/\s/.test(tail)) return null;
return { atIndex: at, query: tail };
}
function hideMentions() {
mentionVisible = false;
mentionActiveIndex = 0;
mentionBox.style.display = 'none';
mentionBox.innerHTML = '';
}
function showMentions(items) {
if (!items || items.length === 0) {
hideMentions();
return;
}
mentionVisible = true;
mentionBox.style.display = 'block';
mentionBox.innerHTML = '';
items.forEach(function (u, idx) {
const el = document.createElement('div');
el.className = 'admin-mention-item' + (idx === mentionActiveIndex ? ' active' : '');
el.textContent = '@' + u;
el.addEventListener('mousedown', function (e) {
e.preventDefault();
insertMention(u);
});
mentionBox.appendChild(el);
});
}
function refreshMentionBox() {
const info = getMentionQuery();
if (!info) {
hideMentions();
return;
}
const q = (info.query || '').toLowerCase();
const names = admins.map(function (a) { return a.username; });
const filtered = names.filter(function (u) {
return u && u.toLowerCase().indexOf(q) === 0;
}).slice(0, 8);
mentionActiveIndex = 0;
showMentions(filtered);
}
function insertMention(username) {
const info = getMentionQuery();
if (!info) return;
const value = (chatInput.value || '').toString();
const pos = chatInput.selectionStart || 0;
const before = value.slice(0, info.atIndex);
const after = value.slice(pos);
const mentionText = '@' + username + ' ';
chatInput.value = before + mentionText + after;
const newPos = (before + mentionText).length;
chatInput.setSelectionRange(newPos, newPos);
hideMentions();
chatInput.focus();
chatCounter.update();
}
async function sendChatMessage() {
if (chatSending) return;
clearError(chatError);
chatSend.disabled = true;
if (chatAttachBtn) chatAttachBtn.disabled = true;
chatSending = true;
try {
const fd = new FormData();
const msg = (chatInput.value || '').toString();
const trimmed = msg.trim();
if (msg.length > LIMITS.chatMessage) {
throw new Error('Wiadomość jest zbyt długa (max ' + String(LIMITS.chatMessage) + ' znaków)');
}
const file = chatFile && chatFile.files && chatFile.files[0] ? chatFile.files[0] : null;
if (!trimmed && !file) {
chatSend.disabled = false;
if (chatAttachBtn) chatAttachBtn.disabled = false;
chatSending = false;
return;
}
fd.append('message', msg);
if (replyTo) fd.append('reply_to_id', String(replyTo));
if (file) fd.append('file', file, file.name);
const res = await fetch(CHAT_URL, {
method: 'POST',
credentials: 'same-origin',
body: fd
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd zapisu');
const m = json.data;
if (m) {
appendMessages([m]);
chatBox.scrollTop = 0;
tryPlay(audioNewMessage);
}
chatInput.value = '';
autosizeChatInput();
chatCounter.update();
if (chatFile) chatFile.value = '';
if (chatFileChip) chatFileChip.style.display = 'none';
if (chatFileChipName) chatFileChipName.textContent = 'Załącznik';
clearReply();
hideMentions();
chatInput.focus();
} catch (e) {
showError(chatError, 'Nie udało się wysłać: ' + (e && e.message ? e.message : 'nieznany błąd'));
} finally {
chatSend.disabled = false;
if (chatAttachBtn) chatAttachBtn.disabled = false;
chatSending = false;
}
}
async function loadTasks() {
clearError(tasksError);
tasksStatus.textContent = 'Ładowanie…';
try {
const res = await fetch(TASKS_URL + '?limit=50', { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd API');
const rows = json.data || [];
tasksList.innerHTML = '';
if (rows.length === 0) {
const empty = document.createElement('div');
empty.className = 'admin-empty';
empty.textContent = 'Brak tasków';
tasksList.appendChild(empty);
}
for (const t of rows) {
const item = document.createElement('div');
item.className = 'admin-item ' + (t.is_done ? 'done' : 'todo');
const header = document.createElement('div');
header.className = 'admin-item-header';
const title = document.createElement('div');
title.className = 'admin-item-title';
title.textContent = t.title || '(bez tytułu)';
const badges = document.createElement('div');
badges.className = 'admin-item-badges';
const statusBadge = document.createElement('span');
statusBadge.className = 'admin-badge ' + (t.is_done ? 'done' : 'todo');
statusBadge.textContent = t.is_done ? 'Wykonane' : 'Do zrobienia';
badges.appendChild(statusBadge);
header.appendChild(title);
header.appendChild(badges);
const meta = document.createElement('div');
meta.className = 'admin-item-meta';
const by = t.created_by_username || 'admin';
const at = formatTime(t.created_at);
if (t.is_done) {
const doneBy = t.done_by_username || 'admin';
const doneAt = formatTime(t.done_at);
meta.textContent = by + ' • ' + at + ' • wykonane przez: ' + doneBy + (doneAt ? (' • ' + doneAt) : '');
} else {
meta.textContent = by + ' • ' + at;
}
const desc = document.createElement('div');
desc.className = 'admin-item-desc';
desc.textContent = t.description || '';
item.appendChild(header);
item.appendChild(meta);
if ((t.description || '') !== '') item.appendChild(desc);
const attachments = Array.isArray(t.attachments) ? t.attachments : [];
if (attachments.length > 0) {
const filesWrap = document.createElement('div');
filesWrap.style.marginTop = '8px';
for (const att of attachments) {
const link = document.createElement('a');
link.href = (att && att.download_url) ? String(att.download_url) : ('/api/admin_task_file.php?id=' + encodeURIComponent(t.id));
link.target = '_blank';
link.rel = 'noopener';
link.style.display = 'block';
link.style.marginTop = '4px';
link.textContent = 'Pobierz plik' + (att && att.name ? ': ' + String(att.name) : '');
filesWrap.appendChild(link);
}
item.appendChild(filesWrap);
}
const actions = document.createElement('div');
actions.className = 'admin-task-actions';
const btnToggle = document.createElement('button');
btnToggle.type = 'button';
btnToggle.className = 'admin-btn admin-btn-small ' + (t.is_done ? 'admin-btn-secondary' : 'admin-btn-success');
btnToggle.textContent = t.is_done ? 'Oznacz jako do zrobienia' : 'Oznacz jako wykonane';
btnToggle.addEventListener('click', function () {
toggleTaskDone(t.id, !t.is_done);
});
const btnEdit = document.createElement('button');
btnEdit.type = 'button';
btnEdit.className = 'admin-btn admin-btn-small admin-btn-secondary';
btnEdit.textContent = 'Edytuj';
btnEdit.addEventListener('click', function () {
openTaskEditor(item, t);
});
const btnComments = document.createElement('button');
btnComments.type = 'button';
btnComments.className = 'admin-btn admin-btn-small admin-btn-secondary';
const commentsCount = Number(t.comments_count || 0);
btnComments.textContent = 'Komentarze (' + String(commentsCount) + ')';
btnComments.addEventListener('click', function () {
openTaskComments(t);
});
const btnDel = document.createElement('button');
btnDel.type = 'button';
btnDel.className = 'admin-btn admin-btn-small admin-btn-danger';
btnDel.textContent = 'Usuń';
btnDel.addEventListener('click', function () {
deleteTask(t.id);
});
actions.appendChild(btnToggle);
actions.appendChild(btnComments);
actions.appendChild(btnEdit);
actions.appendChild(btnDel);
item.appendChild(actions);
tasksList.appendChild(item);
}
tasksStatus.textContent = 'OK (' + rows.length + ')';
} catch (e) {
tasksStatus.textContent = 'Błąd';
showError(tasksError, 'Notatki: ' + (e && e.message ? e.message : 'nieznany błąd'));
}
}
async function toggleTaskDone(id, isDone) {
clearError(tasksError);
tasksStatus.textContent = 'Zapisywanie…';
try {
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle_done', id: id, is_done: !!isDone })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd');
await loadTasks();
tasksStatus.textContent = 'OK';
} catch (e) {
tasksStatus.textContent = 'Błąd';
showError(tasksError, 'Nie udało się zmienić statusu: ' + (e && e.message ? e.message : 'nieznany błąd'));
}
}
async function deleteTask(id) {
clearError(tasksError);
const ok = await confirmModal({
title: 'Usuwanie taska',
message: 'Czy na pewno chcesz usunąć ten task? Tej operacji nie da się cofnąć.',
cancelText: 'Anuluj',
confirmText: 'Usuń',
confirmClassName: 'admin-btn admin-btn-danger'
});
if (!ok) return;
tasksStatus.textContent = 'Usuwanie…';
try {
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete', id: id })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd');
await loadTasks();
tasksStatus.textContent = 'OK';
} catch (e) {
tasksStatus.textContent = 'Błąd';
showError(tasksError, 'Nie udało się usunąć: ' + (e && e.message ? e.message : 'nieznany błąd'));
}
}
function openTaskEditor(_containerEl, task) {
const wrap = document.createElement('div');
wrap.className = 'admin-task-edit';
const errorInline = document.createElement('div');
errorInline.className = 'admin-status';
errorInline.style.display = 'none';
errorInline.style.color = '#b32d2e';
const labTitle = document.createElement('label');
labTitle.textContent = 'Tytuł';
const inpTitle = document.createElement('input');
inpTitle.type = 'text';
inpTitle.value = (task.title || '').toString();
inpTitle.placeholder = 'Tytuł';
inpTitle.maxLength = LIMITS.taskTitle;
const titleHint = document.createElement('div');
titleHint.className = 'admin-charhint';
const labDesc = document.createElement('label');
labDesc.textContent = 'Opis';
const taDesc = document.createElement('textarea');
taDesc.value = (task.description || '').toString();
taDesc.placeholder = 'Opis';
taDesc.maxLength = LIMITS.taskDescription;
const descHint = document.createElement('div');
descHint.className = 'admin-charhint';
const attachments = Array.isArray(task.attachments) ? task.attachments : [];
const labFile = document.createElement('label');
labFile.textContent = 'Dodaj nowe pliki (opcjonalnie)';
const file = document.createElement('input');
file.type = 'file';
file.name = 'files[]';
file.multiple = true;
const newFilesInfo = document.createElement('div');
newFilesInfo.className = 'admin-muted';
newFilesInfo.style.fontSize = '12px';
newFilesInfo.style.marginTop = '4px';
newFilesInfo.textContent = 'Nie wybrano nowych plików';
const newFilesList = document.createElement('div');
newFilesList.className = 'admin-task-files-list';
const editFilesCtrl = makeFilesController(file, newFilesInfo, newFilesList, 'Nie wybrano nowych plików');
editFilesCtrl.render();
const existingWrap = document.createElement('div');
existingWrap.style.marginTop = '8px';
const pendingDeleteIds = new Set();
let pendingClearLegacy = false;
function renderExistingAttachments() {
existingWrap.innerHTML = '';
const visible = attachments.filter(function (att) {
const isLegacy = !!(att && att.legacy);
const fid = (att && att.id != null) ? Number(att.id) : null;
if (isLegacy) return !pendingClearLegacy;
if (fid !== null && !Number.isNaN(fid)) return !pendingDeleteIds.has(fid);
return true;
});
if (visible.length === 0) {
const noExisting = document.createElement('div');
noExisting.className = 'admin-muted';
noExisting.textContent = 'Brak aktualnych załączników';
existingWrap.appendChild(noExisting);
return;
}
const existingTitle = document.createElement('div');
existingTitle.className = 'admin-muted';
existingTitle.style.marginBottom = '6px';
existingTitle.textContent = 'Aktualne załączniki';
existingWrap.appendChild(existingTitle);
visible.forEach(function (att) {
const row = document.createElement('div');
row.className = 'admin-task-file-item';
const name = document.createElement('span');
name.className = 'admin-task-file-item-name';
name.textContent = (att && att.name) ? String(att.name) : 'załącznik';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'admin-task-file-item-remove';
removeBtn.setAttribute('aria-label', 'Usuń załącznik');
removeBtn.textContent = '×';
removeBtn.addEventListener('click', function () {
if (att && att.legacy) {
pendingClearLegacy = true;
} else if (att && att.id != null) {
pendingDeleteIds.add(Number(att.id));
}
renderExistingAttachments();
});
row.appendChild(name);
row.appendChild(removeBtn);
existingWrap.appendChild(row);
});
}
renderExistingAttachments();
wrap.appendChild(errorInline);
wrap.appendChild(labTitle);
wrap.appendChild(inpTitle);
wrap.appendChild(titleHint);
wrap.appendChild(labDesc);
wrap.appendChild(taDesc);
wrap.appendChild(descHint);
wrap.appendChild(labFile);
wrap.appendChild(file);
wrap.appendChild(newFilesInfo);
wrap.appendChild(newFilesList);
wrap.appendChild(existingWrap);
const modalTitleCounter = wireCharHint(inpTitle, titleHint, LIMITS.taskTitle);
const modalDescCounter = wireCharHint(taDesc, descHint, LIMITS.taskDescription);
const cancel = function () {
closeModal();
};
openModal({
title: 'Edycja taska #' + String(task.id),
bodyEl: wrap,
onCancel: cancel,
initialFocusEl: inpTitle,
buttons: [
{
text: 'Anuluj',
className: 'admin-btn admin-btn-secondary',
onClick: cancel
},
{
text: 'Zapisz',
className: 'admin-btn admin-btn-success',
onClick: async function (btn) {
errorInline.style.display = 'none';
errorInline.textContent = '';
modalTitleCounter.update();
modalDescCounter.update();
const tTitle = (inpTitle.value || '').toString();
const tDesc = (taDesc.value || '').toString();
if (tTitle.trim() === '') {
errorInline.style.display = 'block';
errorInline.textContent = 'Tytuł jest wymagany';
return;
}
if (tTitle.length > LIMITS.taskTitle) {
errorInline.style.display = 'block';
errorInline.textContent = 'Tytuł jest zbyt długi (max ' + String(LIMITS.taskTitle) + ' znaków)';
return;
}
if (tDesc.length > LIMITS.taskDescription) {
errorInline.style.display = 'block';
errorInline.textContent = 'Opis jest zbyt długi (max ' + String(LIMITS.taskDescription) + ' znaków)';
return;
}
btn.disabled = true;
tasksStatus.textContent = 'Zapisywanie…';
try {
const fd = new FormData();
fd.append('action', 'update');
fd.append('id', String(task.id));
fd.append('title', tTitle);
fd.append('description', tDesc);
const deleteIds = Array.from(pendingDeleteIds);
const clearLegacy = pendingClearLegacy;
const selectedFiles = editFilesCtrl.getFiles();
const validationError = validateTaskFiles(selectedFiles);
if (validationError) {
throw new Error(validationError);
}
const existingCount = attachments.length;
const deleteCount = deleteIds.length + (clearLegacy ? 1 : 0);
const totalAfterSave = Math.max(0, existingCount - deleteCount) + selectedFiles.length;
if (totalAfterSave > TASK_ATTACHMENTS.maxCount) {
throw new Error('Task może mieć maksymalnie ' + String(TASK_ATTACHMENTS.maxCount) + ' załączników');
}
if (clearLegacy) fd.append('clear_file', '1');
deleteIds.forEach(function (id) {
fd.append('delete_file_ids[]', String(id));
});
if (selectedFiles.length > 0) {
selectedFiles.forEach(function (f) {
fd.append('files[]', f, f.name);
});
}
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
body: fd
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd zapisu');
closeModal();
await loadTasks();
tasksStatus.textContent = 'OK';
} catch (e) {
tasksStatus.textContent = 'Błąd';
errorInline.style.display = 'block';
errorInline.textContent = 'Nie udało się zapisać: ' + (e && e.message ? e.message : 'nieznany błąd');
} finally {
btn.disabled = false;
}
}
}
]
});
}
function openTaskComments(task) {
const wrap = document.createElement('div');
wrap.className = 'admin-task-comments-wrap';
const info = document.createElement('div');
info.className = 'admin-muted';
info.textContent = 'Wątek komentarzy dla taska #' + String(task.id);
const errorInline = document.createElement('div');
errorInline.className = 'admin-status';
errorInline.style.display = 'none';
errorInline.style.color = '#b32d2e';
const list = document.createElement('div');
list.className = 'admin-task-comments-list';
const form = document.createElement('div');
form.className = 'admin-task-comments-form';
const commentInput = document.createElement('textarea');
commentInput.maxLength = LIMITS.taskComment;
commentInput.placeholder = 'Dodaj komentarz do tego taska…';
const commentHint = document.createElement('div');
commentHint.className = 'admin-charhint';
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.justifyContent = 'space-between';
actions.style.gap = '8px';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'admin-btn admin-btn-secondary';
refreshBtn.textContent = 'Odśwież';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'admin-btn admin-btn-success';
addBtn.textContent = 'Dodaj komentarz';
actions.appendChild(refreshBtn);
actions.appendChild(addBtn);
form.appendChild(commentInput);
form.appendChild(commentHint);
form.appendChild(actions);
wrap.appendChild(info);
wrap.appendChild(errorInline);
wrap.appendChild(list);
wrap.appendChild(form);
const commentCounter = wireCharHint(commentInput, commentHint, LIMITS.taskComment);
async function fetchComments() {
try {
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'list_comments', task_id: task.id })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd API');
return Array.isArray(json.data) ? json.data : [];
} catch (e) {
throw e;
}
}
function renderComments(rows) {
list.innerHTML = '';
if (!rows || rows.length === 0) {
const empty = document.createElement('div');
empty.className = 'admin-empty';
empty.textContent = 'Brak komentarzy w tym tasku';
list.appendChild(empty);
return;
}
rows.forEach(function (row) {
const item = document.createElement('div');
item.className = 'admin-task-comment-item';
const head = document.createElement('div');
head.className = 'admin-task-comment-head';
const who = document.createElement('strong');
who.textContent = (row && row.username) ? String(row.username) : 'admin';
const right = document.createElement('div');
right.style.display = 'inline-flex';
right.style.alignItems = 'center';
right.style.gap = '8px';
const at = document.createElement('span');
at.textContent = formatTime(row && row.created_at ? String(row.created_at) : '');
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'admin-btn admin-btn-small admin-btn-danger';
delBtn.textContent = 'Usuń';
delBtn.addEventListener('click', async function () {
errorInline.style.display = 'none';
try {
const ok = await confirmModal({
title: 'Usuwanie komentarza',
message: 'Usunąć ten komentarz?',
cancelText: 'Anuluj',
confirmText: 'Usuń',
confirmClassName: 'admin-btn admin-btn-danger'
});
if (!ok) return;
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_comment', comment_id: row.id })
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Nie udało się usunąć komentarza');
await reload();
} catch (e) {
errorInline.style.display = 'block';
errorInline.textContent = e && e.message ? e.message : 'Błąd usuwania komentarza';
}
});
right.appendChild(at);
right.appendChild(delBtn);
head.appendChild(who);
head.appendChild(right);
const text = document.createElement('div');
text.className = 'admin-task-comment-text';
text.textContent = (row && row.comment) ? String(row.comment) : '';
item.appendChild(head);
item.appendChild(text);
list.appendChild(item);
});
}
async function reload() {
const rows = await fetchComments();
renderComments(rows);
}
refreshBtn.addEventListener('click', async function () {
errorInline.style.display = 'none';
try {
await reload();
} catch (e) {
errorInline.style.display = 'block';
errorInline.textContent = e && e.message ? e.message : 'Błąd odświeżania komentarzy';
}
});
addBtn.addEventListener('click', async function () {
errorInline.style.display = 'none';
commentCounter.update();
const txt = (commentInput.value || '').toString();
if (txt.trim() === '') {
errorInline.style.display = 'block';
errorInline.textContent = 'Treść komentarza jest wymagana';
return;
}
if (txt.length > LIMITS.taskComment) {
errorInline.style.display = 'block';
errorInline.textContent = 'Komentarz jest zbyt długi (max ' + String(LIMITS.taskComment) + ' znaków)';
return;
}
addBtn.disabled = true;
try {
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'add_comment',
task_id: task.id,
comment: txt
})
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Nie udało się dodać komentarza');
commentInput.value = '';
commentCounter.update();
await reload();
await loadTasks();
} catch (e) {
errorInline.style.display = 'block';
errorInline.textContent = e && e.message ? e.message : 'Błąd dodawania komentarza';
} finally {
addBtn.disabled = false;
}
});
openModal({
title: 'Komentarze — task #' + String(task.id),
bodyEl: wrap,
initialFocusEl: commentInput,
buttons: [
{
text: 'Zamknij',
className: 'admin-btn admin-btn-secondary',
onClick: function () {
closeModal();
}
}
]
});
reload().catch(function (e) {
errorInline.style.display = 'block';
errorInline.textContent = e && e.message ? e.message : 'Błąd ładowania komentarzy';
});
}
async function createTask(formEl) {
clearError(tasksError);
taskSubmit.disabled = true;
tasksStatus.textContent = 'Zapisywanie…';
try {
const tTitle = (taskTitleInput && taskTitleInput.value ? taskTitleInput.value : '').toString();
const tDesc = (taskDescInput && taskDescInput.value ? taskDescInput.value : '').toString();
taskTitleCounter.update();
taskDescCounter.update();
if (tTitle.trim() === '') {
throw new Error('Tytuł jest wymagany');
}
if (tTitle.length > LIMITS.taskTitle) {
throw new Error('Tytuł jest zbyt długi (max ' + String(LIMITS.taskTitle) + ' znaków)');
}
if (tDesc.length > LIMITS.taskDescription) {
throw new Error('Opis jest zbyt długi (max ' + String(LIMITS.taskDescription) + ' znaków)');
}
const selectedFiles = taskCreateFilesCtrl.getFiles();
const validationError = validateTaskFiles(selectedFiles);
if (validationError) {
throw new Error(validationError);
}
const fd = new FormData();
fd.append('title', tTitle);
fd.append('description', tDesc);
selectedFiles.forEach(function (f) {
fd.append('files[]', f, f.name);
});
const res = await fetch(TASKS_URL, {
method: 'POST',
credentials: 'same-origin',
body: fd
});
const json = await res.json();
if (!json.success) throw new Error(json.error || 'Błąd zapisu');
formEl.reset();
taskCreateFilesCtrl.clear();
taskTitleCounter.update();
taskDescCounter.update();
await loadTasks();
tasksStatus.textContent = 'Dodano';
} catch (e) {
tasksStatus.textContent = 'Błąd';
showError(tasksError, 'Nie udało się dodać: ' + (e && e.message ? e.message : 'nieznany błąd'));
} finally {
taskSubmit.disabled = false;
}
}
async function loadAdmins() {
try {
const res = await fetch(ADMINS_URL, { credentials: 'same-origin' });
const json = await res.json();
if (!json.success) return;
admins = json.data || [];
} catch (e) {
}
}
function pingTypingThrottled() {
const now = Date.now();
if (now - lastTypingPingAt < 350) return;
lastTypingPingAt = now;
fetch(TYPING_URL, { method: 'POST', credentials: 'same-origin' }).catch(function () { });
}
async function pollTyping() {
try {
const res = await fetch(TYPING_URL, { credentials: 'same-origin', cache: 'no-store' });
const json = await res.json();
if (!json.success) return;
const rows = json.data || [];
const now = Date.now();
// Cleanup suppress list
typingSuppressUntil.forEach(function (until, uid) {
if (!until || now >= until) typingSuppressUntil.delete(uid);
});
const clientTtlMs = 3000;
for (const r of rows) {
const uid = r && r.user_id ? String(r.user_id) : '';
if (!uid) continue;
if (typingSuppressUntil.has(uid)) continue;
const seenAtMs = r && r.updated_at_ts ? (parseInt(r.updated_at_ts, 10) * 1000) : now;
if (!typingState.has(uid)) {
typingState.set(uid, { username: (r.username || 'admin'), seenAtMs });
} else {
const prev = typingState.get(uid);
if (prev) {
prev.seenAtMs = seenAtMs;
if (!prev.username && r.username) prev.username = r.username;
}
}
}
// Remove users who stopped typing (local TTL to avoid flicker)
Array.from(typingState.entries()).forEach(function ([uid, state]) {
if (!state || !state.seenAtMs) {
typingState.delete(uid);
return;
}
if (now - state.seenAtMs > clientTtlMs) typingState.delete(uid);
});
if (typingState.size > 0) {
// Status text remains (optional)
const latest = Array.from(typingState.values()).sort(function (a, b) { return (b.seenAtMs || 0) - (a.seenAtMs || 0); })[0];
chatStatus.textContent = (latest && latest.username) ? (latest.username + ' pisze…') : 'Ktoś pisze…';
playTypingSoundFor3s();
refreshTypingBubbleFromState();
} else {
chatStatus.textContent = chatBaseStatus;
hideTypingBubble();
}
} catch (e) {
}
}
chatBox.addEventListener('scroll', function () {
if (isNearBottom(chatBox)) {
loadOlderChat();
}
});
chatForm.addEventListener('submit', function (e) {
e.preventDefault();
if (chatSending) return;
sendChatMessage();
});
replyCancel.addEventListener('click', function () {
clearReply();
});
chatInput.addEventListener('keydown', function (e) {
// @mention navigation
if (mentionVisible) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = Array.from(mentionBox.querySelectorAll('.admin-mention-item'));
if (items.length === 0) return;
if (e.key === 'ArrowDown') mentionActiveIndex = Math.min(items.length - 1, mentionActiveIndex + 1);
if (e.key === 'ArrowUp') mentionActiveIndex = Math.max(0, mentionActiveIndex - 1);
items.forEach(function (el, idx) {
el.classList.toggle('active', idx === mentionActiveIndex);
});
return;
}
if (e.key === 'Enter') {
const items = Array.from(mentionBox.querySelectorAll('.admin-mention-item'));
const active = items[mentionActiveIndex];
if (active) {
e.preventDefault();
const u = (active.textContent || '').replace(/^@/, '').trim();
if (u) insertMention(u);
return;
}
}
if (e.key === 'Escape') {
hideMentions();
return;
}
}
// Enter = send (Shift+Enter = newline)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (chatSending) return;
sendChatMessage();
return;
}
// typing indicator for printable keys + kasowanie
const isPrintable = e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
const isDeleteKey = e.key === 'Backspace' || e.key === 'Delete';
if (isPrintable || isDeleteKey) {
if (isPrintable) {
const maxLen = LIMITS.chatMessage;
const val = (chatInput.value || '').toString();
const selStart = typeof chatInput.selectionStart === 'number' ? chatInput.selectionStart : val.length;
const selEnd = typeof chatInput.selectionEnd === 'number' ? chatInput.selectionEnd : val.length;
const replacing = selEnd > selStart;
if (!replacing && val.length >= maxLen) {
pulseRedBorder(chatInput);
}
}
pingTypingThrottled();
}
});
chatInput.addEventListener('paste', function () {
setTimeout(function () {
const maxLen = LIMITS.chatMessage;
const val = (chatInput.value || '').toString();
if (val.length >= maxLen) pulseRedBorder(chatInput);
}, 0);
});
chatInput.addEventListener('input', function () {
autosizeChatInput();
refreshMentionBox();
pingTypingThrottled();
chatCounter.update();
});
if (chatAttachBtn && chatFile) {
chatAttachBtn.addEventListener('click', function () {
chatFile.click();
});
}
if (chatFile) {
chatFile.addEventListener('change', function () {
const f = chatFile.files && chatFile.files[0] ? chatFile.files[0] : null;
if (!f) {
if (chatFileChip) chatFileChip.style.display = 'none';
if (chatFileChipName) chatFileChipName.textContent = 'Załącznik';
return;
}
if (chatFileChipName) chatFileChipName.textContent = f.name;
if (chatFileChip) chatFileChip.style.display = 'grid';
});
}
if (chatFileChipRemove && chatFile) {
chatFileChipRemove.addEventListener('click', function () {
chatFile.value = '';
if (chatFileChip) chatFileChip.style.display = 'none';
if (chatFileChipName) chatFileChipName.textContent = 'Załącznik';
chatInput.focus();
});
}
taskForm.addEventListener('submit', function (e) {
e.preventDefault();
createTask(taskForm);
});
taskCreateFilesCtrl.render();
// Init
loadTasks();
loadAdmins();
autosizeChatInput();
loadInitialChat().then(function () {
if (chatPollingTimer) clearInterval(chatPollingTimer);
chatPollingTimer = setInterval(pollNewChat, 1000);
if (typingPollingTimer) clearInterval(typingPollingTimer);
typingPollingTimer = setInterval(pollTyping, 500);
if (chatEditsPollingTimer) clearInterval(chatEditsPollingTimer);
chatEditsPollingTimer = setInterval(pollEditedChat, 1000);
});
})();
</script>
</div>
<?php
require_once __DIR__ . '/includes/footer.php';
?>