nordabiz/templates/forum/topic.html
Maciej Pienczyn f22342ea37 feat: Add forum modernization with reactions, subscriptions, and moderation
- Add edit tracking (24h limit), soft delete, and JSONB reactions to ForumTopic/ForumReply
- Create ForumTopicSubscription, ForumReport, ForumEditHistory models
- Add 15 new API endpoints for user actions and admin moderation
- Implement reactions (👍❤️🎉), topic subscriptions, content reporting
- Add solution marking, restore deleted content, edit history for admins
- Create forum_reports.html and forum_deleted.html admin templates
- Integrate notifications for replies, reactions, solutions, and reports
- Add SQL migration 024_forum_modernization.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:55:40 +01:00

1810 lines
58 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

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

{% 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);
white-space: pre-wrap;
}
/* 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-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);
}
.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> &raquo; {{ 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.is_admin %}
<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 }}, '{{ topic.title|e }}')" 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>
{{ topic.author.name or topic.author.email.split('@')[0] }}
{% 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.strftime('%d.%m.%Y %H:%M') }}
{% if topic.edited_at %}
<span class="edited-badge">(edytowano {{ topic.edited_at.strftime('%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 }}</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 count = (topic_reactions.get(emoji, [])|length) %}
{% set user_reacted = current_user.id in (topic_reactions.get(emoji, [])) %}
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('topic', {{ topic.id }}, '{{ emoji }}')">
{{ emoji }} <span class="count">{{ count }}</span>
</button>
{% endfor %}
</div>
<!-- User actions for topic -->
{% if not topic.is_locked %}
<div class="user-actions">
{% if topic.author_id == current_user.id or current_user.is_admin %}
<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">
<div class="reply-avatar">
{{ (reply.author.name or reply.author.email)[0].upper() }}
</div>
{{ reply.author.name or reply.author.email.split('@')[0] }}
{% 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.strftime('%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.is_admin %}
<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 }}</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 count = (reply_reactions.get(emoji, [])|length) %}
{% set user_reacted = current_user.id in (reply_reactions.get(emoji, [])) %}
<button type="button" class="reaction-btn {% if user_reacted %}active{% endif %}" onclick="toggleReaction('reply', {{ reply.id }}, '{{ emoji }}')">
{{ emoji }} <span class="count">{{ count }}</span>
</button>
{% endfor %}
</div>
<!-- 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="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 class="upload-counter" id="uploadCounter"></div>
<div class="upload-previews-container" id="previewsContainer"></div>
<div class="upload-dropzone-mini" id="dropzone">
<p>Przeciągnij obrazy lub kliknij tutaj (max 10 plików, możesz też wkleić Ctrl+V)</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">Wyślij odpowiedź</button>
</div>
</form>
{% 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 %}
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);
}
// 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, topicTitle) {
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
// ============================================================
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);
});
}
} 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 = '&times;';
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() {
// Create DataTransfer and add all files from Map
const dataTransfer = new DataTransfer();
filesMap.forEach(file => {
dataTransfer.items.add(file);
});
fileInput.files = dataTransfer.files;
}
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 %}