4279 lines
170 KiB
PHP
4279 lines
170 KiB
PHP
<?php
|
||
error_reporting(E_ALL);
|
||
ini_set('display_errors', '1');
|
||
ini_set('log_errors', '1');
|
||
|
||
register_shutdown_function(static function (): void {
|
||
$error = error_get_last();
|
||
if (!is_array($error)) {
|
||
return;
|
||
}
|
||
|
||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||
if (!in_array($error['type'] ?? 0, $fatalTypes, true)) {
|
||
return;
|
||
}
|
||
|
||
$projectRoot = dirname(__DIR__, 2);
|
||
$logTargets = [
|
||
$projectRoot . '/private_html/admin_fatal.log',
|
||
__DIR__ . '/admin_fatal.log',
|
||
];
|
||
$requestUri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
|
||
$remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
|
||
$line = sprintf(
|
||
"[%s] uri=%s ip=%s file=%s line=%s message=%s%s",
|
||
date('Y-m-d H:i:s'),
|
||
$requestUri,
|
||
$remoteAddr,
|
||
(string) ($error['file'] ?? ''),
|
||
(string) ($error['line'] ?? ''),
|
||
trim((string) ($error['message'] ?? 'unknown fatal error')),
|
||
PHP_EOL
|
||
);
|
||
|
||
foreach ($logTargets as $logPath) {
|
||
if (@file_put_contents($logPath, $line, FILE_APPEND | LOCK_EX) !== false) {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
require_once __DIR__ . '/includes/auth.php';
|
||
require_once __DIR__ . '/includes/config.php';
|
||
require_once __DIR__ . '/includes/header.php';
|
||
require_once __DIR__ . '/includes/sidebar.php';
|
||
|
||
if (!isset($pdo) || !($pdo instanceof PDO)) {
|
||
http_response_code(500);
|
||
echo 'Blad inicjalizacji panelu administracyjnego.';
|
||
exit;
|
||
}
|
||
?>
|
||
|
||
<?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-error-card {
|
||
margin-top: 4px;
|
||
border: 1px solid #efb8b8;
|
||
border-left: 4px solid #b32d2e;
|
||
background: linear-gradient(180deg, #fff8f8 0%, #fff 100%);
|
||
border-radius: 8px;
|
||
padding: 10px 12px;
|
||
box-shadow: 0 2px 8px rgba(179, 45, 46, 0.08);
|
||
}
|
||
|
||
.admin-error-card-title {
|
||
color: #8f1f20;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.admin-error-card-detail {
|
||
margin: 0;
|
||
color: #5f1a1b;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* File error toast */
|
||
#adminFileToast {
|
||
position: fixed;
|
||
bottom: 28px;
|
||
left: 50%;
|
||
transform: translateX(-50%) translateY(20px);
|
||
background: #b32d2e;
|
||
color: #fff;
|
||
padding: 12px 22px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 18px rgba(0,0,0,0.22);
|
||
z-index: 99999;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||
max-width: calc(100vw - 40px);
|
||
text-align: center;
|
||
}
|
||
#adminFileToast.visible {
|
||
opacity: 1;
|
||
transform: translateX(-50%) translateY(0);
|
||
pointer-events: auto;
|
||
}
|
||
.admin-img-error {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: #fdf3f3;
|
||
border: 1px solid #f1b8b8;
|
||
color: #b32d2e;
|
||
font-size: 12px;
|
||
padding: 6px 10px;
|
||
border-radius: 6px;
|
||
margin-top: 6px;
|
||
}
|
||
</style>
|
||
|
||
<div id="adminFileToast" role="alert" aria-live="assertive"></div>
|
||
|
||
<h1 class="admin-page-title">Dashboard</h1>
|
||
|
||
<div class="admin-welcome-box">
|
||
<h2>👋 Witaj w panelu administracyjnym, <?php echo htmlspecialchars($_SESSION['username'] ?? 'Admin'); ?>!</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..."></textarea>
|
||
<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>
|
||
|
||
<input type="file" name="files[]" id="adminTaskFile" multiple accept="image/*,application/pdf,text/plain,text/markdown,.md,application/zip,video/mp4,audio/mpeg,audio/wav,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 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>
|
||
|
||
<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,
|
||
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() {
|
||
if (maxLen === null || maxLen === undefined) {
|
||
hintEl.textContent = '';
|
||
hintEl.classList.remove('over', 'warn');
|
||
return;
|
||
}
|
||
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, null);
|
||
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 escapeHtml(value) {
|
||
return (value || '').toString()
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function stringifyServerErrorDetail(detail) {
|
||
if (typeof detail === 'string') return detail;
|
||
if (Array.isArray(detail)) {
|
||
return detail.map(function (item) {
|
||
if (!item || typeof item !== 'object') return String(item || '');
|
||
const loc = Array.isArray(item.loc) ? '[' + item.loc.join('.') + '] ' : '';
|
||
const msg = item.msg ? String(item.msg) : '';
|
||
const type = item.type ? ' (' + String(item.type) + ')' : '';
|
||
return (loc + msg + type).trim();
|
||
}).filter(Boolean).join('\n');
|
||
}
|
||
if (detail && typeof detail === 'object') {
|
||
try {
|
||
return JSON.stringify(detail, null, 2);
|
||
} catch (e) {
|
||
return String(detail);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
async function parseAdminJsonOrThrow(res, fallbackMessage) {
|
||
const baseMsg = fallbackMessage || 'Błąd żądania';
|
||
const contentType = (res.headers.get('content-type') || '').toLowerCase();
|
||
let payload = null;
|
||
let rawText = '';
|
||
|
||
if (contentType.indexOf('application/json') !== -1) {
|
||
try {
|
||
payload = await res.json();
|
||
} catch (e) {
|
||
}
|
||
} else {
|
||
try {
|
||
rawText = await res.text();
|
||
} catch (e) {
|
||
}
|
||
}
|
||
|
||
if (payload && payload.success) {
|
||
return payload;
|
||
}
|
||
|
||
let detail = '';
|
||
if (payload) {
|
||
detail = (payload.error || '').toString();
|
||
if (!detail) detail = stringifyServerErrorDetail(payload.detail);
|
||
if (!detail) detail = stringifyServerErrorDetail(payload.message);
|
||
}
|
||
if (!detail && rawText) {
|
||
detail = rawText.trim();
|
||
}
|
||
if (!detail) {
|
||
detail = baseMsg;
|
||
}
|
||
const hasHttpCode = /\bHTTP\s+\d{3}\b/i.test(detail);
|
||
throw new Error(hasHttpCode ? detail : (detail + ' (HTTP ' + String(res.status) + ')'));
|
||
}
|
||
|
||
function showErrorCard(el, title, detail) {
|
||
if (!el) return;
|
||
el.style.display = 'block';
|
||
const safeTitle = escapeHtml(title || 'Błąd');
|
||
const safeDetail = escapeHtml((detail || 'Nieznany błąd').toString());
|
||
el.innerHTML = '<div class="admin-error-card">'
|
||
+ '<div class="admin-error-card-title">' + safeTitle + '</div>'
|
||
+ '<pre class="admin-error-card-detail">' + safeDetail + '</pre>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function clearError(el) {
|
||
el.style.display = 'none';
|
||
el.textContent = '';
|
||
el.innerHTML = '';
|
||
}
|
||
|
||
// File download with error toast
|
||
let _fileToastTimer = null;
|
||
function showFileToast(msg) {
|
||
const toast = document.getElementById('adminFileToast');
|
||
if (!toast) return;
|
||
toast.textContent = msg;
|
||
toast.classList.add('visible');
|
||
clearTimeout(_fileToastTimer);
|
||
_fileToastTimer = setTimeout(function () {
|
||
toast.classList.remove('visible');
|
||
}, 4500);
|
||
}
|
||
|
||
function safeDownload(url, filename) {
|
||
fetch(url, { method: 'HEAD' })
|
||
.then(function (res) {
|
||
if (!res.ok) {
|
||
if (res.status === 404) {
|
||
showFileToast('Plik nie istnieje lub został usunięty.');
|
||
} else {
|
||
showFileToast('Błąd pobierania pliku (HTTP ' + res.status + ').');
|
||
}
|
||
return;
|
||
}
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
if (filename) a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
})
|
||
.catch(function () {
|
||
showFileToast('Nie można pobrać pliku. Sprawdź połączenie.');
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
const dlName = msg.file_name ? String(msg.file_name) : 'zalacznik';
|
||
if (isPreviewableImageAttachment(msg)) {
|
||
const a = document.createElement('a');
|
||
a.href = '#';
|
||
a.addEventListener('click', function (e) { e.preventDefault(); safeDownload(fileUrl, dlName); });
|
||
const img = document.createElement('img');
|
||
img.alt = msg.file_name ? ('Załącznik: ' + msg.file_name) : 'Załącznik';
|
||
img.src = fileUrl + '&inline=1';
|
||
img.onerror = function () {
|
||
const errEl = document.createElement('div');
|
||
errEl.className = 'admin-img-error';
|
||
errEl.textContent = '\u26a0\ufe0f Plik niedostępny: ' + dlName;
|
||
a.replaceWith(errEl);
|
||
};
|
||
a.appendChild(img);
|
||
newAtt.appendChild(a);
|
||
} else {
|
||
const a = document.createElement('a');
|
||
a.href = '#';
|
||
a.addEventListener('click', function (e) { e.preventDefault(); safeDownload(fileUrl, dlName); });
|
||
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);
|
||
const dlName = msg.file_name ? String(msg.file_name) : 'zalacznik';
|
||
if (isPreviewableImageAttachment(msg)) {
|
||
const a = document.createElement('a');
|
||
a.href = '#';
|
||
a.addEventListener('click', function (e) { e.preventDefault(); safeDownload(fileUrl, dlName); });
|
||
const img = document.createElement('img');
|
||
img.alt = msg.file_name ? ('Załącznik: ' + msg.file_name) : 'Załącznik';
|
||
img.src = fileUrl + '&inline=1';
|
||
img.onerror = function () {
|
||
const errEl = document.createElement('div');
|
||
errEl.className = 'admin-img-error';
|
||
errEl.textContent = '\u26a0\ufe0f Plik niedostępny: ' + dlName;
|
||
a.replaceWith(errEl);
|
||
};
|
||
a.appendChild(img);
|
||
att.appendChild(a);
|
||
} else {
|
||
const a = document.createElement('a');
|
||
a.href = '#';
|
||
a.addEventListener('click', function (e) { e.preventDefault(); safeDownload(fileUrl, dlName); });
|
||
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 fileUrl = (att && att.download_url) ? String(att.download_url) : ('/api/admin_task_file.php?id=' + encodeURIComponent(t.id));
|
||
const fileName = (att && att.name) ? String(att.name) : 'plik';
|
||
const link = document.createElement('a');
|
||
link.href = '#';
|
||
link.style.display = 'block';
|
||
link.style.marginTop = '4px';
|
||
link.textContent = 'Pobierz plik' + (att && att.name ? ': ' + String(att.name) : '');
|
||
link.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
safeDownload(fileUrl, fileName);
|
||
});
|
||
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';
|
||
|
||
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, null);
|
||
|
||
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;
|
||
}
|
||
|
||
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 parseAdminJsonOrThrow(res, 'Błąd zapisu taska');
|
||
closeModal();
|
||
await loadTasks();
|
||
tasksStatus.textContent = 'OK';
|
||
} catch (e) {
|
||
tasksStatus.textContent = 'Błąd';
|
||
showErrorCard(
|
||
errorInline,
|
||
'Nie udało się zapisać taska',
|
||
(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)');
|
||
}
|
||
|
||
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 parseAdminJsonOrThrow(res, 'Błąd dodawania taska');
|
||
|
||
formEl.reset();
|
||
taskCreateFilesCtrl.clear();
|
||
taskTitleCounter.update();
|
||
taskDescCounter.update();
|
||
await loadTasks();
|
||
tasksStatus.textContent = 'Dodano';
|
||
} catch (e) {
|
||
tasksStatus.textContent = 'Błąd';
|
||
showErrorCard(
|
||
tasksError,
|
||
'Nie udało się dodać taska',
|
||
(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';
|
||
?>
|