Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Reply seen-by section was hardcoded to show initials only. Added avatar_path check matching topic readers pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2278 lines
75 KiB
HTML
Executable File
2278 lines
75 KiB
HTML
Executable File
{% extends "base.html" %}
|
||
|
||
{% block title %}{{ topic.title }} - Forum - Norda Biznes Partner{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.topic-breadcrumb {
|
||
margin-bottom: var(--spacing-lg);
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.topic-breadcrumb a {
|
||
color: var(--primary);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.topic-breadcrumb a:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.topic-header {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-xl);
|
||
margin-bottom: var(--spacing-xl);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.topic-header.pinned {
|
||
border-left: 4px solid var(--primary);
|
||
background: linear-gradient(135deg, #eff6ff, var(--surface));
|
||
}
|
||
|
||
.topic-header.locked {
|
||
border-left: 4px solid var(--secondary);
|
||
}
|
||
|
||
.topic-title-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: var(--spacing-md);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.topic-title {
|
||
font-size: var(--font-size-2xl);
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.topic-badge {
|
||
font-size: var(--font-size-sm);
|
||
padding: 4px 10px;
|
||
border-radius: var(--radius-sm);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.badge-pinned {
|
||
background: var(--primary);
|
||
color: white;
|
||
}
|
||
|
||
.badge-locked {
|
||
background: var(--secondary);
|
||
color: white;
|
||
}
|
||
|
||
/* Category badges */
|
||
.badge-category {
|
||
border: 1px solid;
|
||
}
|
||
|
||
.badge-feature_request {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
border-color: #93c5fd;
|
||
}
|
||
|
||
.badge-bug {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
border-color: #fca5a5;
|
||
}
|
||
|
||
.badge-question {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
border-color: #86efac;
|
||
}
|
||
|
||
.badge-announcement {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
border-color: #fcd34d;
|
||
}
|
||
|
||
/* Status badges */
|
||
.badge-status {
|
||
font-size: var(--font-size-xs);
|
||
padding: 2px 8px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.badge-new {
|
||
background: #f3f4f6;
|
||
color: #374151;
|
||
}
|
||
|
||
.badge-in_progress {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
}
|
||
|
||
.badge-resolved {
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}
|
||
|
||
.badge-rejected {
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}
|
||
|
||
.topic-meta {
|
||
display: flex;
|
||
gap: var(--spacing-lg);
|
||
color: var(--text-secondary);
|
||
font-size: var(--font-size-sm);
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
|
||
.topic-meta span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.topic-content {
|
||
line-height: 1.8;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* Markdown styles */
|
||
.forum-quote {
|
||
border-left: 3px solid var(--primary);
|
||
background: var(--background);
|
||
padding: var(--spacing-sm) var(--spacing-md);
|
||
margin: var(--spacing-sm) 0;
|
||
font-style: italic;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.forum-list {
|
||
margin: var(--spacing-sm) 0;
|
||
padding-left: var(--spacing-xl);
|
||
}
|
||
|
||
.forum-list li {
|
||
margin: var(--spacing-xs) 0;
|
||
}
|
||
|
||
.forum-code {
|
||
background: var(--background);
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-family: monospace;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.forum-code-block {
|
||
background: #1e293b;
|
||
color: #e2e8f0;
|
||
padding: var(--spacing-md);
|
||
border-radius: var(--radius);
|
||
overflow-x: auto;
|
||
margin: var(--spacing-sm) 0;
|
||
}
|
||
|
||
.forum-code-block code {
|
||
font-family: monospace;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.forum-link {
|
||
color: var(--primary);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.forum-mention {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
padding: 1px 4px;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* User stats tooltip */
|
||
.user-stats-trigger {
|
||
cursor: pointer;
|
||
position: relative;
|
||
font-weight: 500;
|
||
text-decoration: none;
|
||
color: inherit;
|
||
}
|
||
|
||
.user-stats-trigger:hover {
|
||
color: var(--primary);
|
||
}
|
||
|
||
.user-stats-tooltip {
|
||
position: absolute;
|
||
bottom: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: var(--card-bg, #fff);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 12px 16px;
|
||
min-width: 200px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
z-index: 1000;
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.user-stats-tooltip::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
border: 6px solid transparent;
|
||
border-top-color: var(--border);
|
||
}
|
||
|
||
.user-stats-tooltip .stats-header {
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.user-stats-tooltip .stats-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
|
||
.user-stats-tooltip .stat-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.user-stats-tooltip .stat-value {
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.user-stats-tooltip .loading {
|
||
color: var(--text-secondary);
|
||
text-align: center;
|
||
}
|
||
|
||
/* Attachments */
|
||
.topic-attachment {
|
||
margin-top: var(--spacing-lg);
|
||
padding-top: var(--spacing-lg);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.attachment-image {
|
||
max-width: 100%;
|
||
max-height: 500px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
transition: var(--transition);
|
||
}
|
||
|
||
.attachment-image:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.attachment-info {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
|
||
.replies-section {
|
||
margin-top: var(--spacing-xl);
|
||
}
|
||
|
||
.replies-header {
|
||
font-size: var(--font-size-xl);
|
||
font-weight: 600;
|
||
margin-bottom: var(--spacing-lg);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.replies-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.reply-card {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
border-left: 3px solid var(--border);
|
||
}
|
||
|
||
.reply-card:hover {
|
||
border-left-color: var(--primary);
|
||
}
|
||
|
||
.reply-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-md);
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.reply-author {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.reply-avatar {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: var(--primary);
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: 600;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
/* AI generated indicator */
|
||
.ai-indicator {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 2px 6px;
|
||
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: #4338ca;
|
||
cursor: help;
|
||
margin-left: var(--spacing-xs);
|
||
}
|
||
|
||
.ai-indicator svg {
|
||
width: 12px;
|
||
height: 12px;
|
||
}
|
||
|
||
.reply-content {
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.reply-attachments-container {
|
||
margin-top: var(--spacing-md);
|
||
padding-top: var(--spacing-md);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.reply-attachments-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.reply-attachment {
|
||
position: relative;
|
||
}
|
||
|
||
.reply-attachment img {
|
||
width: 100%;
|
||
height: 120px;
|
||
object-fit: cover;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.reply-attachment img:hover {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.reply-attachment .attachment-info {
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* Single attachment - larger display */
|
||
.reply-attachments-grid.single-attachment {
|
||
grid-template-columns: 1fr;
|
||
max-width: 400px;
|
||
}
|
||
|
||
.reply-attachments-grid.single-attachment .reply-attachment img {
|
||
height: auto;
|
||
max-height: 300px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.reply-form {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-xl);
|
||
margin-top: var(--spacing-xl);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.reply-form h3 {
|
||
margin-bottom: var(--spacing-lg);
|
||
font-size: var(--font-size-lg);
|
||
}
|
||
|
||
.reply-form textarea {
|
||
width: 100%;
|
||
min-height: 120px;
|
||
padding: var(--spacing-md);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-family: var(--font-family);
|
||
font-size: var(--font-size-base);
|
||
resize: vertical;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.reply-form textarea:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||
}
|
||
|
||
/* Upload dropzone in reply form */
|
||
.upload-dropzone-mini {
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius);
|
||
padding: var(--spacing-md);
|
||
text-align: center;
|
||
background: var(--background);
|
||
transition: var(--transition);
|
||
cursor: pointer;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.upload-dropzone-mini:hover,
|
||
.upload-dropzone-mini.drag-over {
|
||
border-color: var(--primary);
|
||
background: rgba(37, 99, 235, 0.05);
|
||
}
|
||
|
||
.upload-dropzone-mini p {
|
||
color: var(--text-secondary);
|
||
font-size: var(--font-size-sm);
|
||
margin: 0;
|
||
}
|
||
|
||
.upload-dropzone-mini .mobile-only {
|
||
display: none;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.upload-dropzone-mini .desktop-only {
|
||
display: none;
|
||
}
|
||
.upload-dropzone-mini .mobile-only {
|
||
display: block;
|
||
font-size: var(--font-size-base);
|
||
padding: var(--spacing-sm) 0;
|
||
}
|
||
}
|
||
|
||
.upload-preview-mini {
|
||
display: none;
|
||
margin-bottom: var(--spacing-md);
|
||
padding: var(--spacing-sm);
|
||
background: var(--background);
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.upload-preview-mini.active {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.upload-preview-mini img {
|
||
max-width: 80px;
|
||
max-height: 60px;
|
||
border-radius: var(--radius-sm);
|
||
object-fit: cover;
|
||
}
|
||
|
||
.upload-preview-mini .file-info {
|
||
flex: 1;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.upload-preview-mini .file-name {
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.upload-preview-mini .file-size {
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.upload-preview-mini .remove-file {
|
||
color: var(--error);
|
||
cursor: pointer;
|
||
padding: var(--spacing-xs);
|
||
}
|
||
|
||
.upload-preview-mini .remove-file:hover {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
/* Multi-file upload preview grid */
|
||
.upload-previews-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||
gap: var(--spacing-sm);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.upload-preview-item {
|
||
position: relative;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: var(--spacing-xs);
|
||
background: var(--surface);
|
||
}
|
||
|
||
.upload-preview-item img {
|
||
width: 100%;
|
||
height: 80px;
|
||
object-fit: cover;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.upload-preview-item .preview-info {
|
||
font-size: 10px;
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.upload-preview-item .remove-preview {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: var(--error);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.upload-preview-item .remove-preview:hover {
|
||
background: #c53030;
|
||
}
|
||
|
||
.upload-counter {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
margin-bottom: var(--spacing-xs);
|
||
}
|
||
|
||
.upload-counter.limit-reached {
|
||
color: var(--warning);
|
||
}
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
align-items: center;
|
||
}
|
||
|
||
.locked-notice {
|
||
background: #fef3c7;
|
||
border: 1px solid #f59e0b;
|
||
border-radius: var(--radius);
|
||
padding: var(--spacing-md);
|
||
margin-top: var(--spacing-xl);
|
||
text-align: center;
|
||
color: #92400e;
|
||
}
|
||
|
||
.empty-replies {
|
||
text-align: center;
|
||
padding: var(--spacing-xl);
|
||
color: var(--text-secondary);
|
||
background: var(--background);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
/* Lightbox for images */
|
||
.lightbox {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.lightbox.active {
|
||
display: flex;
|
||
}
|
||
|
||
.lightbox img {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.topic-title-row {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.topic-meta {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.form-actions {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
}
|
||
|
||
/* Admin actions */
|
||
.admin-actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.admin-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: 1px solid;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.admin-btn svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.admin-btn-delete {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
border-color: #fecaca;
|
||
}
|
||
|
||
.admin-btn-delete:hover {
|
||
background: #fee2e2;
|
||
border-color: #f87171;
|
||
}
|
||
|
||
.admin-btn-pin {
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
border-color: #bfdbfe;
|
||
}
|
||
|
||
.admin-btn-pin:hover {
|
||
background: #dbeafe;
|
||
border-color: #60a5fa;
|
||
}
|
||
|
||
.admin-btn-lock {
|
||
background: #f5f5f5;
|
||
color: #525252;
|
||
border-color: #d4d4d4;
|
||
}
|
||
|
||
.admin-btn-lock:hover {
|
||
background: #e5e5e5;
|
||
border-color: #a3a3a3;
|
||
}
|
||
|
||
.reply-admin-actions {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.admin-btn-sm {
|
||
padding: 4px 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.admin-btn-sm svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
/* User actions */
|
||
.user-actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
margin-top: var(--spacing-md);
|
||
padding-top: var(--spacing-md);
|
||
border-top: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.action-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px 12px;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text-secondary);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.action-btn:hover {
|
||
background: var(--background);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.action-btn svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.action-btn.danger {
|
||
color: #dc2626;
|
||
}
|
||
|
||
.action-btn.danger:hover {
|
||
background: #fef2f2;
|
||
border-color: #fecaca;
|
||
}
|
||
|
||
/* Reactions bar */
|
||
.reactions-bar {
|
||
display: flex;
|
||
gap: var(--spacing-xs);
|
||
margin-top: var(--spacing-md);
|
||
}
|
||
|
||
.seen-by-section {
|
||
margin-top: var(--spacing-lg);
|
||
padding-top: var(--spacing-md);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.seen-by-label {
|
||
font-size: var(--font-size-sm);
|
||
color: var(--text-secondary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
|
||
.seen-by-avatars {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
|
||
.reader-avatar {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
cursor: default;
|
||
position: relative;
|
||
}
|
||
|
||
.reader-avatar img {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
object-fit: cover;
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
}
|
||
|
||
.reader-avatar[data-name]::after {
|
||
content: attr(data-name);
|
||
position: absolute;
|
||
bottom: calc(100% + 8px);
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #1e293b;
|
||
color: #f8fafc;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
z-index: 100;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||
letter-spacing: 0.2px;
|
||
}
|
||
|
||
.reader-avatar[data-name]:hover::after {
|
||
opacity: 1;
|
||
}
|
||
|
||
.reader-avatar.more {
|
||
background: var(--text-secondary);
|
||
font-size: 9px;
|
||
}
|
||
|
||
.reaction-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
border-radius: 20px;
|
||
font-size: var(--font-size-sm);
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.reaction-btn:hover {
|
||
background: var(--background);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.reaction-btn.active {
|
||
background: #eff6ff;
|
||
border-color: #3b82f6;
|
||
}
|
||
|
||
.reaction-btn .count {
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Subscribe button */
|
||
.subscribe-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
color: var(--text-secondary);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.subscribe-btn:hover {
|
||
background: var(--background);
|
||
}
|
||
|
||
.subscribe-btn.subscribed {
|
||
background: #dcfce7;
|
||
border-color: #86efac;
|
||
color: #166534;
|
||
}
|
||
|
||
/* Edited badge */
|
||
.edited-badge {
|
||
font-size: var(--font-size-xs);
|
||
color: var(--text-muted);
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Solution badge */
|
||
.solution-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-sm);
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* Deleted overlay */
|
||
.reply-card.deleted {
|
||
opacity: 0.6;
|
||
background: #fef2f2;
|
||
border: 1px dashed #fecaca;
|
||
}
|
||
|
||
.deleted-notice {
|
||
color: #dc2626;
|
||
font-style: italic;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
/* Edit/Report modal */
|
||
.form-modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 2000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.form-modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
|
||
.form-modal {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-xl);
|
||
max-width: 600px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.form-modal h3 {
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.form-modal textarea {
|
||
width: 100%;
|
||
min-height: 150px;
|
||
padding: var(--spacing-md);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-base);
|
||
resize: vertical;
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.form-modal select {
|
||
width: 100%;
|
||
padding: var(--spacing-sm);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: var(--font-size-base);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.form-modal .modal-actions {
|
||
display: flex;
|
||
gap: var(--spacing-sm);
|
||
justify-content: flex-end;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<nav class="topic-breadcrumb">
|
||
<a href="{{ url_for('forum_index') }}">Forum</a> » {{ topic.title[:50] }}{% if topic.title|length > 50 %}...{% endif %}
|
||
</nav>
|
||
|
||
<article class="topic-header {% if topic.is_pinned %}pinned{% endif %} {% if topic.is_locked %}locked{% endif %}">
|
||
<div class="topic-title-row">
|
||
<h1 class="topic-title">
|
||
{% if topic.is_pinned %}
|
||
<span class="topic-badge badge-pinned">Przypięty</span>
|
||
{% endif %}
|
||
{% if topic.is_locked %}
|
||
<span class="topic-badge badge-locked">Zamknięty</span>
|
||
{% endif %}
|
||
<span class="topic-badge badge-category badge-{{ topic.category or 'question' }}">
|
||
{{ category_labels.get(topic.category, 'Pytanie') }}
|
||
</span>
|
||
<span class="topic-badge badge-status badge-{{ topic.status or 'new' }}">
|
||
{{ status_labels.get(topic.status, 'Nowy') }}
|
||
</span>
|
||
{{ topic.title }}
|
||
</h1>
|
||
{% if current_user.is_authenticated and current_user.can_moderate_forum() %}
|
||
<div class="admin-actions">
|
||
<button type="button" class="admin-btn admin-btn-pin" onclick="togglePin({{ topic.id }})" title="{% if topic.is_pinned %}Odepnij{% else %}Przypnij{% endif %}">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
|
||
</svg>
|
||
{% if topic.is_pinned %}Odepnij{% else %}Przypnij{% endif %}
|
||
</button>
|
||
<button type="button" class="admin-btn admin-btn-lock" onclick="toggleLock({{ topic.id }})" title="{% if topic.is_locked %}Odblokuj{% else %}Zablokuj{% endif %}">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
{% if topic.is_locked %}
|
||
<path d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z"/>
|
||
{% else %}
|
||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||
{% endif %}
|
||
</svg>
|
||
{% if topic.is_locked %}Odblokuj{% else %}Zablokuj{% endif %}
|
||
</button>
|
||
<button type="button" class="admin-btn admin-btn-delete" onclick="deleteTopic({{ topic.id }})" title="Usuń wątek">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
Usuń
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="topic-meta">
|
||
<span>
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||
<circle cx="12" cy="7" r="4"></circle>
|
||
</svg>
|
||
<a href="{{ url_for('public.user_profile', user_id=topic.author_id) }}" class="user-stats-trigger user-profile-link" data-user-id="{{ topic.author_id }}">
|
||
{{ topic.author.name or topic.author.email.split('@')[0] }}
|
||
</a>
|
||
{% if topic.is_ai_generated %}
|
||
<span class="ai-indicator" title="Wygenerowano przez AI">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||
AI
|
||
</span>
|
||
{% endif %}
|
||
</span>
|
||
<span>
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<polyline points="12 6 12 12 16 14"></polyline>
|
||
</svg>
|
||
{{ topic.created_at|local_time('%d.%m.%Y %H:%M') }}
|
||
{% if topic.edited_at %}
|
||
<span class="edited-badge">(edytowano {{ topic.edited_at|local_time('%d.%m.%Y %H:%M') }})</span>
|
||
{% endif %}
|
||
</span>
|
||
<span>
|
||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||
<circle cx="12" cy="12" r="3"></circle>
|
||
</svg>
|
||
{{ topic.views_count }} wyświetleń
|
||
</span>
|
||
<button type="button" class="subscribe-btn {% if is_subscribed %}subscribed{% endif %}" id="subscribeBtn" onclick="toggleSubscribe({{ topic.id }})">
|
||
{% if is_subscribed %}🔔 Obserwujesz{% else %}🔕 Obserwuj{% endif %}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="topic-content" id="topicContent">{{ topic.content|forum_markdown }}</div>
|
||
|
||
<!-- Reactions bar for topic -->
|
||
<div class="reactions-bar" id="topicReactions" data-content-type="topic" data-content-id="{{ topic.id }}">
|
||
{% set topic_reactions = topic.reactions or {} %}
|
||
{% for emoji in available_reactions %}
|
||
{% set user_ids = topic_reactions.get(emoji, []) %}
|
||
{% set count = user_ids|length %}
|
||
{% set user_reacted = current_user.id in user_ids %}
|
||
{% set names = [] %}
|
||
{% for uid in user_ids %}{% if reaction_user_names.get(uid) %}{% if names.append(reaction_user_names[uid]) %}{% endif %}{% endif %}{% endfor %}
|
||
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('topic', {{ topic.id }}, '{{ emoji }}')" {% if names %}title="{{ names|join(', ') }}"{% endif %} data-user-ids="{{ user_ids|join(',') }}">
|
||
{{ emoji }} <span class="count">{{ count }}</span>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Seen by section for topic -->
|
||
{% if topic_readers %}
|
||
<div class="seen-by-section">
|
||
<div class="seen-by-label">Widziane przez {{ topic_readers|length }} {{ 'osobę' if topic_readers|length == 1 else 'osoby' if topic_readers|length < 5 else 'osób' }}:</div>
|
||
<div class="seen-by-avatars">
|
||
{% for read in topic_readers[:20] %}
|
||
<div class="reader-avatar"
|
||
data-name="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
|
||
style="{% if not read.user.avatar_path %}background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);{% endif %}">
|
||
{% if read.user.avatar_path %}<img src="{{ url_for('static', filename=read.user.avatar_path) }}" alt="" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">{% else %}{{ (read.user.name or read.user.email)[0]|upper }}{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% if topic_readers|length > 20 %}
|
||
<div class="reader-avatar more" title="i {{ topic_readers|length - 20 }} innych">
|
||
+{{ topic_readers|length - 20 }}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- User actions for topic -->
|
||
{% if not topic.is_locked %}
|
||
<div class="user-actions">
|
||
{% if topic.author_id == current_user.id or current_user.can_moderate_forum() %}
|
||
<button type="button" class="action-btn" onclick="openEditModal('topic', {{ topic.id }}, document.getElementById('topicContent').innerText)">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Edytuj
|
||
</button>
|
||
{% endif %}
|
||
<button type="button" class="action-btn" onclick="openReportModal('topic', {{ topic.id }})">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
|
||
Zgłoś
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if topic.attachments %}
|
||
{% for attachment in topic.attachments %}
|
||
<div class="topic-attachment">
|
||
<img src="{{ url_for('static', filename='uploads/forum/topics/' ~ topic.created_at.strftime('%Y/%m/') ~ attachment.stored_filename) }}"
|
||
alt="{{ attachment.original_filename }}"
|
||
class="attachment-image"
|
||
onclick="openLightbox(this.src)">
|
||
<div class="attachment-info">
|
||
{{ attachment.original_filename }} ({{ (attachment.file_size / 1024)|int }} KB)
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% endif %}
|
||
</article>
|
||
|
||
<section class="replies-section">
|
||
<h2 class="replies-header">
|
||
Odpowiedzi ({{ visible_replies|length }})
|
||
</h2>
|
||
|
||
{% if visible_replies %}
|
||
<div class="replies-list">
|
||
{% for reply in visible_replies %}
|
||
<article class="reply-card {% if reply.is_deleted %}deleted{% endif %}" id="reply-{{ reply.id }}">
|
||
<div class="reply-header">
|
||
<div class="reply-author">
|
||
<a href="{{ url_for('public.user_profile', user_id=reply.author_id) }}" class="reply-avatar user-profile-link" style="text-decoration:none;color:inherit;">
|
||
{% if reply.author.avatar_path %}
|
||
<img src="{{ url_for('static', filename=reply.author.avatar_path) }}" alt="{{ reply.author.name }}" style="width:100%;height:100%;border-radius:50%;object-fit:cover;">
|
||
{% else %}
|
||
{{ (reply.author.name or reply.author.email)[0].upper() }}
|
||
{% endif %}
|
||
</a>
|
||
<a href="{{ url_for('public.user_profile', user_id=reply.author_id) }}" class="user-stats-trigger user-profile-link" data-user-id="{{ reply.author_id }}">
|
||
{{ reply.author.name or reply.author.email.split('@')[0] }}
|
||
</a>
|
||
{% if reply.is_ai_generated %}
|
||
<span class="ai-indicator" title="Wygenerowano przez AI">
|
||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||
AI
|
||
</span>
|
||
{% endif %}
|
||
{% if reply.is_solution %}
|
||
<span class="solution-badge" title="Oznaczone jako rozwiązanie">✓ Rozwiązanie</span>
|
||
{% endif %}
|
||
</div>
|
||
<span>
|
||
{{ reply.created_at|local_time('%d.%m.%Y %H:%M') }}
|
||
{% if reply.edited_at %}
|
||
<span class="edited-badge">(edytowano)</span>
|
||
{% endif %}
|
||
</span>
|
||
{% if current_user.is_authenticated and current_user.can_moderate_forum() %}
|
||
<div class="reply-admin-actions">
|
||
<button type="button" class="admin-btn admin-btn-sm" onclick="toggleSolution({{ reply.id }})" title="{% if reply.is_solution %}Usuń oznaczenie{% else %}Oznacz jako rozwiązanie{% endif %}">
|
||
✓
|
||
</button>
|
||
<button type="button" class="admin-btn admin-btn-sm" onclick="openEditModal('reply', {{ reply.id }}, document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</button>
|
||
{% if reply.is_deleted %}
|
||
<button type="button" class="admin-btn admin-btn-sm" onclick="restoreReply({{ reply.id }})" title="Przywróć">↩</button>
|
||
{% else %}
|
||
<button type="button" class="admin-btn admin-btn-delete admin-btn-sm" onclick="deleteReply({{ reply.id }})" title="Usuń odpowiedź">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if reply.is_deleted %}
|
||
<div class="reply-content deleted-notice">[Ta odpowiedź została usunięta]</div>
|
||
{% else %}
|
||
<div class="reply-content">{{ reply.content|forum_markdown }}</div>
|
||
|
||
{% if reply.attachments %}
|
||
<div class="reply-attachments-container">
|
||
<div class="reply-attachments-grid {% if reply.attachments|length == 1 %}single-attachment{% endif %}">
|
||
{% for attachment in reply.attachments %}
|
||
<div class="reply-attachment">
|
||
<img src="{{ url_for('static', filename='uploads/forum/replies/' ~ reply.created_at.strftime('%Y/%m/') ~ attachment.stored_filename) }}"
|
||
alt="{{ attachment.original_filename }}"
|
||
onclick="openLightbox(this.src)">
|
||
<div class="attachment-info">
|
||
{{ attachment.original_filename|truncate(20) }} ({{ (attachment.file_size / 1024)|int }} KB)
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Reactions bar for reply -->
|
||
<div class="reactions-bar" data-content-type="reply" data-content-id="{{ reply.id }}">
|
||
{% set reply_reactions = reply.reactions or {} %}
|
||
{% for emoji in available_reactions %}
|
||
{% set user_ids = reply_reactions.get(emoji, []) %}
|
||
{% set count = user_ids|length %}
|
||
{% set user_reacted = current_user.id in user_ids %}
|
||
{% set names = [] %}
|
||
{% for uid in user_ids %}{% if reaction_user_names.get(uid) %}{% if names.append(reaction_user_names[uid]) %}{% endif %}{% endif %}{% endfor %}
|
||
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('reply', {{ reply.id }}, '{{ emoji }}')" {% if names %}title="{{ names|join(', ') }}"{% endif %} data-user-ids="{{ user_ids|join(',') }}">
|
||
{{ emoji }} <span class="count">{{ count }}</span>
|
||
</button>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
<!-- Seen by section for reply -->
|
||
{% if reply.readers %}
|
||
<div class="seen-by-section">
|
||
<div class="seen-by-label">Widziane przez {{ reply.readers|length }} {{ 'osobę' if reply.readers|length == 1 else 'osoby' if reply.readers|length < 5 else 'osób' }}:</div>
|
||
<div class="seen-by-avatars">
|
||
{% for read in reply.readers[:15] %}
|
||
<div class="reader-avatar"
|
||
data-name="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
|
||
style="{% if not read.user.avatar_path %}background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);{% endif %}">
|
||
{% if read.user.avatar_path %}<img src="{{ url_for('static', filename=read.user.avatar_path) }}" alt="">{% else %}{{ (read.user.name or read.user.email)[0]|upper }}{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% if reply.readers|length > 15 %}
|
||
<div class="reader-avatar more" title="i {{ reply.readers|length - 15 }} innych">
|
||
+{{ reply.readers|length - 15 }}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- User actions for reply -->
|
||
{% if not topic.is_locked %}
|
||
<div class="user-actions">
|
||
{% if reply.author_id == current_user.id %}
|
||
<button type="button" class="action-btn" onclick="openEditModal('reply', {{ reply.id }}, document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Edytuj
|
||
</button>
|
||
<button type="button" class="action-btn danger" onclick="deleteOwnReply({{ reply.id }})">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||
Usuń
|
||
</button>
|
||
{% endif %}
|
||
<button type="button" class="action-btn" onclick="quoteReply('{{ reply.author.name or reply.author.email.split('@')[0] }}', document.querySelector('#reply-{{ reply.id }} .reply-content').innerText)">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
||
Cytuj
|
||
</button>
|
||
<button type="button" class="action-btn" onclick="openReportModal('reply', {{ reply.id }})">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="14" height="14"><path d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9"/></svg>
|
||
Zgłoś
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
</article>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="empty-replies">
|
||
Brak odpowiedzi. Bądź pierwszy!
|
||
</div>
|
||
{% endif %}
|
||
</section>
|
||
|
||
{% if topic.is_locked %}
|
||
<div class="locked-notice">
|
||
Ten temat jest zamknięty. Nie można dodawać nowych odpowiedzi.
|
||
</div>
|
||
{% else %}
|
||
<form class="reply-form" method="POST" action="{{ url_for('forum_reply', topic_id=topic.id) }}" enctype="multipart/form-data">
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
<h3>Dodaj odpowiedź</h3>
|
||
<textarea name="content" id="replyContent" placeholder="Twoja odpowiedź..." required></textarea>
|
||
<div style="font-size: var(--font-size-xs); color: var(--text-muted); margin-top: var(--spacing-xs);">
|
||
Formatowanie: **pogrubienie**, *kursywa*, `kod`, [link](url), @wzmianka, > cytat
|
||
</div>
|
||
|
||
<div class="upload-counter" id="uploadCounter"></div>
|
||
<div class="upload-previews-container" id="previewsContainer"></div>
|
||
<div class="upload-dropzone-mini" id="dropzone">
|
||
<p class="desktop-only">Przeciągnij obrazy lub kliknij tutaj (max 10 plików, możesz też wkleić Ctrl+V)</p>
|
||
<p class="mobile-only">📷 Dodaj zdjęcie z galerii</p>
|
||
<input type="file" id="attachmentInput" name="attachments[]" accept="image/jpeg,image/png,image/gif" multiple style="display: none;">
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary" id="replySubmitBtn">Wyślij odpowiedź</button>
|
||
</div>
|
||
</form>
|
||
<script>
|
||
(function() {
|
||
var form = document.querySelector('.reply-form');
|
||
var btn = document.getElementById('replySubmitBtn');
|
||
if (form && btn) {
|
||
form.addEventListener('submit', function() {
|
||
if (btn.dataset.submitting === '1') return false;
|
||
btn.dataset.submitting = '1';
|
||
btn.disabled = true;
|
||
btn.textContent = 'Wysyłanie...';
|
||
setTimeout(function() {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Wyślij odpowiedź';
|
||
btn.dataset.submitting = '';
|
||
}, 10000);
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
|
||
<!-- Lightbox for enlarged images -->
|
||
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||
<img id="lightboxImage" src="" alt="Enlarged image">
|
||
</div>
|
||
|
||
<!-- Confirm modal -->
|
||
<div class="confirm-modal-overlay" id="confirmModal">
|
||
<div class="confirm-modal">
|
||
<div class="confirm-modal-icon" id="confirmIcon">
|
||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||
</svg>
|
||
</div>
|
||
<h3 class="confirm-modal-title" id="confirmTitle">Potwierdź usunięcie</h3>
|
||
<p class="confirm-modal-message" id="confirmMessage">Czy na pewno chcesz usunąć ten element?</p>
|
||
<p class="confirm-modal-warning" id="confirmWarning"></p>
|
||
<div class="confirm-modal-actions">
|
||
<button type="button" class="btn btn-outline" onclick="closeConfirmModal()">Anuluj</button>
|
||
<button type="button" class="btn btn-danger" id="confirmButton">Usuń</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit modal -->
|
||
<div class="form-modal-overlay" id="editModal">
|
||
<div class="form-modal">
|
||
<h3>Edytuj <span id="editType">treść</span></h3>
|
||
<textarea id="editContent" placeholder="Edytuj treść..."></textarea>
|
||
<input type="hidden" id="editContentType" value="">
|
||
<input type="hidden" id="editContentId" value="">
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn btn-outline" onclick="closeEditModal()">Anuluj</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveEdit()">Zapisz</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Report modal -->
|
||
<div class="form-modal-overlay" id="reportModal">
|
||
<div class="form-modal">
|
||
<h3>Zgłoś treść</h3>
|
||
<select id="reportReason">
|
||
<option value="">Wybierz powód...</option>
|
||
<option value="spam">Spam</option>
|
||
<option value="offensive">Obraźliwe treści</option>
|
||
<option value="off-topic">Nie na temat</option>
|
||
<option value="other">Inne</option>
|
||
</select>
|
||
<textarea id="reportDescription" placeholder="Opcjonalny opis zgłoszenia..."></textarea>
|
||
<input type="hidden" id="reportContentType" value="">
|
||
<input type="hidden" id="reportContentId" value="">
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn btn-outline" onclick="closeReportModal()">Anuluj</button>
|
||
<button type="button" class="btn btn-primary" onclick="submitReport()">Wyślij zgłoszenie</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||
<style>
|
||
.toast { padding: 12px 20px; border-radius: var(--radius); background: var(--surface); border-left: 4px solid var(--primary); box-shadow: 0 4px 12px rgba(0,0,0,0.15); display: flex; align-items: center; gap: 10px; animation: toastIn 0.3s ease; }
|
||
.toast.success { border-left-color: var(--success); }
|
||
.toast.error { border-left-color: var(--error); }
|
||
.toast.warning { border-left-color: var(--warning); }
|
||
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
|
||
|
||
/* Confirm modal */
|
||
.confirm-modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
z-index: 2000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
animation: fadeIn 0.2s ease;
|
||
}
|
||
.confirm-modal-overlay.active {
|
||
display: flex;
|
||
}
|
||
.confirm-modal {
|
||
background: var(--surface);
|
||
border-radius: var(--radius-lg);
|
||
padding: var(--spacing-xl);
|
||
max-width: 400px;
|
||
width: 90%;
|
||
text-align: center;
|
||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||
animation: slideUp 0.3s ease;
|
||
}
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
@keyframes slideUp {
|
||
from { transform: translateY(20px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
.confirm-modal-icon {
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
background: #fef2f2;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: 0 auto var(--spacing-md);
|
||
}
|
||
.confirm-modal-icon svg {
|
||
width: 28px;
|
||
height: 28px;
|
||
color: #dc2626;
|
||
}
|
||
.confirm-modal-title {
|
||
font-size: var(--font-size-lg);
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.confirm-modal-message {
|
||
color: var(--text-secondary);
|
||
margin-bottom: var(--spacing-sm);
|
||
}
|
||
.confirm-modal-warning {
|
||
font-size: var(--font-size-sm);
|
||
color: #dc2626;
|
||
margin-bottom: var(--spacing-lg);
|
||
}
|
||
.confirm-modal-actions {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
justify-content: center;
|
||
}
|
||
.btn-danger {
|
||
background: #dc2626;
|
||
color: white;
|
||
border: none;
|
||
}
|
||
.btn-danger:hover {
|
||
background: #b91c1c;
|
||
}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
// Scroll to reply anchor (e.g. from notification link #reply-55)
|
||
(function() {
|
||
var hash = window.location.hash;
|
||
if (!hash || !hash.startsWith('#reply-')) return;
|
||
|
||
function doScroll() {
|
||
var target = document.getElementById(hash.substring(1));
|
||
if (!target) return;
|
||
var headerEl = document.querySelector('header');
|
||
var offset = headerEl ? headerEl.offsetHeight + 16 : 80;
|
||
var top = target.getBoundingClientRect().top + window.pageYOffset - offset;
|
||
window.scrollTo(0, top);
|
||
target.style.transition = 'box-shadow 0.3s ease';
|
||
target.style.boxShadow = '0 0 0 3px var(--primary-light, #4a6999)';
|
||
setTimeout(function() { target.style.boxShadow = ''; }, 3000);
|
||
}
|
||
|
||
// Run multiple times to fight browser native anchor scroll
|
||
window.addEventListener('load', function() {
|
||
setTimeout(doScroll, 50);
|
||
setTimeout(doScroll, 300);
|
||
setTimeout(doScroll, 600);
|
||
});
|
||
})();
|
||
|
||
function showToast(message, type = 'info', duration = 4000) {
|
||
const container = document.getElementById('toastContainer');
|
||
const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.innerHTML = `<span style="font-size:1.2em">${icons[type]||'ℹ'}</span><span>${message}</span>`;
|
||
container.appendChild(toast);
|
||
setTimeout(() => { toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); }, duration);
|
||
}
|
||
|
||
// User stats tooltip
|
||
const userStatsCache = {};
|
||
let activeTooltip = null;
|
||
|
||
document.querySelectorAll('.user-stats-trigger').forEach(trigger => {
|
||
let hoverTimeout = null;
|
||
|
||
trigger.addEventListener('mouseenter', function() {
|
||
const userId = this.dataset.userId;
|
||
hoverTimeout = setTimeout(() => showUserStats(this, userId), 500);
|
||
});
|
||
|
||
trigger.addEventListener('mouseleave', function() {
|
||
clearTimeout(hoverTimeout);
|
||
setTimeout(() => {
|
||
if (activeTooltip && !activeTooltip.matches(':hover')) {
|
||
activeTooltip.remove();
|
||
activeTooltip = null;
|
||
}
|
||
}, 200);
|
||
});
|
||
});
|
||
|
||
async function showUserStats(element, userId) {
|
||
// Remove any existing tooltip
|
||
if (activeTooltip) {
|
||
activeTooltip.remove();
|
||
}
|
||
|
||
// Create tooltip
|
||
const tooltip = document.createElement('div');
|
||
tooltip.className = 'user-stats-tooltip';
|
||
tooltip.innerHTML = '<div class="loading">Ładowanie...</div>';
|
||
element.appendChild(tooltip);
|
||
activeTooltip = tooltip;
|
||
|
||
// Check cache
|
||
if (userStatsCache[userId]) {
|
||
renderStats(tooltip, userStatsCache[userId]);
|
||
return;
|
||
}
|
||
|
||
// Fetch stats
|
||
try {
|
||
const response = await fetch(`/forum/user/${userId}/stats`);
|
||
const data = await response.json();
|
||
if (data.success) {
|
||
userStatsCache[userId] = data;
|
||
renderStats(tooltip, data);
|
||
} else {
|
||
tooltip.innerHTML = '<div class="loading">Błąd</div>';
|
||
}
|
||
} catch (e) {
|
||
tooltip.innerHTML = '<div class="loading">Błąd</div>';
|
||
}
|
||
|
||
// Close on mouse leave
|
||
tooltip.addEventListener('mouseleave', function() {
|
||
this.remove();
|
||
activeTooltip = null;
|
||
});
|
||
}
|
||
|
||
function renderStats(tooltip, data) {
|
||
const s = data.stats;
|
||
tooltip.innerHTML = `
|
||
<div class="stats-header">${data.user_name}</div>
|
||
<div class="stats-grid">
|
||
<div class="stat-item">
|
||
<span>Tematy:</span>
|
||
<span class="stat-value">${s.topics}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span>Odpowiedzi:</span>
|
||
<span class="stat-value">${s.replies}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span>Rozwiązania:</span>
|
||
<span class="stat-value">${s.solutions}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span>Reakcje:</span>
|
||
<span class="stat-value">${s.reactions_received}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Lightbox functions
|
||
function openLightbox(src) {
|
||
document.getElementById('lightboxImage').src = src;
|
||
document.getElementById('lightbox').classList.add('active');
|
||
}
|
||
|
||
function closeLightbox() {
|
||
document.getElementById('lightbox').classList.remove('active');
|
||
}
|
||
|
||
// Close lightbox with Escape key
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeLightbox();
|
||
}
|
||
});
|
||
|
||
// Confirm modal functions
|
||
let confirmCallback = null;
|
||
|
||
function showConfirmModal(title, message, warning, onConfirm) {
|
||
document.getElementById('confirmTitle').textContent = title;
|
||
document.getElementById('confirmMessage').textContent = message;
|
||
document.getElementById('confirmWarning').textContent = warning || '';
|
||
document.getElementById('confirmModal').classList.add('active');
|
||
confirmCallback = onConfirm;
|
||
}
|
||
|
||
function closeConfirmModal() {
|
||
document.getElementById('confirmModal').classList.remove('active');
|
||
confirmCallback = null;
|
||
}
|
||
|
||
document.getElementById('confirmButton').addEventListener('click', function() {
|
||
if (confirmCallback) {
|
||
confirmCallback();
|
||
}
|
||
closeConfirmModal();
|
||
});
|
||
|
||
// Close modal on overlay click
|
||
document.getElementById('confirmModal').addEventListener('click', function(e) {
|
||
if (e.target === this) {
|
||
closeConfirmModal();
|
||
}
|
||
});
|
||
|
||
// Admin functions
|
||
function deleteTopic(topicId) {
|
||
var topicTitle = document.querySelector('.topic-title')?.textContent?.trim() || '';
|
||
showConfirmModal(
|
||
'Usuń wątek',
|
||
'Czy na pewno chcesz usunąć wątek "' + topicTitle + '"?',
|
||
'Ta operacja usunie również wszystkie odpowiedzi i jest nieodwracalna.',
|
||
function() {
|
||
fetch(`/admin/forum/topic/${topicId}/delete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast('Wątek usunięty', 'success');
|
||
setTimeout(() => window.location.href = '/forum', 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd usuwania', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
);
|
||
}
|
||
|
||
function deleteReply(replyId) {
|
||
showConfirmModal(
|
||
'Usuń odpowiedź',
|
||
'Czy na pewno chcesz usunąć tę odpowiedź?',
|
||
'Ta operacja jest nieodwracalna.',
|
||
function() {
|
||
fetch(`/admin/forum/reply/${replyId}/delete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast('Odpowiedź usunięta', 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd usuwania', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
);
|
||
}
|
||
|
||
function togglePin(topicId) {
|
||
fetch(`/admin/forum/topic/${topicId}/pin`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
function toggleLock(topicId) {
|
||
fetch(`/admin/forum/topic/${topicId}/lock`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// USER ACTIONS: Subscribe, Reactions, Edit, Delete, Report
|
||
// ============================================================
|
||
|
||
// Quote reply - insert quoted text into reply textarea
|
||
function quoteReply(author, content) {
|
||
const textarea = document.getElementById('replyContent');
|
||
if (!textarea) {
|
||
showToast('Temat jest zamknięty', 'warning');
|
||
return;
|
||
}
|
||
|
||
// Format quote with author
|
||
const quote = `> **${author}** napisał(a):\n> ${content.trim().replace(/\n/g, '\n> ')}\n\n`;
|
||
|
||
// Insert at cursor position or append
|
||
const start = textarea.selectionStart;
|
||
const end = textarea.selectionEnd;
|
||
const currentValue = textarea.value;
|
||
|
||
textarea.value = currentValue.substring(0, start) + quote + currentValue.substring(end);
|
||
textarea.focus();
|
||
textarea.selectionStart = textarea.selectionEnd = start + quote.length;
|
||
|
||
// Scroll to textarea
|
||
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
function toggleSubscribe(topicId) {
|
||
const btn = document.getElementById('subscribeBtn');
|
||
const isSubscribed = btn.classList.contains('subscribed');
|
||
const endpoint = isSubscribed ? 'unsubscribe' : 'subscribe';
|
||
|
||
fetch(`/forum/topic/${topicId}/${endpoint}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
btn.classList.toggle('subscribed');
|
||
btn.innerHTML = btn.classList.contains('subscribed') ? '🔔 Obserwujesz' : '🔕 Obserwuj';
|
||
showToast(data.message, 'success');
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
function toggleReaction(contentType, contentId, emoji) {
|
||
fetch(`/forum/${contentType}/${contentId}/react`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
},
|
||
body: JSON.stringify({ reaction: emoji })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
// Update UI
|
||
const container = document.querySelector(`[data-content-type="${contentType}"][data-content-id="${contentId}"]`);
|
||
if (container) {
|
||
container.querySelectorAll('.reaction-btn').forEach(btn => {
|
||
const btnEmoji = btn.textContent.trim().split(' ')[0];
|
||
const count = data.reactions[btnEmoji] || 0;
|
||
btn.querySelector('.count').textContent = count;
|
||
btn.classList.toggle('active', btnEmoji === data.user_reaction);
|
||
// Update tooltip with user names
|
||
const names = (data.reaction_names && data.reaction_names[btnEmoji]) || [];
|
||
btn.title = names.join(', ');
|
||
});
|
||
}
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// Edit modal functions
|
||
function openEditModal(contentType, contentId, currentContent) {
|
||
document.getElementById('editContentType').value = contentType;
|
||
document.getElementById('editContentId').value = contentId;
|
||
document.getElementById('editContent').value = currentContent;
|
||
document.getElementById('editType').textContent = contentType === 'topic' ? 'temat' : 'odpowiedź';
|
||
document.getElementById('editModal').classList.add('active');
|
||
}
|
||
|
||
function closeEditModal() {
|
||
document.getElementById('editModal').classList.remove('active');
|
||
}
|
||
|
||
function saveEdit() {
|
||
const contentType = document.getElementById('editContentType').value;
|
||
const contentId = document.getElementById('editContentId').value;
|
||
const newContent = document.getElementById('editContent').value.trim();
|
||
|
||
if (!newContent) {
|
||
showToast('Treść nie może być pusta', 'error');
|
||
return;
|
||
}
|
||
|
||
fetch(`/forum/${contentType}/${contentId}/edit`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
},
|
||
body: JSON.stringify({ content: newContent })
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
closeEditModal();
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd edycji', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// User delete own reply
|
||
function deleteOwnReply(replyId) {
|
||
showConfirmModal(
|
||
'Usuń odpowiedź',
|
||
'Czy na pewno chcesz usunąć swoją odpowiedź?',
|
||
'',
|
||
function() {
|
||
fetch(`/forum/reply/${replyId}/delete`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd usuwania', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
);
|
||
}
|
||
|
||
// Report modal functions
|
||
function openReportModal(contentType, contentId) {
|
||
document.getElementById('reportContentType').value = contentType;
|
||
document.getElementById('reportContentId').value = contentId;
|
||
document.getElementById('reportReason').value = '';
|
||
document.getElementById('reportDescription').value = '';
|
||
document.getElementById('reportModal').classList.add('active');
|
||
}
|
||
|
||
function closeReportModal() {
|
||
document.getElementById('reportModal').classList.remove('active');
|
||
}
|
||
|
||
function submitReport() {
|
||
const contentType = document.getElementById('reportContentType').value;
|
||
const contentId = document.getElementById('reportContentId').value;
|
||
const reason = document.getElementById('reportReason').value;
|
||
const description = document.getElementById('reportDescription').value.trim();
|
||
|
||
if (!reason) {
|
||
showToast('Wybierz powód zgłoszenia', 'error');
|
||
return;
|
||
}
|
||
|
||
fetch('/forum/report', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
},
|
||
body: JSON.stringify({
|
||
content_type: contentType,
|
||
content_id: contentId,
|
||
reason: reason,
|
||
description: description
|
||
})
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
closeReportModal();
|
||
} else {
|
||
showToast(data.error || 'Błąd zgłoszenia', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// Admin: Toggle solution
|
||
function toggleSolution(replyId) {
|
||
fetch(`/admin/forum/reply/${replyId}/solution`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// Admin: Restore reply
|
||
function restoreReply(replyId) {
|
||
fetch(`/admin/forum/reply/${replyId}/restore`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': '{{ csrf_token() }}'
|
||
}
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
showToast(data.message, 'success');
|
||
setTimeout(() => location.reload(), 1000);
|
||
} else {
|
||
showToast(data.error || 'Błąd', 'error');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showToast('Błąd połączenia', 'error');
|
||
});
|
||
}
|
||
|
||
// Close modals on overlay click
|
||
document.getElementById('editModal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeEditModal();
|
||
});
|
||
|
||
document.getElementById('reportModal').addEventListener('click', function(e) {
|
||
if (e.target === this) closeReportModal();
|
||
});
|
||
|
||
// Multi-file upload handling (only if form exists)
|
||
const dropzone = document.getElementById('dropzone');
|
||
if (dropzone) {
|
||
const fileInput = document.getElementById('attachmentInput');
|
||
const previewsContainer = document.getElementById('previewsContainer');
|
||
const uploadCounter = document.getElementById('uploadCounter');
|
||
const replyContent = document.getElementById('replyContent');
|
||
|
||
const MAX_FILES = 10;
|
||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||
|
||
// Store files in a Map for easy removal
|
||
let filesMap = new Map();
|
||
let fileIdCounter = 0;
|
||
|
||
// Click to upload
|
||
dropzone.addEventListener('click', () => fileInput.click());
|
||
|
||
// Drag and drop
|
||
dropzone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropzone.classList.add('drag-over');
|
||
});
|
||
|
||
dropzone.addEventListener('dragleave', () => {
|
||
dropzone.classList.remove('drag-over');
|
||
});
|
||
|
||
dropzone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropzone.classList.remove('drag-over');
|
||
const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
||
addFiles(droppedFiles);
|
||
});
|
||
|
||
// File input change
|
||
fileInput.addEventListener('change', (e) => {
|
||
const selectedFiles = Array.from(e.target.files);
|
||
addFiles(selectedFiles);
|
||
// Reset input to allow selecting same files again
|
||
fileInput.value = '';
|
||
});
|
||
|
||
// Paste from clipboard (Ctrl+V)
|
||
document.addEventListener('paste', (e) => {
|
||
// Only handle paste if reply textarea is focused
|
||
if (document.activeElement !== replyContent && !replyContent.contains(document.activeElement)) {
|
||
return;
|
||
}
|
||
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
|
||
const pastedFiles = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (items[i].type.startsWith('image/')) {
|
||
e.preventDefault();
|
||
const file = items[i].getAsFile();
|
||
if (file) {
|
||
pastedFiles.push(file);
|
||
}
|
||
}
|
||
}
|
||
if (pastedFiles.length > 0) {
|
||
addFiles(pastedFiles);
|
||
}
|
||
});
|
||
|
||
function addFiles(newFiles) {
|
||
const currentCount = filesMap.size;
|
||
const availableSlots = MAX_FILES - currentCount;
|
||
|
||
if (availableSlots <= 0) {
|
||
showToast('Osiągnięto limit ' + MAX_FILES + ' plików', 'warning');
|
||
return;
|
||
}
|
||
|
||
const filesToAdd = newFiles.slice(0, availableSlots);
|
||
const errors = [];
|
||
|
||
filesToAdd.forEach(file => {
|
||
// Validate size
|
||
if (file.size > MAX_SIZE) {
|
||
errors.push(file.name + ': za duży (max 5MB)');
|
||
return;
|
||
}
|
||
|
||
// Validate type
|
||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||
errors.push(file.name + ': niedozwolony format');
|
||
return;
|
||
}
|
||
|
||
const fileId = 'file_' + (fileIdCounter++);
|
||
filesMap.set(fileId, file);
|
||
createPreview(fileId, file);
|
||
});
|
||
|
||
if (errors.length > 0) {
|
||
showToast('Błędy: ' + errors.join(', '), 'error');
|
||
}
|
||
|
||
updateCounter();
|
||
syncFilesToInput();
|
||
}
|
||
|
||
function createPreview(fileId, file) {
|
||
const preview = document.createElement('div');
|
||
preview.className = 'upload-preview-item';
|
||
preview.dataset.fileId = fileId;
|
||
|
||
const img = document.createElement('img');
|
||
const info = document.createElement('div');
|
||
info.className = 'preview-info';
|
||
info.textContent = file.name.substring(0, 15) + (file.name.length > 15 ? '...' : '') + ' (' + formatFileSize(file.size) + ')';
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.type = 'button';
|
||
removeBtn.className = 'remove-preview';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.title = 'Usuń';
|
||
removeBtn.onclick = () => removeFile(fileId);
|
||
|
||
preview.appendChild(img);
|
||
preview.appendChild(info);
|
||
preview.appendChild(removeBtn);
|
||
previewsContainer.appendChild(preview);
|
||
|
||
// Load image preview
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
function removeFile(fileId) {
|
||
filesMap.delete(fileId);
|
||
const preview = previewsContainer.querySelector('[data-file-id="' + fileId + '"]');
|
||
if (preview) {
|
||
preview.remove();
|
||
}
|
||
updateCounter();
|
||
syncFilesToInput();
|
||
}
|
||
|
||
function updateCounter() {
|
||
const count = filesMap.size;
|
||
if (count === 0) {
|
||
uploadCounter.textContent = '';
|
||
uploadCounter.classList.remove('limit-reached');
|
||
dropzone.style.display = 'block';
|
||
} else {
|
||
uploadCounter.textContent = 'Wybrano: ' + count + '/' + MAX_FILES + ' plikow';
|
||
uploadCounter.classList.toggle('limit-reached', count >= MAX_FILES);
|
||
dropzone.style.display = count >= MAX_FILES ? 'none' : 'block';
|
||
}
|
||
}
|
||
|
||
function syncFilesToInput() {
|
||
try {
|
||
const dataTransfer = new DataTransfer();
|
||
filesMap.forEach(file => {
|
||
dataTransfer.items.add(file);
|
||
});
|
||
fileInput.files = dataTransfer.files;
|
||
} catch (e) {
|
||
// DataTransfer not supported — form submit handler will use FormData fallback
|
||
}
|
||
}
|
||
|
||
// Intercept form submit to ensure files are sent even if DataTransfer failed
|
||
const replyForm = dropzone.closest('form');
|
||
replyForm.addEventListener('submit', function(e) {
|
||
if (filesMap.size === 0) return; // no files, let form submit normally
|
||
|
||
// Check if fileInput actually has files (DataTransfer worked)
|
||
if (fileInput.files && fileInput.files.length > 0) return;
|
||
|
||
// DataTransfer failed — manually submit with FormData
|
||
e.preventDefault();
|
||
const formData = new FormData(replyForm);
|
||
// Remove empty attachments[] field
|
||
formData.delete('attachments[]');
|
||
// Add files from Map
|
||
filesMap.forEach(file => {
|
||
formData.append('attachments[]', file);
|
||
});
|
||
|
||
fetch(replyForm.action, {
|
||
method: 'POST',
|
||
body: formData
|
||
}).then(resp => {
|
||
if (resp.redirected) {
|
||
window.location.href = resp.url;
|
||
} else {
|
||
window.location.reload();
|
||
}
|
||
}).catch(() => {
|
||
showToast('Błąd wysyłania', 'error');
|
||
});
|
||
});
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
}
|
||
}
|
||
{% endblock %}
|