feat: add image attachments to B2B classifieds
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
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
- New ClassifiedAttachment model with migration - FileUploadService extended with 'classified' type - Dropzone with drag & drop, paste, multi-file preview in creation form - Image gallery with lightbox in classified detail view - Max 10 files, 5MB each, JPG/PNG/GIF Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a51142cfe
commit
699af41efa
@ -10,7 +10,7 @@ from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
|
||||
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, ClassifiedAttachment, User
|
||||
from sqlalchemy import desc
|
||||
from utils.helpers import sanitize_input
|
||||
from utils.decorators import member_required
|
||||
@ -106,6 +106,41 @@ def new():
|
||||
expires_at=expires
|
||||
)
|
||||
db.add(classified)
|
||||
db.flush() # Get classified.id before commit
|
||||
|
||||
# Handle file uploads
|
||||
files = request.files.getlist('attachments[]')
|
||||
if files:
|
||||
try:
|
||||
from file_upload_service import FileUploadService
|
||||
saved_count = 0
|
||||
for file in files[:10]: # Max 10 files
|
||||
if not file or file.filename == '':
|
||||
continue
|
||||
is_valid, error_msg = FileUploadService.validate_file(file)
|
||||
if not is_valid:
|
||||
flash(f'Plik {file.filename}: {error_msg}', 'warning')
|
||||
continue
|
||||
stored_filename, rel_path, file_size, mime_type = FileUploadService.save_file(file, 'classified')
|
||||
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||
attachment = ClassifiedAttachment(
|
||||
classified_id=classified.id,
|
||||
original_filename=file.filename,
|
||||
stored_filename=stored_filename,
|
||||
file_extension=ext,
|
||||
file_size=file_size,
|
||||
mime_type=mime_type,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
db.add(attachment)
|
||||
saved_count += 1
|
||||
if saved_count > 0:
|
||||
flash(f'Dodano {saved_count} zdjęć.', 'info')
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(f"Upload error: {e}")
|
||||
flash('Nie udało się zapisać niektórych plików.', 'warning')
|
||||
|
||||
db.commit()
|
||||
|
||||
flash('Ogłoszenie dodane.', 'success')
|
||||
|
||||
38
database.py
38
database.py
@ -2565,6 +2565,7 @@ class Classified(Base):
|
||||
|
||||
author = relationship('User', backref='classifieds')
|
||||
company = relationship('Company')
|
||||
attachments = relationship('ClassifiedAttachment', back_populates='classified', cascade='all, delete-orphan', order_by='ClassifiedAttachment.created_at')
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
@ -2573,6 +2574,43 @@ class Classified(Base):
|
||||
return False
|
||||
|
||||
|
||||
class ClassifiedAttachment(Base):
|
||||
"""File attachments for B2B classifieds"""
|
||||
__tablename__ = 'classified_attachments'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
classified_id = Column(Integer, ForeignKey('classifieds.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
|
||||
# File metadata
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
stored_filename = Column(String(255), nullable=False, unique=True)
|
||||
file_extension = Column(String(10), nullable=False)
|
||||
file_size = Column(Integer, nullable=False) # in bytes
|
||||
mime_type = Column(String(100), nullable=False)
|
||||
|
||||
# Uploader
|
||||
uploaded_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
classified = relationship('Classified', back_populates='attachments')
|
||||
uploader = relationship('User')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
date = self.created_at or datetime.now()
|
||||
return f"/static/uploads/classifieds/{date.year}/{date.month:02d}/{self.stored_filename}"
|
||||
|
||||
@property
|
||||
def is_image(self):
|
||||
return self.file_extension.lower() in ('jpg', 'jpeg', 'png', 'gif')
|
||||
|
||||
|
||||
class ClassifiedRead(Base):
|
||||
"""
|
||||
Śledzenie odczytów ogłoszeń B2B (seen by).
|
||||
|
||||
19
database/migrations/add_classified_attachments.sql
Normal file
19
database/migrations/add_classified_attachments.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- Migration: Add classified_attachments table for B2B image uploads
|
||||
-- Date: 2026-04-10
|
||||
|
||||
CREATE TABLE IF NOT EXISTS classified_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
classified_id INTEGER NOT NULL REFERENCES classifieds(id) ON DELETE CASCADE,
|
||||
original_filename VARCHAR(255) NOT NULL,
|
||||
stored_filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
file_extension VARCHAR(10) NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
uploaded_by INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_classified_attachments_classified_id ON classified_attachments(classified_id);
|
||||
|
||||
GRANT ALL ON TABLE classified_attachments TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE classified_attachments_id_seq TO nordabiz_app;
|
||||
@ -1,8 +1,8 @@
|
||||
"""
|
||||
Forum File Upload Service
|
||||
=========================
|
||||
File Upload Service
|
||||
===================
|
||||
|
||||
Secure file upload handling for forum attachments.
|
||||
Secure file upload handling for forum and classified attachments.
|
||||
Supports JPG, PNG, GIF images up to 5MB.
|
||||
|
||||
Features:
|
||||
@ -34,6 +34,7 @@ MAX_IMAGE_DIMENSIONS = (4096, 4096) # Max 4K resolution
|
||||
# Get absolute path based on this file's location
|
||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
UPLOAD_BASE_PATH = os.path.join(_BASE_DIR, 'static', 'uploads', 'forum')
|
||||
UPLOAD_CLASSIFIEDS_PATH = os.path.join(_BASE_DIR, 'static', 'uploads', 'classifieds')
|
||||
|
||||
# Magic bytes for image validation
|
||||
IMAGE_SIGNATURES = {
|
||||
@ -149,8 +150,11 @@ class FileUploadService:
|
||||
Full path to upload directory
|
||||
"""
|
||||
now = datetime.now()
|
||||
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||
path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}")
|
||||
if attachment_type == 'classified':
|
||||
path = os.path.join(UPLOAD_CLASSIFIEDS_PATH, str(now.year), f"{now.month:02d}")
|
||||
else:
|
||||
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||
path = os.path.join(UPLOAD_BASE_PATH, subdir, str(now.year), f"{now.month:02d}")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
@ -241,15 +245,19 @@ class FileUploadService:
|
||||
Returns:
|
||||
True if deleted, False otherwise
|
||||
"""
|
||||
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||
if attachment_type == 'classified':
|
||||
base_path = UPLOAD_CLASSIFIEDS_PATH
|
||||
subdir = None
|
||||
else:
|
||||
base_path = UPLOAD_BASE_PATH
|
||||
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||
|
||||
if created_at:
|
||||
# Try exact path first
|
||||
path = os.path.join(
|
||||
UPLOAD_BASE_PATH, subdir,
|
||||
str(created_at.year), f"{created_at.month:02d}",
|
||||
stored_filename
|
||||
)
|
||||
if subdir:
|
||||
path = os.path.join(base_path, subdir, str(created_at.year), f"{created_at.month:02d}", stored_filename)
|
||||
else:
|
||||
path = os.path.join(base_path, str(created_at.year), f"{created_at.month:02d}", stored_filename)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
os.remove(path)
|
||||
@ -260,8 +268,8 @@ class FileUploadService:
|
||||
return False
|
||||
|
||||
# Search in all date directories
|
||||
base_path = os.path.join(UPLOAD_BASE_PATH, subdir)
|
||||
for root, dirs, files in os.walk(base_path):
|
||||
search_path = os.path.join(base_path, subdir) if subdir else base_path
|
||||
for root, dirs, files in os.walk(search_path):
|
||||
if stored_filename in files:
|
||||
try:
|
||||
os.remove(os.path.join(root, stored_filename))
|
||||
@ -287,5 +295,7 @@ class FileUploadService:
|
||||
Returns:
|
||||
URL path to the file
|
||||
"""
|
||||
if attachment_type == 'classified':
|
||||
return f"/static/uploads/classifieds/{created_at.year}/{created_at.month:02d}/{stored_filename}"
|
||||
subdir = 'topics' if attachment_type == 'topic' else 'replies'
|
||||
return f"/static/uploads/forum/{subdir}/{created_at.year}/{created_at.month:02d}/{stored_filename}"
|
||||
|
||||
@ -134,6 +134,85 @@
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Upload dropzone */
|
||||
.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-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(--error); font-weight: 600; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -156,7 +235,7 @@
|
||||
Ogloszenie bedzie widoczne przez 30 dni. Po tym czasie wygasnie automatycznie.
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('classifieds.classifieds_new') }}">
|
||||
<form method="POST" action="{{ url_for('classifieds.classifieds_new') }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="form-group">
|
||||
@ -215,11 +294,149 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Zdjęcia (opcjonalnie)</label>
|
||||
<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, JPG/PNG/GIF do 5MB)</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>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Dodaj ogloszenie</button>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn">Dodaj ogloszenie</button>
|
||||
<a href="{{ url_for('classifieds.classifieds_index') }}" class="btn btn-secondary">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
(function() {
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
if (!dropzone) return;
|
||||
|
||||
const fileInput = document.getElementById('attachmentInput');
|
||||
const previewsContainer = document.getElementById('previewsContainer');
|
||||
const uploadCounter = document.getElementById('uploadCounter');
|
||||
const MAX_FILES = 10;
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
let filesMap = new Map();
|
||||
let fileIdCounter = 0;
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
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');
|
||||
addFiles(Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')));
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
addFiles(Array.from(e.target.files));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
// Paste from clipboard
|
||||
document.addEventListener('paste', (e) => {
|
||||
const desc = document.getElementById('description');
|
||||
if (document.activeElement !== desc) 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 availableSlots = MAX_FILES - filesMap.size;
|
||||
if (availableSlots <= 0) return;
|
||||
newFiles.slice(0, availableSlots).forEach(file => {
|
||||
if (file.size > MAX_SIZE || !ALLOWED_TYPES.includes(file.type)) return;
|
||||
const fileId = 'file_' + (fileIdCounter++);
|
||||
filesMap.set(fileId, file);
|
||||
createPreview(fileId, file);
|
||||
});
|
||||
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.onclick = () => { filesMap.delete(fileId); preview.remove(); updateCounter(); syncFilesToInput(); };
|
||||
preview.appendChild(img);
|
||||
preview.appendChild(info);
|
||||
preview.appendChild(removeBtn);
|
||||
previewsContainer.appendChild(preview);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => { img.src = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
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 + ' plików';
|
||||
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) {}
|
||||
}
|
||||
|
||||
// FormData fallback for mobile
|
||||
const form = dropzone.closest('form');
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (filesMap.size === 0) return;
|
||||
if (fileInput.files && fileInput.files.length > 0) return;
|
||||
e.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
formData.delete('attachments[]');
|
||||
filesMap.forEach(file => formData.append('attachments[]', file));
|
||||
fetch(form.action, { method: 'POST', body: formData })
|
||||
.then(resp => { if (resp.redirected) { window.location.href = resp.url; } else { window.location.reload(); } })
|
||||
.catch(() => alert('Błąd wysyłania'));
|
||||
});
|
||||
|
||||
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 %}
|
||||
|
||||
@ -630,6 +630,39 @@
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Classified image gallery */
|
||||
.classified-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
.classified-gallery img {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.classified-gallery img:hover {
|
||||
transform: scale(1.03);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -680,6 +713,16 @@
|
||||
|
||||
<div class="classified-description">{{ classified.description }}</div>
|
||||
|
||||
{% if classified.attachments %}
|
||||
<div class="classified-gallery">
|
||||
{% for att in classified.attachments %}
|
||||
{% if att.is_image %}
|
||||
<img src="{{ att.url }}" alt="{{ att.original_filename }}" onclick="openLightbox(this.src)" loading="lazy">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if classified.budget_info or classified.location_info %}
|
||||
<div class="classified-details">
|
||||
{% if classified.budget_info %}
|
||||
@ -860,6 +903,10 @@
|
||||
|
||||
<div id="toastContainer" style="position: fixed; top: 80px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 10px;"></div>
|
||||
|
||||
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
|
||||
<img id="lightboxImage" src="" alt="Powiększony obraz">
|
||||
</div>
|
||||
|
||||
<!-- Interests Modal -->
|
||||
<div class="modal-overlay" id="interestsModal">
|
||||
<div class="modal" style="max-width: 500px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
|
||||
@ -1182,4 +1229,15 @@ async function toggleQuestionVisibility(questionId) {
|
||||
showToast('Błąd połączenia', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openLightbox(src) {
|
||||
document.getElementById('lightboxImage').src = src;
|
||||
document.getElementById('lightbox').classList.add('active');
|
||||
}
|
||||
function closeLightbox() {
|
||||
document.getElementById('lightbox').classList.remove('active');
|
||||
}
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
});
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user