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
B2B ogłoszenia mogły zostać stworzone 3x (user 81 Bormax 14.04.2026 w ciągu 2 sekund) — brak dedup window server-side i disable submit button. Rozszerzam zabezpieczenie także na announcements i board meeting form. - classifieds POST /nowe: odrzuć duplikat z ostatnich 60s (ten sam author+company+title) → redirect do istniejącego z flash info - classifieds new.html: disable submitBtn + "Wysyłanie..." po walidacji; ponowne kliknięcie blokowane event.preventDefault - announcements_form.html + board/meeting_form.html: jednolity handler disable wszystkich button[type="submit"] po pierwszym submit Forum topic/reply już miały analogiczne zabezpieczenie (bez zmian). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
358 lines
13 KiB
HTML
Executable File
358 lines
13 KiB
HTML
Executable File
{% extends "base.html" %}
|
|
|
|
{% block title %}{% if announcement %}Edytuj ogloszenie{% else %}Nowe ogloszenie{% endif %} - Norda Biznes Partner{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.admin-header {
|
|
margin-bottom: var(--spacing-xl);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.admin-header h1 {
|
|
font-size: var(--font-size-3xl);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-section {
|
|
background: var(--surface);
|
|
padding: var(--spacing-xl);
|
|
border-radius: var(--radius-lg);
|
|
box-shadow: var(--shadow);
|
|
max-width: 900px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: var(--spacing-lg);
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: var(--spacing-xs);
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group label .required {
|
|
color: var(--error);
|
|
}
|
|
|
|
.form-group input[type="text"],
|
|
.form-group input[type="url"],
|
|
.form-group input[type="datetime-local"],
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
font-size: var(--font-size-base);
|
|
font-family: inherit;
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 120px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.form-group textarea.content-editor {
|
|
min-height: 300px;
|
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: var(--spacing-lg);
|
|
}
|
|
|
|
.checkbox-group {
|
|
display: flex;
|
|
gap: var(--spacing-lg);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.checkbox-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
}
|
|
|
|
.checkbox-item input[type="checkbox"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
|
|
.form-hint {
|
|
font-size: var(--font-size-sm);
|
|
color: var(--text-secondary);
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.btn-group {
|
|
display: flex;
|
|
gap: var(--spacing-md);
|
|
margin-top: var(--spacing-xl);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.char-counter {
|
|
font-size: var(--font-size-xs);
|
|
color: var(--text-secondary);
|
|
text-align: right;
|
|
margin-top: var(--spacing-xs);
|
|
}
|
|
|
|
.char-counter.warning {
|
|
color: var(--warning);
|
|
}
|
|
|
|
.char-counter.error {
|
|
color: var(--error);
|
|
}
|
|
|
|
.status-info {
|
|
background: var(--background);
|
|
padding: var(--spacing-md);
|
|
border-radius: var(--radius);
|
|
margin-bottom: var(--spacing-lg);
|
|
font-size: var(--font-size-sm);
|
|
}
|
|
|
|
.status-info strong {
|
|
color: var(--primary);
|
|
}
|
|
|
|
.preview-image {
|
|
max-width: 200px;
|
|
max-height: 150px;
|
|
border-radius: var(--radius);
|
|
margin-top: var(--spacing-sm);
|
|
}
|
|
|
|
.section-title {
|
|
font-size: var(--font-size-lg);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-top: var(--spacing-xl);
|
|
margin-bottom: var(--spacing-md);
|
|
padding-bottom: var(--spacing-xs);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container">
|
|
<div class="admin-header">
|
|
<h1>{% if announcement %}Edytuj ogloszenie{% else %}Nowe ogloszenie{% endif %}</h1>
|
|
{% if announcement %}
|
|
<a href="{{ url_for('announcement_detail', slug=announcement.slug) }}" class="btn btn-secondary" target="_blank">
|
|
Podglad ↗
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if announcement %}
|
|
<div class="status-info">
|
|
<strong>Status:</strong> {{ announcement.status_label }}
|
|
{% if announcement.published_at %}
|
|
| <strong>Opublikowano:</strong> {{ announcement.published_at|local_time('%Y-%m-%d %H:%M') }}
|
|
{% endif %}
|
|
{% if announcement.views_count %}
|
|
| <strong>Wyswietlenia:</strong> {{ announcement.views_count }}
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="form-section">
|
|
<form method="POST" id="announcementForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
|
|
<!-- Basic Info -->
|
|
<div class="form-group">
|
|
<label for="title">Tytul <span class="required">*</span></label>
|
|
<input type="text" id="title" name="title" required maxlength="300"
|
|
value="{{ announcement.title if announcement else '' }}"
|
|
placeholder="np. Baza noclegowa dla pracownikow budowy elektrowni jadrowej">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="excerpt">Krotki opis (do listy)</label>
|
|
<textarea id="excerpt" name="excerpt" maxlength="500"
|
|
placeholder="Krotki opis wyswietlany na liscie ogloszen (max 500 znakow)">{{ announcement.excerpt if announcement else '' }}</textarea>
|
|
<div class="char-counter" id="excerpt-counter">0 / 500</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="content">Tresc <span class="required">*</span></label>
|
|
<textarea id="content" name="content" required class="content-editor"
|
|
placeholder="Pelna tresc ogloszenia (mozesz uzyc HTML)">{{ announcement.content if announcement else '' }}</textarea>
|
|
<p class="form-hint">Mozesz uzyc HTML: <p>, <h3>, <ul>, <li>, <a href="">, <strong></p>
|
|
</div>
|
|
|
|
<!-- Categorization -->
|
|
<h3 class="section-title">Kategoryzacja</h3>
|
|
|
|
<div class="form-group">
|
|
<label>Kategorie <span class="required">*</span></label>
|
|
<p class="form-hint" style="margin-bottom: var(--spacing-sm);">Wybierz co najmniej jedną kategorię (możesz wybrać kilka)</p>
|
|
<div class="checkbox-group">
|
|
{% for cat in categories %}
|
|
<label class="checkbox-item">
|
|
<input type="checkbox" name="categories" value="{{ cat }}"
|
|
{% if announcement and announcement.has_category(cat) %}checked{% endif %}>
|
|
<span>{{ category_labels.get(cat, cat) }}</span>
|
|
</label>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Media -->
|
|
<h3 class="section-title">Media i linki</h3>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="image_url">URL obrazka</label>
|
|
<input type="url" id="image_url" name="image_url"
|
|
value="{{ announcement.image_url if announcement else '' }}"
|
|
placeholder="https://example.com/image.jpg">
|
|
<p class="form-hint">Opcjonalny obrazek wyswietlany przy ogloszeniu</p>
|
|
{% if announcement and announcement.image_url %}
|
|
<img src="{{ announcement.image_url }}" alt="Preview" class="preview-image" onerror="this.style.display='none'">
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="external_link">Link zewnetrzny</label>
|
|
<input type="url" id="external_link" name="external_link"
|
|
value="{{ announcement.external_link if announcement else '' }}"
|
|
placeholder="https://example.com/wiecej-informacji">
|
|
<p class="form-hint">Link do zewnetrznego zrodla lub formularza</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Publication -->
|
|
<h3 class="section-title">Publikacja</h3>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="expires_at">Data wygasniecia</label>
|
|
<input type="datetime-local" id="expires_at" name="expires_at"
|
|
value="{{ announcement.expires_at|local_time('%Y-%m-%dT%H:%M') if announcement and announcement.expires_at else '' }}">
|
|
<p class="form-hint">Pozostaw puste aby nie wygasalo</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Opcje wyswietlania</label>
|
|
<div class="checkbox-group">
|
|
<label class="checkbox-item">
|
|
<input type="checkbox" name="is_pinned"
|
|
{% if announcement and announcement.is_pinned %}checked{% endif %}>
|
|
<span>📌 Przypiete (wyswietlane na gorze)</span>
|
|
</label>
|
|
<label class="checkbox-item">
|
|
<input type="checkbox" name="is_featured"
|
|
{% if announcement and announcement.is_featured %}checked{% endif %}>
|
|
<span>⭐ Wyrozone (specjalne wyroznienie)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="btn-group">
|
|
<a href="{{ url_for('admin.admin_announcements') }}" class="btn btn-secondary">Anuluj</a>
|
|
|
|
{% if announcement %}
|
|
<!-- Edit mode -->
|
|
<button type="submit" name="action" value="save" class="btn btn-primary">
|
|
Zapisz zmiany
|
|
</button>
|
|
{% if announcement.status == 'draft' %}
|
|
<button type="submit" name="action" value="publish" class="btn btn-success">
|
|
Opublikuj
|
|
</button>
|
|
{% elif announcement.status == 'published' %}
|
|
<button type="submit" name="action" value="archive" class="btn btn-warning">
|
|
Archiwizuj
|
|
</button>
|
|
{% elif announcement.status == 'archived' %}
|
|
<button type="submit" name="action" value="publish" class="btn btn-success">
|
|
Przywroc i opublikuj
|
|
</button>
|
|
{% endif %}
|
|
{% else %}
|
|
<!-- New mode -->
|
|
<button type="submit" name="action" value="draft" class="btn btn-secondary">
|
|
Zapisz szkic
|
|
</button>
|
|
<button type="submit" name="action" value="publish" class="btn btn-success">
|
|
Opublikuj
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
// Anty double/triple-click — blokada wielokrotnego submitu
|
|
(function() {
|
|
const form = document.getElementById('announcementForm');
|
|
if (!form) return;
|
|
form.addEventListener('submit', function(e) {
|
|
const btns = form.querySelectorAll('button[type="submit"]');
|
|
if (Array.from(btns).some(function(b) { return b.disabled; })) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
btns.forEach(function(b) {
|
|
b.dataset.originalText = b.textContent.trim();
|
|
b.disabled = true;
|
|
b.textContent = 'Wysyłanie...';
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Character counter for excerpt
|
|
const excerptTextarea = document.getElementById('excerpt');
|
|
const excerptCounter = document.getElementById('excerpt-counter');
|
|
|
|
function updateExcerptCounter() {
|
|
const length = excerptTextarea.value.length;
|
|
excerptCounter.textContent = length + ' / 500';
|
|
excerptCounter.classList.remove('warning', 'error');
|
|
if (length > 450) {
|
|
excerptCounter.classList.add('warning');
|
|
}
|
|
if (length >= 500) {
|
|
excerptCounter.classList.add('error');
|
|
}
|
|
}
|
|
|
|
excerptTextarea.addEventListener('input', updateExcerptCounter);
|
|
updateExcerptCounter();
|
|
|
|
// Image preview on URL change
|
|
const imageUrlInput = document.getElementById('image_url');
|
|
imageUrlInput.addEventListener('change', function() {
|
|
const existingPreview = document.querySelector('.preview-image');
|
|
if (existingPreview) {
|
|
existingPreview.src = this.value;
|
|
existingPreview.style.display = this.value ? 'block' : 'none';
|
|
} else if (this.value) {
|
|
const img = document.createElement('img');
|
|
img.src = this.value;
|
|
img.alt = 'Preview';
|
|
img.className = 'preview-image';
|
|
img.onerror = function() { this.style.display = 'none'; };
|
|
this.parentNode.appendChild(img);
|
|
}
|
|
});
|
|
{% endblock %}
|