nordabiz/templates/classifieds/view.html
Maciej Pienczyn e87ba8ee09 feat: Add read tracking for Forum topics/replies and B2B classifieds
- Add ForumTopicRead, ForumReplyRead, ClassifiedRead models
- Add SQL migration for new tables
- Record reads when user views forum topic (topic + all visible replies)
- Record reads when user views B2B classified
- Display "Seen by" avatars in forum topic and B2B detail pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:50:27 +01:00

591 lines
20 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 %}{{ classified.title }} - Norda Biznes Partner{% endblock %}
{% block extra_css %}
<style>
.classified-container {
max-width: 800px;
margin: 0 auto;
}
.classified-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
box-shadow: var(--shadow);
margin-bottom: var(--spacing-xl);
}
.classified-card.szukam {
border-top: 4px solid var(--warning);
}
.classified-card.oferuje {
border-top: 4px solid var(--success);
}
.classified-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-lg);
}
.classified-type {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
}
.classified-type.szukam {
background: #fef3c7;
color: #92400e;
}
.classified-type.oferuje {
background: #dcfce7;
color: #166534;
}
.classified-category {
display: inline-block;
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
margin-left: var(--spacing-sm);
}
.category-uslugi {
background: #dbeafe;
color: #1e40af;
}
.category-produkty {
background: #fef3c7;
color: #92400e;
}
.category-wspolpraca {
background: #dcfce7;
color: #166534;
}
.category-praca {
background: #fce7f3;
color: #9d174d;
}
.category-inne {
background: #f3f4f6;
color: #374151;
}
.category-nieruchomosci {
background: #e0e7ff;
color: #3730a3;
}
.classified-title {
font-size: var(--font-size-2xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-lg);
}
.classified-description {
line-height: 1.8;
color: var(--text-primary);
margin-bottom: var(--spacing-xl);
white-space: pre-wrap;
}
.classified-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
margin-bottom: var(--spacing-xl);
}
.detail-item {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.detail-item svg {
width: 20px;
height: 20px;
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.detail-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.detail-value {
font-weight: 500;
color: var(--text-primary);
}
.author-card {
display: flex;
align-items: center;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
background: var(--background);
border-radius: var(--radius);
}
.author-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: var(--font-size-lg);
}
.author-info {
flex: 1;
}
.author-name {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.author-company {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.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);
}
.stats-bar {
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
color: var(--text-secondary);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
margin-top: var(--spacing-lg);
}
.seen-by-section {
margin-top: var(--spacing-lg);
padding-top: var(--spacing-lg);
border-top: 1px solid var(--border);
}
.seen-by-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-sm);
}
.seen-by-avatars {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reader-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
cursor: default;
}
.reader-avatar.more {
background: var(--text-secondary);
font-size: 10px;
}
.close-btn {
margin-left: auto;
}
/* Admin actions */
.admin-actions {
display: flex;
gap: var(--spacing-sm);
margin-left: auto;
}
.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-toggle {
background: #f5f5f5;
color: #525252;
border-color: #d4d4d4;
}
.admin-btn-toggle:hover {
background: #e5e5e5;
border-color: #a3a3a3;
}
.admin-btn-toggle.inactive {
background: #fef3c7;
color: #92400e;
border-color: #fcd34d;
}
.inactive-badge {
background: #fef2f2;
color: #dc2626;
padding: 4px 10px;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: 500;
margin-left: var(--spacing-sm);
}
</style>
{% endblock %}
{% block content %}
<div class="classified-container">
<a href="{{ url_for('classifieds.classifieds_index') }}" 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>
Powrot do tablicy
</a>
<div class="classified-card {{ classified.listing_type }}">
<div class="classified-header">
<div>
<span class="classified-type {{ classified.listing_type }}">{{ 'Szukam' if classified.listing_type == 'szukam' else 'Oferuje' }}</span>
<span class="classified-category category-{{ classified.category }}">{{ classified.category|replace('uslugi', 'Usługi')|replace('produkty', 'Produkty')|replace('wspolpraca', 'Współpraca')|replace('praca', 'Praca')|replace('inne', 'Inne')|replace('nieruchomosci', 'Nieruchomości') }}</span>
{% if not classified.is_active %}
<span class="inactive-badge">Nieaktywne</span>
{% endif %}
</div>
{% if classified.author_id == current_user.id %}
<button class="btn btn-secondary btn-sm close-btn" onclick="closeClassified()">Zamknij ogloszenie</button>
{% endif %}
{% if current_user.is_authenticated and current_user.is_admin %}
<div class="admin-actions">
<button type="button" class="admin-btn admin-btn-toggle {% if not classified.is_active %}inactive{% endif %}" onclick="toggleActive()" title="{% if classified.is_active %}Dezaktywuj{% else %}Aktywuj{% endif %}">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
{% if classified.is_active %}
<path d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
{% else %}
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
{% endif %}
</svg>
{% if classified.is_active %}Dezaktywuj{% else %}Aktywuj{% endif %}
</button>
<button type="button" class="admin-btn admin-btn-delete" onclick="deleteClassified()" title="Usuń ogłoszenie">
<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>
<h1 class="classified-title">{{ classified.title }}</h1>
<div class="classified-description">{{ classified.description }}</div>
{% if classified.budget_info or classified.location_info %}
<div class="classified-details">
{% if classified.budget_info %}
<div class="detail-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<div class="detail-label">Budzet / Cena</div>
<div class="detail-value">{{ classified.budget_info }}</div>
</div>
</div>
{% endif %}
{% if classified.location_info %}
<div class="detail-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<div>
<div class="detail-label">Lokalizacja</div>
<div class="detail-value">{{ classified.location_info }}</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="author-card">
<div class="author-avatar">
{{ (classified.author.name or classified.author.email)[0].upper() }}
</div>
<div class="author-info">
<div class="author-name">{{ classified.author.name or classified.author.email.split('@')[0] }}</div>
{% if classified.company %}
<div class="author-company">{{ classified.company.name }}</div>
{% endif %}
</div>
{% if classified.author_id != current_user.id %}
<a href="{{ url_for('messages_new', to=classified.author_id) }}" class="btn btn-primary">Skontaktuj sie</a>
{% endif %}
</div>
<div class="stats-bar">
<span>{{ classified.views_count }} wyswietlen</span>
<span>Dodano: {{ classified.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
{% if classified.expires_at %}
<span>Wygasa: {{ classified.expires_at.strftime('%d.%m.%Y') }}</span>
{% endif %}
</div>
{% if readers %}
<div class="seen-by-section">
<div class="seen-by-label">Widziane przez {{ readers_count }} {{ 'osobę' if readers_count == 1 else 'osoby' if readers_count < 5 else 'osób' }}:</div>
<div class="seen-by-avatars">
{% for read in readers[:20] %}
<div class="reader-avatar"
title="{{ read.user.name or read.user.email.split('@')[0] }}{% if current_user.is_authenticated and read.user.id == current_user.id %} (Ty){% endif %}"
style="background: hsl({{ (read.user.id * 137) % 360 }}, 65%, 50%);">
{{ (read.user.name or read.user.email)[0]|upper }}
</div>
{% endfor %}
{% if readers_count > 20 %}
<div class="reader-avatar more" title="i {{ readers_count - 20 }} innych">
+{{ readers_count - 20 }}
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Universal Confirm Modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal" style="max-width: 420px; background: var(--surface); border-radius: var(--radius-lg); padding: var(--spacing-xl);">
<div style="text-align: center; margin-bottom: var(--spacing-lg);">
<div class="modal-icon" id="confirmModalIcon" style="font-size: 3em; margin-bottom: var(--spacing-md);"></div>
<h3 id="confirmModalTitle" style="margin-bottom: var(--spacing-sm);">Potwierdzenie</h3>
<p class="modal-description" id="confirmModalMessage" style="color: var(--text-secondary);"></p>
</div>
<div class="modal-actions" style="display: flex; gap: var(--spacing-sm); justify-content: center;">
<button type="button" class="btn btn-secondary" id="confirmModalCancel">Anuluj</button>
<button type="button" class="btn btn-primary" id="confirmModalOk">OK</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>
.modal-overlay#confirmModal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1050; align-items: center; justify-content: center; }
.modal-overlay#confirmModal.active { display: flex; }
.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); }
@keyframes toastIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } }
.btn-danger { background: #dc2626; color: white; border: none; }
.btn-danger:hover { background: #b91c1c; }
.btn-warning { background: #f59e0b; color: white; border: none; }
.btn-warning:hover { background: #d97706; }
.btn-success { background: #10b981; color: white; border: none; }
.btn-success:hover { background: #059669; }
</style>
{% endblock %}
{% block extra_js %}
const csrfToken = '{{ csrf_token() }}';
let confirmResolve = null;
function showConfirm(message, options = {}) {
return new Promise(resolve => {
confirmResolve = resolve;
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
document.getElementById('confirmModalMessage').innerHTML = message;
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
document.getElementById('confirmModalOk').className = 'btn ' + (options.okClass || 'btn-primary');
document.getElementById('confirmModal').classList.add('active');
});
}
function closeConfirm(result) {
document.getElementById('confirmModal').classList.remove('active');
if (confirmResolve) { confirmResolve(result); confirmResolve = null; }
}
document.getElementById('confirmModalOk').addEventListener('click', () => closeConfirm(true));
document.getElementById('confirmModalCancel').addEventListener('click', () => closeConfirm(false));
document.getElementById('confirmModal').addEventListener('click', e => { if (e.target.id === 'confirmModal') closeConfirm(false); });
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);
}
async function closeClassified() {
const confirmed = await showConfirm('Czy na pewno chcesz zamknąć to ogłoszenie?', {
icon: '🔒',
title: 'Zamykanie ogłoszenia',
okText: 'Zamknij',
okClass: 'btn-warning'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_close", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('Ogłoszenie zostało zamknięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
// Admin functions
async function deleteClassified() {
const confirmed = await showConfirm('Czy na pewno chcesz usunąć to ogłoszenie?<br><br><strong>Ta operacja jest nieodwracalna.</strong>', {
icon: '🗑️',
title: 'Usuń ogłoszenie',
okText: 'Usuń',
okClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_delete", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast('Ogłoszenie usunięte', 'success');
setTimeout(() => window.location.href = '{{ url_for("classifieds.classifieds_index") }}', 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
async function toggleActive() {
const isActive = {{ 'true' if classified.is_active else 'false' }};
const action = isActive ? 'dezaktywować' : 'aktywować';
const confirmed = await showConfirm(`Czy na pewno chcesz ${action} to ogłoszenie?`, {
icon: isActive ? '🚫' : '✅',
title: isActive ? 'Dezaktywuj ogłoszenie' : 'Aktywuj ogłoszenie',
okText: isActive ? 'Dezaktywuj' : 'Aktywuj',
okClass: isActive ? 'btn-warning' : 'btn-success'
});
if (!confirmed) return;
try {
const response = await fetch('{{ url_for("classifieds.classifieds_toggle_active", classified_id=classified.id) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
showToast(data.message, 'success');
setTimeout(() => location.reload(), 1500);
} else {
showToast(data.error || 'Wystąpił błąd', 'error');
}
} catch (error) {
showToast('Błąd połączenia', 'error');
}
}
{% endblock %}