nordabiz/templates/classifieds/edit.html
Maciej Pienczyn e1a16e2542
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
fix(classifieds): scope Quill submit handler to actual form
document.querySelector('form') was matching the company switcher form
in the navbar, not the classifieds form. Quill content was therefore
never synced into the hidden description textarea, server saw it empty
and rejected the submit with a misleading 'fill all fields' error.

Added id='classifiedForm' to both new.html and edit.html and switched
selectors to getElementById.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:42:56 +02:00

321 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Edycja ogloszenia - Norda Biznes Partner{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
{% endblock %}
{% block extra_css %}
<style>
.form-container { max-width: 700px; margin: 0 auto; }
.form-header { margin-bottom: var(--spacing-xl); }
.form-header h1 { font-size: var(--font-size-3xl); color: var(--text-primary); }
.form-card { background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl); box-shadow: var(--shadow); }
.form-group { margin-bottom: var(--spacing-lg); }
.form-group label { display: block; font-weight: 500; margin-bottom: var(--spacing-xs); color: var(--text-primary); }
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: var(--spacing-sm) var(--spacing-md); border: 1px solid var(--border); border-radius: var(--radius); font-size: var(--font-size-base); transition: var(--transition); }
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--spacing-md); }
.form-actions { display: flex; gap: var(--spacing-md); margin-top: var(--spacing-xl); }
.back-link { display: inline-flex; align-items: center; gap: var(--spacing-xs); color: var(--text-secondary); text-decoration: none; margin-bottom: var(--spacing-lg); }
.back-link:hover { color: var(--primary); }
.type-selector { display: flex; gap: var(--spacing-md); }
.type-option { flex: 1; position: relative; }
.type-option input { position: absolute; opacity: 0; }
.type-option label { display: flex; flex-direction: column; align-items: center; padding: var(--spacing-lg); border: 2px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; transition: var(--transition); }
.type-option input:checked + label { border-color: var(--primary); background: rgba(37, 99, 235, 0.05); }
.type-option label:hover { border-color: var(--primary); }
.type-icon { font-size: 24px; margin-bottom: var(--spacing-sm); }
/* 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; }
.quill-container { border: 1px solid var(--border); border-radius: var(--radius); }
.quill-container .ql-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.quill-container .ql-container { border-bottom-left-radius: var(--radius); border-bottom-right-radius: var(--radius); font-size: var(--font-size-base); }
.quill-container .ql-editor { min-height: 150px; }
.field-error, input.field-error, .quill-container.field-error {
border: 2px solid #dc2626 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
:invalid:not(:focus):not(:placeholder-shown) { border: 2px solid #dc2626; }
/* Existing attachments */
.existing-attachment { position: relative; border: 1px solid var(--border); border-radius: var(--radius); padding: var(--spacing-xs); background: var(--surface); }
.existing-attachment img { width: 100%; height: 80px; object-fit: cover; border-radius: var(--radius-sm); }
.existing-attachment .preview-info { font-size: 10px; color: var(--text-secondary); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.existing-attachment .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; }
.existing-attachment .remove-preview:hover { background: #c53030; }
.existing-attachment.marked-for-delete { opacity: 0.3; }
.existing-attachment.marked-for-delete::after { content: 'Usunięto'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--error); font-weight: 700; font-size: var(--font-size-sm); }
</style>
{% endblock %}
{% block content %}
<div class="form-container">
<a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}" class="back-link">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Powrót do ogłoszenia
</a>
<div class="form-header">
<h1>Edytuj ogłoszenie</h1>
</div>
<div class="form-card">
<form id="classifiedForm" method="POST" action="{{ url_for('classifieds.classifieds_edit', classified_id=classified.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Typ ogłoszenia *</label>
<div class="type-selector">
<div class="type-option">
<input type="radio" id="type_szukam" name="listing_type" value="szukam" required {% if classified.listing_type == 'szukam' %}checked{% endif %}>
<label for="type_szukam">
<span class="type-icon">🔍</span>
<strong>Szukam</strong>
</label>
</div>
<div class="type-option">
<input type="radio" id="type_oferuje" name="listing_type" value="oferuje" required {% if classified.listing_type == 'oferuje' %}checked{% endif %}>
<label for="type_oferuje">
<span class="type-icon"></span>
<strong>Oferuję</strong>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="category">Kategoria *</label>
<select id="category" name="category" required>
<option value="">Wybierz kategorię...</option>
<option value="uslugi" {% if classified.category == 'uslugi' %}selected{% endif %}>Usługi profesjonalne</option>
<option value="produkty" {% if classified.category == 'produkty' %}selected{% endif %}>Produkty, materiały</option>
<option value="wspolpraca" {% if classified.category == 'wspolpraca' %}selected{% endif %}>Propozycje współpracy</option>
<option value="praca" {% if classified.category == 'praca' %}selected{% endif %}>Oferty pracy, zlecenia</option>
<option value="inne" {% if classified.category == 'inne' %}selected{% endif %}>Inne</option>
</select>
</div>
<div class="form-group">
<label for="title">Tytuł ogłoszenia *</label>
<input type="text" id="title" name="title" required maxlength="255" value="{{ classified.title }}" class="{% if missing_fields and missing_fields.title %}field-error{% endif %}">
</div>
<div class="form-group">
<label>Opis *</label>
<div id="quill-editor" class="quill-container{% if missing_fields and missing_fields.description %} field-error{% endif %}"></div>
<textarea id="description" name="description" style="display:none;"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="budget_info">Budżet / Cena</label>
<input type="text" id="budget_info" name="budget_info" maxlength="255" value="{{ classified.budget_info or '' }}">
</div>
<div class="form-group">
<label for="location_info">Lokalizacja</label>
<input type="text" id="location_info" name="location_info" maxlength="255" value="{{ classified.location_info or '' }}">
</div>
</div>
<div class="form-group">
<label>Zdjecia</label>
{% if classified.attachments %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm);">Istniejące zdjęcia (kliknij X aby usunąć):</div>
<div class="upload-previews-container" id="existingAttachments">
{% for att in classified.attachments %}
<div class="existing-attachment" id="existing-{{ att.id }}">
<img src="{{ att.url }}" alt="{{ att.original_filename }}">
<div class="preview-info">{{ att.original_filename[:20] }}</div>
<button type="button" class="remove-preview" onclick="toggleDeleteAttachment({{ att.id }})">&times;</button>
<input type="checkbox" name="delete_attachments[]" value="{{ att.id }}" style="display:none;" id="del-{{ att.id }}">
</div>
{% endfor %}
</div>
{% endif %}
<div style="font-size: var(--font-size-sm); color: var(--text-secondary); margin-bottom: var(--spacing-sm); margin-top: var(--spacing-md);">Dodaj nowe zdjęcia:</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, 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">Zapisz zmiany</button>
<a href="{{ url_for('classifieds.classifieds_view', classified_id=classified.id) }}" class="btn btn-secondary">Anuluj</a>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
var quill = new Quill('#quill-editor', {
theme: 'snow',
placeholder: 'Opisz szczegółowo czego szukasz lub co oferujesz...',
modules: {
toolbar: [
['bold', 'italic'],
[{'list': 'ordered'}, {'list': 'bullet'}],
['link'],
['clean']
]
}
});
quill.root.innerHTML = {{ classified.description|tojson }};
(function() {
var qc = document.getElementById('quill-editor');
quill.on('text-change', function() { qc && qc.classList.remove('field-error'); });
document.getElementById('classifiedForm').addEventListener('submit', function(e) {
var html = quill.root.innerHTML;
var empty = (html === '<p><br></p>' || quill.getText().trim() === '');
if (empty) {
e.preventDefault();
qc && qc.classList.add('field-error');
qc && qc.scrollIntoView({behavior: 'smooth', block: 'center'});
quill.focus();
return;
}
qc && qc.classList.remove('field-error');
document.getElementById('description').value = html;
});
})();
function toggleDeleteAttachment(attId) {
var el = document.getElementById('existing-' + attId);
var cb = document.getElementById('del-' + attId);
if (cb.checked) {
cb.checked = false;
el.classList.remove('marked-for-delete');
} else {
cb.checked = true;
el.classList.add('marked-for-delete');
}
}
(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 = ''; });
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 = '&times;';
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 = '';
dropzone.style.display = 'block';
} else {
uploadCounter.textContent = 'Nowych: ' + 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) {}
}
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 %}