feat(announcements): System ogłoszeń i aktualności dla członków
- Model Announcement z kategoriami, statusami, slugami URL - Panel admina /admin/announcements (CRUD, filtry, AJAX) - Strona /ogloszenia tylko dla zalogowanych członków - Szczegóły ogłoszenia /ogloszenia/<slug> - Migracja SQL rozszerzająca istniejącą tabelę - Testowe ogłoszenia: ARP baza noclegowa, Tytani Przedsiębiorczości - Pliki PDF regulaminu i harmonogramu konkursu Tytani Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
587d000b9b
commit
e14d95394d
430
app.py
430
app.py
@ -13340,6 +13340,436 @@ def api_geoip_stats():
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ANNOUNCEMENTS (Ogłoszenia dla członków)
|
||||
# ============================================================
|
||||
|
||||
def generate_slug(title):
|
||||
"""
|
||||
Generate URL-friendly slug from title.
|
||||
Uses unidecode for proper Polish character handling.
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
from unidecode import unidecode
|
||||
text = unidecode(title.lower())
|
||||
except ImportError:
|
||||
# Fallback without unidecode
|
||||
text = title.lower()
|
||||
replacements = {
|
||||
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n',
|
||||
'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z'
|
||||
}
|
||||
for pl, en in replacements.items():
|
||||
text = text.replace(pl, en)
|
||||
|
||||
# Remove special characters, replace spaces with hyphens
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[-\s]+', '-', text).strip('-')
|
||||
return text[:200] # Limit slug length
|
||||
|
||||
|
||||
@app.route('/admin/announcements')
|
||||
@login_required
|
||||
def admin_announcements():
|
||||
"""Admin panel - lista ogłoszeń"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
from database import Announcement
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Filters
|
||||
status_filter = request.args.get('status', 'all')
|
||||
category_filter = request.args.get('category', 'all')
|
||||
|
||||
query = db.query(Announcement)
|
||||
|
||||
if status_filter != 'all':
|
||||
query = query.filter(Announcement.status == status_filter)
|
||||
if category_filter != 'all':
|
||||
query = query.filter(Announcement.category == category_filter)
|
||||
|
||||
# Sort: pinned first, then by created_at desc
|
||||
query = query.order_by(
|
||||
Announcement.is_pinned.desc(),
|
||||
Announcement.created_at.desc()
|
||||
)
|
||||
|
||||
announcements = query.all()
|
||||
|
||||
return render_template('admin/announcements.html',
|
||||
announcements=announcements,
|
||||
now=datetime.now(),
|
||||
status_filter=status_filter,
|
||||
category_filter=category_filter,
|
||||
categories=Announcement.CATEGORIES,
|
||||
category_labels=Announcement.CATEGORY_LABELS,
|
||||
statuses=Announcement.STATUSES,
|
||||
status_labels=Announcement.STATUS_LABELS)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/admin/announcements/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_announcements_new():
|
||||
"""Admin panel - nowe ogłoszenie"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
from database import Announcement
|
||||
|
||||
if request.method == 'POST':
|
||||
db = SessionLocal()
|
||||
try:
|
||||
title = request.form.get('title', '').strip()
|
||||
excerpt = request.form.get('excerpt', '').strip()
|
||||
content = request.form.get('content', '').strip()
|
||||
category = request.form.get('category', 'general')
|
||||
image_url = request.form.get('image_url', '').strip() or None
|
||||
external_link = request.form.get('external_link', '').strip() or None
|
||||
is_featured = 'is_featured' in request.form
|
||||
is_pinned = 'is_pinned' in request.form
|
||||
|
||||
# Handle expires_at
|
||||
expires_at_str = request.form.get('expires_at', '').strip()
|
||||
expires_at = None
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Generate unique slug
|
||||
base_slug = generate_slug(title)
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while db.query(Announcement).filter(Announcement.slug == slug).first():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Determine status based on button clicked
|
||||
action = request.form.get('action', 'draft')
|
||||
status = 'published' if action == 'publish' else 'draft'
|
||||
published_at = datetime.now() if status == 'published' else None
|
||||
|
||||
announcement = Announcement(
|
||||
title=title,
|
||||
slug=slug,
|
||||
excerpt=excerpt or None,
|
||||
content=content,
|
||||
category=category,
|
||||
image_url=image_url,
|
||||
external_link=external_link,
|
||||
status=status,
|
||||
published_at=published_at,
|
||||
expires_at=expires_at,
|
||||
is_featured=is_featured,
|
||||
is_pinned=is_pinned,
|
||||
created_by=current_user.id
|
||||
)
|
||||
|
||||
db.add(announcement)
|
||||
db.commit()
|
||||
|
||||
flash(f'Ogłoszenie zostało {"opublikowane" if status == "published" else "zapisane jako szkic"}.', 'success')
|
||||
return redirect(url_for('admin_announcements'))
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating announcement: {e}")
|
||||
flash(f'Błąd podczas tworzenia ogłoszenia: {e}', 'error')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# GET request - show form
|
||||
from database import Announcement
|
||||
return render_template('admin/announcements_form.html',
|
||||
announcement=None,
|
||||
categories=Announcement.CATEGORIES,
|
||||
category_labels=Announcement.CATEGORY_LABELS)
|
||||
|
||||
|
||||
@app.route('/admin/announcements/<int:id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def admin_announcements_edit(id):
|
||||
"""Admin panel - edycja ogłoszenia"""
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
from database import Announcement
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
||||
if not announcement:
|
||||
flash('Nie znaleziono ogłoszenia.', 'error')
|
||||
return redirect(url_for('admin_announcements'))
|
||||
|
||||
if request.method == 'POST':
|
||||
announcement.title = request.form.get('title', '').strip()
|
||||
announcement.excerpt = request.form.get('excerpt', '').strip() or None
|
||||
announcement.content = request.form.get('content', '').strip()
|
||||
announcement.category = request.form.get('category', 'general')
|
||||
announcement.image_url = request.form.get('image_url', '').strip() or None
|
||||
announcement.external_link = request.form.get('external_link', '').strip() or None
|
||||
announcement.is_featured = 'is_featured' in request.form
|
||||
announcement.is_pinned = 'is_pinned' in request.form
|
||||
|
||||
# Handle expires_at
|
||||
expires_at_str = request.form.get('expires_at', '').strip()
|
||||
if expires_at_str:
|
||||
try:
|
||||
announcement.expires_at = datetime.strptime(expires_at_str, '%Y-%m-%dT%H:%M')
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
announcement.expires_at = None
|
||||
|
||||
# Regenerate slug if title changed significantly
|
||||
new_slug = generate_slug(announcement.title)
|
||||
if new_slug != announcement.slug.split('-')[0]: # Check if base changed
|
||||
base_slug = new_slug
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while db.query(Announcement).filter(
|
||||
Announcement.slug == slug,
|
||||
Announcement.id != id
|
||||
).first():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
announcement.slug = slug
|
||||
|
||||
# Handle status change
|
||||
action = request.form.get('action', 'save')
|
||||
if action == 'publish' and announcement.status != 'published':
|
||||
announcement.status = 'published'
|
||||
announcement.published_at = datetime.now()
|
||||
elif action == 'archive':
|
||||
announcement.status = 'archived'
|
||||
elif action == 'draft':
|
||||
announcement.status = 'draft'
|
||||
|
||||
announcement.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
flash('Zmiany zostały zapisane.', 'success')
|
||||
return redirect(url_for('admin_announcements'))
|
||||
|
||||
# GET request - show form
|
||||
return render_template('admin/announcements_form.html',
|
||||
announcement=announcement,
|
||||
categories=Announcement.CATEGORIES,
|
||||
category_labels=Announcement.CATEGORY_LABELS)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error editing announcement {id}: {e}")
|
||||
flash(f'Błąd: {e}', 'error')
|
||||
return redirect(url_for('admin_announcements'))
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/admin/announcements/<int:id>/publish', methods=['POST'])
|
||||
@login_required
|
||||
def admin_announcements_publish(id):
|
||||
"""Publikacja ogłoszenia"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from database import Announcement
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
||||
if not announcement:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404
|
||||
|
||||
announcement.status = 'published'
|
||||
if not announcement.published_at:
|
||||
announcement.published_at = datetime.now()
|
||||
announcement.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Ogłoszenie zostało opublikowane'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error publishing announcement {id}: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/admin/announcements/<int:id>/archive', methods=['POST'])
|
||||
@login_required
|
||||
def admin_announcements_archive(id):
|
||||
"""Archiwizacja ogłoszenia"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from database import Announcement
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
||||
if not announcement:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404
|
||||
|
||||
announcement.status = 'archived'
|
||||
announcement.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Ogłoszenie zostało zarchiwizowane'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error archiving announcement {id}: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/admin/announcements/<int:id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def admin_announcements_delete(id):
|
||||
"""Usunięcie ogłoszenia"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
|
||||
|
||||
from database import Announcement
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
announcement = db.query(Announcement).filter(Announcement.id == id).first()
|
||||
if not announcement:
|
||||
return jsonify({'success': False, 'error': 'Nie znaleziono ogłoszenia'}), 404
|
||||
|
||||
db.delete(announcement)
|
||||
db.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': 'Ogłoszenie zostało usunięte'})
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error deleting announcement {id}: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC ANNOUNCEMENTS PAGE
|
||||
# ============================================================
|
||||
|
||||
@app.route('/ogloszenia')
|
||||
@login_required
|
||||
@limiter.limit("60 per minute")
|
||||
def announcements_list():
|
||||
"""Strona z listą ogłoszeń dla zalogowanych członków"""
|
||||
from database import Announcement
|
||||
from sqlalchemy import or_, desc
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
category = request.args.get('category', '')
|
||||
per_page = 12
|
||||
|
||||
# Base query: published and not expired
|
||||
query = db.query(Announcement).filter(
|
||||
Announcement.status == 'published',
|
||||
or_(
|
||||
Announcement.expires_at.is_(None),
|
||||
Announcement.expires_at > datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by category
|
||||
if category and category in Announcement.CATEGORIES:
|
||||
query = query.filter(Announcement.category == category)
|
||||
|
||||
# Sort: pinned first, then by published_at desc
|
||||
query = query.order_by(
|
||||
desc(Announcement.is_pinned),
|
||||
desc(Announcement.published_at)
|
||||
)
|
||||
|
||||
# Pagination
|
||||
total = query.count()
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
announcements = query.offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return render_template('announcements/list.html',
|
||||
announcements=announcements,
|
||||
current_category=category,
|
||||
categories=Announcement.CATEGORIES,
|
||||
category_labels=Announcement.CATEGORY_LABELS,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total=total)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.route('/ogloszenia/<slug>')
|
||||
@login_required
|
||||
@limiter.limit("60 per minute")
|
||||
def announcement_detail(slug):
|
||||
"""Szczegóły ogłoszenia dla zalogowanych członków"""
|
||||
from database import Announcement
|
||||
from sqlalchemy import or_, desc
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
announcement = db.query(Announcement).filter(
|
||||
Announcement.slug == slug,
|
||||
Announcement.status == 'published',
|
||||
or_(
|
||||
Announcement.expires_at.is_(None),
|
||||
Announcement.expires_at > datetime.now()
|
||||
)
|
||||
).first()
|
||||
|
||||
if not announcement:
|
||||
flash('Nie znaleziono ogłoszenia lub zostało usunięte.', 'error')
|
||||
return redirect(url_for('announcements_list'))
|
||||
|
||||
# Increment views counter
|
||||
announcement.views_count = (announcement.views_count or 0) + 1
|
||||
db.commit()
|
||||
|
||||
# Get other recent announcements for sidebar
|
||||
other_announcements = db.query(Announcement).filter(
|
||||
Announcement.status == 'published',
|
||||
Announcement.id != announcement.id,
|
||||
or_(
|
||||
Announcement.expires_at.is_(None),
|
||||
Announcement.expires_at > datetime.now()
|
||||
)
|
||||
).order_by(desc(Announcement.published_at)).limit(5).all()
|
||||
|
||||
return render_template('announcements/detail.html',
|
||||
announcement=announcement,
|
||||
other_announcements=other_announcements,
|
||||
category_labels=Announcement.CATEGORY_LABELS)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# HONEYPOT ENDPOINTS (trap for malicious bots)
|
||||
# ============================================================
|
||||
|
||||
86
database.py
86
database.py
@ -17,6 +17,7 @@ Models:
|
||||
- ITAudit: IT infrastructure audit results
|
||||
- ITCollaborationMatch: IT collaboration matches between companies
|
||||
- AIUsageLog, AIUsageDaily, AIRateLimit: AI API usage monitoring
|
||||
- Announcement: Ogłoszenia i aktualności dla członków
|
||||
|
||||
Author: Norda Biznes Development Team
|
||||
Created: 2025-11-23
|
||||
@ -3000,6 +3001,91 @@ class SecurityAlert(Base):
|
||||
return f"<SecurityAlert {self.id} {self.alert_type} ({self.severity}) from {self.ip_address}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ANNOUNCEMENTS (Ogłoszenia dla członków)
|
||||
# ============================================================
|
||||
|
||||
class Announcement(Base):
|
||||
"""
|
||||
Ogłoszenia i aktualności dla członków Norda Biznes.
|
||||
Obsługuje różne kategorie: ogólne, wydarzenia, okazje biznesowe, od członków.
|
||||
"""
|
||||
__tablename__ = 'announcements'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String(300), nullable=False)
|
||||
slug = Column(String(300), unique=True, index=True)
|
||||
excerpt = Column(String(500)) # Krótki opis do listy
|
||||
content = Column(Text, nullable=False) # Pełna treść (HTML)
|
||||
|
||||
# Kategoryzacja
|
||||
category = Column(String(50), default='general')
|
||||
# Wartości: general, event, opportunity, member_news, partnership
|
||||
|
||||
# Media
|
||||
image_url = Column(String(1000))
|
||||
external_link = Column(String(1000)) # Link do zewnętrznego źródła
|
||||
|
||||
# Publikacja
|
||||
status = Column(String(20), default='draft', index=True)
|
||||
# Wartości: draft, published, archived
|
||||
published_at = Column(DateTime)
|
||||
expires_at = Column(DateTime) # Opcjonalne wygaśnięcie
|
||||
|
||||
# Wyróżnienie
|
||||
is_featured = Column(Boolean, default=False)
|
||||
is_pinned = Column(Boolean, default=False)
|
||||
|
||||
# Statystyki
|
||||
views_count = Column(Integer, default=0)
|
||||
|
||||
# Audyt
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
author = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
# Constants
|
||||
CATEGORIES = ['general', 'event', 'opportunity', 'member_news', 'partnership']
|
||||
CATEGORY_LABELS = {
|
||||
'general': 'Ogólne',
|
||||
'event': 'Wydarzenie',
|
||||
'opportunity': 'Okazja biznesowa',
|
||||
'member_news': 'Od członka',
|
||||
'partnership': 'Partnerstwo'
|
||||
}
|
||||
STATUSES = ['draft', 'published', 'archived']
|
||||
STATUS_LABELS = {
|
||||
'draft': 'Szkic',
|
||||
'published': 'Opublikowane',
|
||||
'archived': 'Zarchiwizowane'
|
||||
}
|
||||
|
||||
@property
|
||||
def category_label(self):
|
||||
"""Zwraca polską etykietę kategorii"""
|
||||
return self.CATEGORY_LABELS.get(self.category, self.category)
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
"""Zwraca polską etykietę statusu"""
|
||||
return self.STATUS_LABELS.get(self.status, self.status)
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Sprawdza czy ogłoszenie jest aktywne (opublikowane i nie wygasło)"""
|
||||
if self.status != 'published':
|
||||
return False
|
||||
if self.expires_at and self.expires_at < datetime.now():
|
||||
return False
|
||||
return True
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Announcement {self.id} '{self.title[:50]}' ({self.status})>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ZOPK MILESTONES (Timeline)
|
||||
# ============================================================
|
||||
|
||||
177
database/migrations/018_announcements.sql
Normal file
177
database/migrations/018_announcements.sql
Normal file
@ -0,0 +1,177 @@
|
||||
-- ============================================================
|
||||
-- Migration: 018_announcements.sql
|
||||
-- Description: Rozszerzenie tabeli ogłoszeń o nowe pola
|
||||
-- Author: Claude
|
||||
-- Date: 2026-01-26
|
||||
-- Note: Tabela announcements już istnieje - rozszerzamy ją
|
||||
-- ============================================================
|
||||
|
||||
-- Dodanie nowych kolumn (jeśli nie istnieją)
|
||||
-- slug: URL-friendly identyfikator
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'slug') THEN
|
||||
ALTER TABLE announcements ADD COLUMN slug VARCHAR(300);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- excerpt: Krótki opis do listy
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'excerpt') THEN
|
||||
ALTER TABLE announcements ADD COLUMN excerpt VARCHAR(500);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- category: Nowa kategoryzacja (general, event, opportunity, member_news, partnership)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'category') THEN
|
||||
ALTER TABLE announcements ADD COLUMN category VARCHAR(50) DEFAULT 'general';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- image_url: Obrazek ogłoszenia
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'image_url') THEN
|
||||
ALTER TABLE announcements ADD COLUMN image_url VARCHAR(1000);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- external_link: Link do zewnętrznego źródła
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'external_link') THEN
|
||||
ALTER TABLE announcements ADD COLUMN external_link VARCHAR(1000);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- status: draft, published, archived (zastępuje is_published)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'status') THEN
|
||||
ALTER TABLE announcements ADD COLUMN status VARCHAR(20) DEFAULT 'draft';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- published_at: Data publikacji
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'published_at') THEN
|
||||
ALTER TABLE announcements ADD COLUMN published_at TIMESTAMP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- expires_at: Data wygaśnięcia (zastępuje expire_date)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'expires_at') THEN
|
||||
ALTER TABLE announcements ADD COLUMN expires_at TIMESTAMP;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- is_featured: Wyróżnienie
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'is_featured') THEN
|
||||
ALTER TABLE announcements ADD COLUMN is_featured BOOLEAN DEFAULT FALSE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- views_count: Licznik wyświetleń
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'views_count') THEN
|
||||
ALTER TABLE announcements ADD COLUMN views_count INTEGER DEFAULT 0;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- created_by: ID użytkownika (mapowanie z author_id)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'announcements' AND column_name = 'created_by') THEN
|
||||
ALTER TABLE announcements ADD COLUMN created_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migracja danych ze starych kolumn do nowych
|
||||
-- Mapowanie announcement_type -> category
|
||||
UPDATE announcements
|
||||
SET category = CASE
|
||||
WHEN announcement_type = 'fees' THEN 'general'
|
||||
WHEN announcement_type = 'important' THEN 'general'
|
||||
WHEN announcement_type = 'urgent' THEN 'general'
|
||||
WHEN announcement_type = 'event' THEN 'event'
|
||||
ELSE COALESCE(announcement_type, 'general')
|
||||
END
|
||||
WHERE category IS NULL OR category = '';
|
||||
|
||||
-- Mapowanie is_published -> status
|
||||
UPDATE announcements
|
||||
SET status = CASE
|
||||
WHEN is_published = true THEN 'published'
|
||||
ELSE 'draft'
|
||||
END
|
||||
WHERE status IS NULL OR status = '';
|
||||
|
||||
-- Mapowanie expire_date -> expires_at
|
||||
UPDATE announcements
|
||||
SET expires_at = expire_date
|
||||
WHERE expires_at IS NULL AND expire_date IS NOT NULL;
|
||||
|
||||
-- Mapowanie publish_date -> published_at
|
||||
UPDATE announcements
|
||||
SET published_at = COALESCE(publish_date, created_at)
|
||||
WHERE published_at IS NULL AND is_published = true;
|
||||
|
||||
-- Mapowanie author_id -> created_by
|
||||
UPDATE announcements
|
||||
SET created_by = author_id
|
||||
WHERE created_by IS NULL AND author_id IS NOT NULL;
|
||||
|
||||
-- Generowanie slugów dla istniejących ogłoszeń
|
||||
UPDATE announcements
|
||||
SET slug = CONCAT(
|
||||
LOWER(REGEXP_REPLACE(
|
||||
REGEXP_REPLACE(title, '[^a-zA-Z0-9\s]', '', 'g'),
|
||||
'\s+', '-', 'g'
|
||||
)),
|
||||
'-', id
|
||||
)
|
||||
WHERE slug IS NULL OR slug = '';
|
||||
|
||||
-- Tworzenie indeksów (jeśli nie istnieją)
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_status ON announcements(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_slug ON announcements(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_published_at ON announcements(published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_announcements_category ON announcements(category);
|
||||
|
||||
-- Tworzenie unikalności na slug
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_announcements_slug_unique') THEN
|
||||
CREATE UNIQUE INDEX idx_announcements_slug_unique ON announcements(slug) WHERE slug IS NOT NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Uprawnienia
|
||||
GRANT ALL ON TABLE announcements TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE announcements_id_seq TO nordabiz_app;
|
||||
|
||||
-- Komentarze
|
||||
COMMENT ON COLUMN announcements.slug IS 'URL-friendly slug (np. baza-noclegowa-arp-choczewo)';
|
||||
COMMENT ON COLUMN announcements.excerpt IS 'Krótki opis do listy (max 500 znaków)';
|
||||
COMMENT ON COLUMN announcements.category IS 'Kategoria: general, event, opportunity, member_news, partnership';
|
||||
COMMENT ON COLUMN announcements.status IS 'Status: draft, published, archived';
|
||||
253
docs/ROADMAP.md
253
docs/ROADMAP.md
@ -114,6 +114,255 @@ Główne instrukcje znajdują się w [CLAUDE.md](../CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Priorytet 6: System powiadomień
|
||||
|
||||
**Status:** Planowane
|
||||
**Cel:** Powiadamianie użytkowników o istotnych zdarzeniach bez konieczności logowania
|
||||
**Źródło:** Feedback od członków Norda Biznes (Janusz Masiak, Angelika Piechocka) - 2026-01-22
|
||||
|
||||
### Kanały powiadomień
|
||||
|
||||
| Kanał | Zastosowanie | Technologia | Koszt |
|
||||
|-------|--------------|-------------|-------|
|
||||
| **Email** | Standardowe powiadomienia | SendGrid / Mailgun / SMTP | ~$15/mies (10k maili) |
|
||||
| **SMS** | Pilne/ważne powiadomienia | SMSAPI.pl | ~0.07 zł/SMS |
|
||||
| **Push** | Natychmiastowe (PWA) | Web Push API | Darmowe |
|
||||
|
||||
### Typy powiadomień
|
||||
|
||||
| Typ | Email | SMS | Push | Domyślnie |
|
||||
|-----|:-----:|:---:|:----:|:---------:|
|
||||
| Nowe wydarzenia Norda Biznes | ✅ | ⚪ | ✅ | Email+Push |
|
||||
| Nowe aktualności i newsy | ✅ | ❌ | ✅ | Email |
|
||||
| Wiadomości od innych firm | ✅ | ⚪ | ✅ | Email+Push |
|
||||
| Przypomnienia o spotkaniach | ✅ | ✅ | ✅ | Wszystkie |
|
||||
| Newsletter tygodniowy | ✅ | ❌ | ❌ | Wyłączone |
|
||||
|
||||
**Legenda:** ✅ dostępne domyślnie, ⚪ dostępne opcjonalnie, ❌ niedostępne
|
||||
|
||||
### Panel preferencji powiadomień
|
||||
|
||||
Użytkownik w profilu będzie mógł:
|
||||
- Włączyć/wyłączyć poszczególne typy powiadomień
|
||||
- Wybrać preferowane kanały (email, SMS, push)
|
||||
- Ustawić "ciche godziny" (np. brak SMS po 21:00)
|
||||
- Zapisać numer telefonu do SMS
|
||||
|
||||
### Wymagania techniczne
|
||||
|
||||
```python
|
||||
# Nowa tabela: notification_preferences
|
||||
class NotificationPreference(Base):
|
||||
__tablename__ = 'notification_preferences'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), unique=True)
|
||||
|
||||
# Kanały
|
||||
email_enabled = Column(Boolean, default=True)
|
||||
sms_enabled = Column(Boolean, default=False)
|
||||
push_enabled = Column(Boolean, default=True)
|
||||
phone_number = Column(String(15)) # Format: +48XXXXXXXXX
|
||||
|
||||
# Typy powiadomień
|
||||
events_email = Column(Boolean, default=True)
|
||||
events_sms = Column(Boolean, default=False)
|
||||
news_email = Column(Boolean, default=True)
|
||||
messages_email = Column(Boolean, default=True)
|
||||
messages_push = Column(Boolean, default=True)
|
||||
reminders_sms = Column(Boolean, default=True)
|
||||
newsletter = Column(Boolean, default=False)
|
||||
|
||||
# Ciche godziny
|
||||
quiet_hours_start = Column(Time) # np. 21:00
|
||||
quiet_hours_end = Column(Time) # np. 08:00
|
||||
|
||||
# Nowa tabela: notification_log (audit)
|
||||
class NotificationLog(Base):
|
||||
__tablename__ = 'notification_log'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
channel = Column(String(10)) # email, sms, push
|
||||
type = Column(String(30)) # event, news, message, reminder
|
||||
subject = Column(String(200))
|
||||
sent_at = Column(DateTime, default=datetime.utcnow)
|
||||
status = Column(String(20)) # sent, failed, bounced
|
||||
```
|
||||
|
||||
### Integracje
|
||||
|
||||
- **Email:** Flask-Mail + SendGrid API (fallback: SMTP)
|
||||
- **SMS:** SMSAPI.pl (polska firma, dobra dokumentacja)
|
||||
- **Push:** Web Push API + pywebpush (wymaga PWA - Priorytet 7)
|
||||
|
||||
---
|
||||
|
||||
## Priorytet 7: PWA (Progressive Web App)
|
||||
|
||||
**Status:** Planowane
|
||||
**Cel:** Umożliwienie "instalacji" NordaBiznes na telefonie jako aplikacji
|
||||
**Źródło:** Feedback od Angeliki Piechockiej - 2026-01-22
|
||||
|
||||
### Korzyści PWA vs natywna aplikacja
|
||||
|
||||
| Aspekt | PWA | Natywna aplikacja |
|
||||
|--------|-----|-------------------|
|
||||
| Koszt rozwoju | Niski | Wysoki (iOS + Android) |
|
||||
| Czas wdrożenia | 2-4 tygodnie | 3-6 miesięcy |
|
||||
| Aktualizacje | Natychmiastowe | Wymaga publikacji w Store |
|
||||
| Instalacja | "Dodaj do ekranu" | App Store / Google Play |
|
||||
| Push notifications | ✅ (Android, iOS 16.4+) | ✅ |
|
||||
| Dostęp offline | Ograniczony | Pełny |
|
||||
| Kamera/GPS | ✅ | ✅ |
|
||||
|
||||
### Wymagania techniczne
|
||||
|
||||
1. **manifest.json** - metadane aplikacji (nazwa, ikony, kolory)
|
||||
2. **Service Worker** - cache, offline, push notifications
|
||||
3. **HTTPS** - już mamy (Let's Encrypt)
|
||||
4. **Ikony** - różne rozmiary (192x192, 512x512)
|
||||
|
||||
### Przykład manifest.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "NordaBiznes - Katalog Firm",
|
||||
"short_name": "NordaBiznes",
|
||||
"description": "Platforma networkingowa członków Norda Biznes",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a2e",
|
||||
"theme_color": "#6c5ce7",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Fazy wdrożenia
|
||||
|
||||
1. **Faza 1:** Podstawowy PWA (manifest + service worker cache)
|
||||
2. **Faza 2:** Push notifications (integracja z Priorytetem 6)
|
||||
3. **Faza 3:** Offline mode (cache kluczowych stron)
|
||||
|
||||
### Zależności
|
||||
|
||||
- Push notifications w PWA wymagają Service Worker
|
||||
- Priorytet 6 (System powiadomień) i Priorytet 7 (PWA) są powiązane
|
||||
- Rekomendacja: wdrażać równolegle
|
||||
|
||||
---
|
||||
|
||||
## Priorytet 8: System ogłoszeń i aktualności
|
||||
|
||||
**Status:** Planowane
|
||||
**Cel:** Umożliwienie komunikacji z członkami Norda Biznes - ogłoszenia, aktualności, ważne informacje
|
||||
**Źródło:** Potrzeba rozesłania informacji o bazie noclegowej ARP dla elektrowni jądrowej - 2026-01-26
|
||||
|
||||
### Problem
|
||||
|
||||
Obecnie brak możliwości:
|
||||
- Publikowania ogłoszeń dla członków na stronie www
|
||||
- Wysyłania masowych komunikatów do wszystkich członków
|
||||
- Targetowania komunikatów (np. tylko branża noclegowa)
|
||||
|
||||
### Funkcjonalności
|
||||
|
||||
#### Panel admina (`/admin/announcements`)
|
||||
- Tworzenie nowych ogłoszeń (tytuł, treść, obrazek, link)
|
||||
- Ustawianie daty publikacji i wygaśnięcia
|
||||
- Wybór grupy docelowej (wszyscy / wybrane kategorie firm)
|
||||
- Podgląd przed publikacją
|
||||
- Historia wysłanych ogłoszeń
|
||||
|
||||
#### Strona publiczna (`/aktualnosci`)
|
||||
- Lista aktualnych ogłoszeń
|
||||
- Archiwum starszych ogłoszeń
|
||||
- Filtrowanie po kategorii
|
||||
- RSS feed (opcjonalnie)
|
||||
|
||||
#### Integracja z powiadomieniami (Priorytet 6)
|
||||
- Automatyczne wysyłanie email do członków przy nowym ogłoszeniu
|
||||
- Push notification (PWA)
|
||||
- Opcja "wyślij teraz" vs "tylko opublikuj na stronie"
|
||||
|
||||
### Wymagania techniczne
|
||||
|
||||
```python
|
||||
# Nowa tabela: announcements
|
||||
class Announcement(Base):
|
||||
__tablename__ = 'announcements'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
slug = Column(String(200), unique=True)
|
||||
content = Column(Text, nullable=False) # Markdown lub HTML
|
||||
excerpt = Column(String(500)) # Krótki opis do listy
|
||||
image_url = Column(String(500))
|
||||
external_link = Column(String(500)) # Link do zewnętrznego źródła
|
||||
|
||||
# Publikacja
|
||||
status = Column(String(20), default='draft') # draft, published, archived
|
||||
published_at = Column(DateTime)
|
||||
expires_at = Column(DateTime) # Opcjonalna data wygaśnięcia
|
||||
|
||||
# Targetowanie
|
||||
target_audience = Column(String(50), default='all') # all, category:IT, category:Services
|
||||
|
||||
# Powiadomienia
|
||||
send_email = Column(Boolean, default=True)
|
||||
send_push = Column(Boolean, default=True)
|
||||
notification_sent_at = Column(DateTime)
|
||||
|
||||
# Meta
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, onupdate=datetime.utcnow)
|
||||
view_count = Column(Integer, default=0)
|
||||
|
||||
# Tabela śledzenia kto widział ogłoszenie
|
||||
class AnnouncementView(Base):
|
||||
__tablename__ = 'announcement_views'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
announcement_id = Column(Integer, ForeignKey('announcements.id'))
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
viewed_at = Column(DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
### Przykłady użycia
|
||||
|
||||
| Scenariusz | Target | Email | Push |
|
||||
|------------|--------|:-----:|:----:|
|
||||
| Baza noclegowa ARP (elektrownia) | Wszystkie firmy | ✅ | ✅ |
|
||||
| Szkolenie IT | category:IT | ✅ | ❌ |
|
||||
| Spotkanie networkingowe | Wszystkie firmy | ✅ | ✅ |
|
||||
| Aktualizacja regulaminu | Wszystkie firmy | ✅ | ❌ |
|
||||
|
||||
### Zależności
|
||||
|
||||
- **Priorytet 6 (Powiadomienia)** - do wysyłania email/push
|
||||
- **Priorytet 7 (PWA)** - do push notifications
|
||||
- Może działać samodzielnie (tylko strona www) bez powiadomień
|
||||
|
||||
### Fazy wdrożenia
|
||||
|
||||
1. **Faza 1:** Strona `/aktualnosci` + panel admina (bez powiadomień)
|
||||
2. **Faza 2:** Integracja z email (gdy Priorytet 6 gotowy)
|
||||
3. **Faza 3:** Push notifications (gdy Priorytet 7 gotowy)
|
||||
|
||||
---
|
||||
|
||||
## Notatki implementacyjne
|
||||
|
||||
- Scraper powinien deduplikować wydarzenia (hash tytułu + daty)
|
||||
@ -170,6 +419,10 @@ Najwyższy poziom służy jako **kotwica** - sprawia że środkowy wydaje się a
|
||||
| Forum | ❌ | ✅ | ✅ |
|
||||
| Kalendarz wydarzeń | ❌ | ✅ | ✅ |
|
||||
| Chat AI (NordaGPT) | ❌ | ✅ | ✅ |
|
||||
| **Powiadomienia email** | ✅ | ✅ | ✅ |
|
||||
| **Powiadomienia SMS** | ❌ | ✅ | ✅ |
|
||||
| **Powiadomienia push (PWA)** | ❌ | ✅ | ✅ |
|
||||
| **Aktualności i ogłoszenia** | ✅ | ✅ | ✅ |
|
||||
| **Raporty podstawowe** | ❌ | ✅ | ✅ |
|
||||
| **Raporty zaawansowane** | ❌ | ❌ | ✅ |
|
||||
| Eksport danych (CSV/PDF) | ❌ | ❌ | ✅ |
|
||||
|
||||
BIN
static/announcements/tytani-harmonogram.pdf
Normal file
BIN
static/announcements/tytani-harmonogram.pdf
Normal file
Binary file not shown.
BIN
static/announcements/tytani-regulamin.pdf
Normal file
BIN
static/announcements/tytani-regulamin.pdf
Normal file
Binary file not shown.
@ -9,6 +9,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
@ -16,6 +18,34 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
@ -57,9 +87,10 @@
|
||||
|
||||
.status-published { background: var(--success-bg); color: var(--success); }
|
||||
.status-draft { background: var(--warning-bg); color: var(--warning); }
|
||||
.status-expired { background: var(--surface-secondary); color: var(--text-secondary); }
|
||||
.status-archived { background: var(--surface-secondary); color: var(--text-secondary); }
|
||||
.status-expired { background: var(--error-bg); color: var(--error); }
|
||||
|
||||
.type-badge {
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
@ -69,15 +100,21 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.type-fees { background: var(--warning-bg); color: var(--warning); }
|
||||
.type-important { background: var(--error-bg); color: var(--error); }
|
||||
.type-urgent { background: var(--error); color: white; }
|
||||
.category-event { background: #e0f2fe; color: #0369a1; }
|
||||
.category-opportunity { background: #dcfce7; color: #15803d; }
|
||||
.category-member_news { background: #fef3c7; color: #b45309; }
|
||||
.category-partnership { background: #f3e8ff; color: #7c3aed; }
|
||||
|
||||
.pinned-icon {
|
||||
color: var(--warning);
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.featured-icon {
|
||||
color: var(--primary);
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
@ -85,6 +122,8 @@
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@ -92,6 +131,64 @@
|
||||
padding: var(--spacing-2xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.title-cell a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-cell a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.views-count {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.announcements-table {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
.announcements-table th:nth-child(4),
|
||||
.announcements-table td:nth-child(4),
|
||||
.announcements-table th:nth-child(5),
|
||||
.announcements-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -104,41 +201,67 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label for="status-filter">Status:</label>
|
||||
<select id="status-filter" onchange="applyFilters()">
|
||||
<option value="all" {% if status_filter == 'all' %}selected{% endif %}>Wszystkie</option>
|
||||
{% for status in statuses %}
|
||||
<option value="{{ status }}" {% if status_filter == status %}selected{% endif %}>
|
||||
{{ status_labels.get(status, status) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="category-filter">Kategoria:</label>
|
||||
<select id="category-filter" onchange="applyFilters()">
|
||||
<option value="all" {% if category_filter == 'all' %}selected{% endif %}>Wszystkie</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if category_filter == cat %}selected{% endif %}>
|
||||
{{ category_labels.get(cat, cat) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
{% if announcements %}
|
||||
<table class="announcements-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tytul</th>
|
||||
<th>Typ</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Status</th>
|
||||
<th>Autor</th>
|
||||
<th>Utworzono</th>
|
||||
<th>Wyswietlenia</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ann in announcements %}
|
||||
<tr>
|
||||
<td>
|
||||
<td class="title-cell">
|
||||
<a href="{{ url_for('admin_announcements_edit', id=ann.id) }}">
|
||||
{{ ann.title }}
|
||||
{% if ann.is_pinned %}<span class="pinned-icon" title="Przypiety">📌</span>{% endif %}
|
||||
</a>
|
||||
{% if ann.is_pinned %}<span class="pinned-icon" title="Przypiete">📌</span>{% endif %}
|
||||
{% if ann.is_featured %}<span class="featured-icon" title="Wyroznienie">⭐</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="type-badge type-{{ ann.announcement_type }}">
|
||||
{% if ann.announcement_type == 'general' %}Ogolne
|
||||
{% elif ann.announcement_type == 'fees' %}Skladki
|
||||
{% elif ann.announcement_type == 'event' %}Wydarzenie
|
||||
{% elif ann.announcement_type == 'important' %}Wazne
|
||||
{% elif ann.announcement_type == 'urgent' %}Pilne
|
||||
{% else %}{{ ann.announcement_type }}
|
||||
{% endif %}
|
||||
<span class="category-badge category-{{ ann.category }}">
|
||||
{{ category_labels.get(ann.category, ann.category) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if not ann.is_published %}
|
||||
<span class="status-badge status-draft">Wersja robocza</span>
|
||||
{% elif ann.expire_date and ann.expire_date < now %}
|
||||
{% if ann.status == 'draft' %}
|
||||
<span class="status-badge status-draft">Szkic</span>
|
||||
{% elif ann.status == 'archived' %}
|
||||
<span class="status-badge status-archived">Zarchiwizowane</span>
|
||||
{% elif ann.expires_at and ann.expires_at < now %}
|
||||
<span class="status-badge status-expired">Wygaslo</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-published">Opublikowane</span>
|
||||
@ -146,10 +269,20 @@
|
||||
</td>
|
||||
<td>{{ ann.author.name if ann.author else '-' }}</td>
|
||||
<td>{{ ann.created_at.strftime('%Y-%m-%d %H:%M') if ann.created_at else '-' }}</td>
|
||||
<td class="views-count">{{ ann.views_count or 0 }}</td>
|
||||
<td class="actions-cell">
|
||||
<a href="{{ url_for('admin_announcements_edit', id=ann.id) }}" class="btn btn-secondary btn-small">
|
||||
Edytuj
|
||||
</a>
|
||||
{% if ann.status == 'draft' %}
|
||||
<button class="btn btn-success btn-small" onclick="publishAnnouncement({{ ann.id }})">
|
||||
Publikuj
|
||||
</button>
|
||||
{% elif ann.status == 'published' %}
|
||||
<button class="btn btn-warning btn-small" onclick="archiveAnnouncement({{ ann.id }})">
|
||||
Archiwizuj
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-error btn-small" onclick="deleteAnnouncement({{ ann.id }})">
|
||||
Usun
|
||||
</button>
|
||||
@ -160,7 +293,7 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak ogloszen. Utworz pierwsze ogloszenie klikajac przycisk powyzej.</p>
|
||||
<p>Brak ogloszen{% if status_filter != 'all' or category_filter != 'all' %} pasujacych do filtrow{% endif %}. Utworz pierwsze ogloszenie klikajac przycisk powyzej.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@ -170,7 +303,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@ -198,10 +331,19 @@
|
||||
const now = new Date();
|
||||
let confirmResolve = null;
|
||||
|
||||
function applyFilters() {
|
||||
const status = document.getElementById('status-filter').value;
|
||||
const category = document.getElementById('category-filter').value;
|
||||
let url = '{{ url_for("admin_announcements") }}?';
|
||||
if (status !== 'all') url += 'status=' + status + '&';
|
||||
if (category !== 'all') url += 'category=' + category + '&';
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function showConfirm(message, options = {}) {
|
||||
return new Promise(resolve => {
|
||||
confirmResolve = resolve;
|
||||
document.getElementById('confirmModalIcon').textContent = options.icon || '❓';
|
||||
document.getElementById('confirmModalIcon').innerHTML = options.icon || '❓';
|
||||
document.getElementById('confirmModalTitle').textContent = options.title || 'Potwierdzenie';
|
||||
document.getElementById('confirmModalMessage').innerHTML = message;
|
||||
document.getElementById('confirmModalOk').textContent = options.okText || 'OK';
|
||||
@ -221,19 +363,75 @@
|
||||
|
||||
function showToast(message, type = 'info', duration = 4000) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
|
||||
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>`;
|
||||
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 publishAnnouncement(id) {
|
||||
const confirmed = await showConfirm('Czy na pewno chcesz opublikowac to ogloszenie?', {
|
||||
icon: '📣',
|
||||
title: 'Publikacja ogloszenia',
|
||||
okText: 'Publikuj',
|
||||
okClass: 'btn-success'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/announcements/' + id + '/publish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Ogloszenie zostalo opublikowane', 'success');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast('Blad: ' + data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Blad: ' + err, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveAnnouncement(id) {
|
||||
const confirmed = await showConfirm('Czy na pewno chcesz zarchiwizowac to ogloszenie?', {
|
||||
icon: '📦',
|
||||
title: 'Archiwizacja ogloszenia',
|
||||
okText: 'Archiwizuj',
|
||||
okClass: 'btn-warning'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/announcements/' + id + '/archive', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Ogloszenie zostalo zarchiwizowane', 'success');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast('Blad: ' + data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Blad: ' + err, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAnnouncement(id) {
|
||||
const confirmed = await showConfirm('Czy na pewno chcesz usunąć to ogłoszenie?', {
|
||||
icon: '🗑️',
|
||||
title: 'Usuwanie ogłoszenia',
|
||||
okText: 'Usuń',
|
||||
const confirmed = await showConfirm('Czy na pewno chcesz usunac to ogloszenie? Ta operacja jest nieodwracalna.', {
|
||||
icon: '🗑',
|
||||
title: 'Usuwanie ogloszenia',
|
||||
okText: 'Usun',
|
||||
okClass: 'btn-error'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
@ -247,13 +445,13 @@
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast('Ogłoszenie zostało usunięte', 'success');
|
||||
showToast('Ogloszenie zostalo usuniete', 'success');
|
||||
setTimeout(() => location.reload(), 1500);
|
||||
} else {
|
||||
showToast('Błąd: ' + data.error, 'error');
|
||||
showToast('Blad: ' + data.error, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Błąd: ' + err, 'error');
|
||||
showToast('Blad: ' + err, 'error');
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
<style>
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
@ -18,7 +21,7 @@
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
max-width: 800px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@ -32,7 +35,12 @@
|
||||
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 {
|
||||
@ -41,13 +49,20 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
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));
|
||||
@ -81,6 +96,51 @@
|
||||
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 %}
|
||||
@ -89,78 +149,190 @@
|
||||
<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.strftime('%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">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="form-group">
|
||||
<label for="title">Tytul *</label>
|
||||
<input type="text" id="title" name="title" required
|
||||
<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. Informacja o skladkach za styczen 2026">
|
||||
placeholder="np. Baza noclegowa dla pracownikow budowy elektrowni jadrowej">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">Tresc *</label>
|
||||
<textarea id="content" name="content" required
|
||||
placeholder="Tresc ogloszenia...">{{ announcement.content if announcement else '' }}</textarea>
|
||||
<p class="form-hint">Mozesz uzyc podstawowego formatowania HTML.</p>
|
||||
<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-row">
|
||||
<div class="form-group">
|
||||
<label for="type">Typ ogloszenia</label>
|
||||
<select id="type" name="type">
|
||||
<option value="general" {% if announcement and announcement.announcement_type == 'general' %}selected{% endif %}>Ogolne</option>
|
||||
<option value="fees" {% if announcement and announcement.announcement_type == 'fees' %}selected{% endif %}>Skladki</option>
|
||||
<option value="event" {% if announcement and announcement.announcement_type == 'event' %}selected{% endif %}>Wydarzenie</option>
|
||||
<option value="important" {% if announcement and announcement.announcement_type == 'important' %}selected{% endif %}>Wazne</option>
|
||||
<option value="urgent" {% if announcement and announcement.announcement_type == 'urgent' %}selected{% endif %}>Pilne</option>
|
||||
<label for="category">Kategoria</label>
|
||||
<select id="category" name="category">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if announcement and announcement.category == cat %}selected{% endif %}>
|
||||
{{ category_labels.get(cat, cat) }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<h3 class="section-title">Media i linki</h3>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="publish_date">Data publikacji</label>
|
||||
<input type="datetime-local" id="publish_date" name="publish_date"
|
||||
value="{{ announcement.publish_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.publish_date else '' }}">
|
||||
<p class="form-hint">Pozostaw puste aby opublikowac natychmiast.</p>
|
||||
<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="expire_date">Data wygasniecia</label>
|
||||
<input type="datetime-local" id="expire_date" name="expire_date"
|
||||
value="{{ announcement.expire_date.strftime('%Y-%m-%dT%H:%M') if announcement and announcement.expire_date else '' }}">
|
||||
<p class="form-hint">Pozostaw puste aby nie wygasalo.</p>
|
||||
<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.strftime('%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</label>
|
||||
<label>Opcje wyswietlania</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" name="is_published"
|
||||
{% if announcement and announcement.is_published %}checked{% endif %}>
|
||||
<span>Opublikowane</span>
|
||||
</label>
|
||||
<label class="checkbox-item">
|
||||
<input type="checkbox" name="is_pinned"
|
||||
{% if announcement and announcement.is_pinned %}checked{% endif %}>
|
||||
<span>Przypiete (na gorze)</span>
|
||||
<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_announcements') }}" class="btn btn-secondary">Anuluj</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if announcement %}Zapisz zmiany{% else %}Utworz ogloszenie{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
// 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 %}
|
||||
|
||||
375
templates/announcements/detail.html
Normal file
375
templates/announcements/detail.html
Normal file
@ -0,0 +1,375 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ announcement.title }} - Aktualnosci - Norda Biznes Hub{% endblock %}
|
||||
|
||||
{% block meta_description %}{{ announcement.excerpt or announcement.content|striptags|truncate(160) }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.announcement-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.announcement-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-main {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.announcement-header {
|
||||
padding: var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.announcement-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.category-event { background: #e0f2fe; color: #0369a1; }
|
||||
.category-opportunity { background: #dcfce7; color: #15803d; }
|
||||
.category-member_news { background: #fef3c7; color: #b45309; }
|
||||
.category-partnership { background: #f3e8ff; color: #7c3aed; }
|
||||
|
||||
.meta-date {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.meta-views {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.announcement-title {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.announcement-excerpt {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.announcement-image {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.announcement-content p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.announcement-content h3 {
|
||||
margin-top: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.announcement-content ul, .announcement-content ol {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-left: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.announcement-content li {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.announcement-content a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.announcement-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.external-link-box {
|
||||
background: var(--primary-bg);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin: var(--spacing-xl) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.external-link-box .link-text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.external-link-box .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.other-announcement {
|
||||
padding: var(--spacing-md) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.other-announcement:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.other-announcement:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.other-announcement a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.other-announcement a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.other-announcement .date {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.share-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
font-size: 1.2em;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.share-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.share-btn.facebook { background: #1877f2; color: white; }
|
||||
.share-btn.linkedin { background: #0a66c2; color: white; }
|
||||
.share-btn.twitter { background: #1da1f2; color: white; }
|
||||
.share-btn.copy { background: var(--surface-secondary); color: var(--text-primary); border: 1px solid var(--border); }
|
||||
|
||||
.pinned-notice {
|
||||
background: linear-gradient(135deg, var(--primary-bg) 0%, var(--surface) 100%);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<a href="{{ url_for('announcements_list') }}" class="back-link">
|
||||
← Powrot do listy ogloszen
|
||||
</a>
|
||||
|
||||
{% if announcement.is_pinned %}
|
||||
<div class="pinned-notice">
|
||||
📌 To ogloszenie jest przypiete i wyswietla sie na gorze listy
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="announcement-layout">
|
||||
<!-- Main content -->
|
||||
<article class="announcement-main">
|
||||
<div class="announcement-header">
|
||||
<div class="announcement-meta">
|
||||
<span class="category-badge category-{{ announcement.category }}">
|
||||
{{ category_labels.get(announcement.category, announcement.category) }}
|
||||
</span>
|
||||
<span class="meta-date">
|
||||
{{ announcement.published_at.strftime('%d %B %Y') if announcement.published_at else '' }}
|
||||
</span>
|
||||
<span class="meta-views">
|
||||
👁 {{ announcement.views_count or 0 }} wyswietlen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="announcement-title">{{ announcement.title }}</h1>
|
||||
|
||||
{% if announcement.excerpt %}
|
||||
<p class="announcement-excerpt">{{ announcement.excerpt }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if announcement.image_url %}
|
||||
<img src="{{ announcement.image_url }}" alt="{{ announcement.title }}" class="announcement-image"
|
||||
onerror="this.style.display='none'">
|
||||
{% endif %}
|
||||
|
||||
<div class="announcement-content">
|
||||
{{ announcement.content|safe }}
|
||||
|
||||
{% if announcement.external_link %}
|
||||
<div class="external-link-box">
|
||||
<div class="link-text">
|
||||
🌐 Wiecej informacji znajdziesz na zewnetrznej stronie
|
||||
</div>
|
||||
<a href="{{ announcement.external_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
|
||||
Przejdz do strony →
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<!-- Share -->
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">Udostepnij</h3>
|
||||
<div class="share-buttons">
|
||||
<a href="https://www.facebook.com/sharer/sharer.php?u={{ request.url|urlencode }}"
|
||||
target="_blank" rel="noopener" class="share-btn facebook" title="Udostepnij na Facebooku">
|
||||
f
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.url|urlencode }}"
|
||||
target="_blank" rel="noopener" class="share-btn linkedin" title="Udostepnij na LinkedIn">
|
||||
in
|
||||
</a>
|
||||
<a href="https://twitter.com/intent/tweet?url={{ request.url|urlencode }}&text={{ announcement.title|urlencode }}"
|
||||
target="_blank" rel="noopener" class="share-btn twitter" title="Udostepnij na Twitterze">
|
||||
X
|
||||
</a>
|
||||
<button class="share-btn copy" title="Kopiuj link" onclick="copyLink()">
|
||||
🔗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Other announcements -->
|
||||
{% if other_announcements %}
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-title">Inne ogloszenia</h3>
|
||||
{% for other in other_announcements %}
|
||||
<div class="other-announcement">
|
||||
<a href="{{ url_for('announcement_detail', slug=other.slug) }}">
|
||||
{{ other.title }}
|
||||
</a>
|
||||
<div class="date">
|
||||
{{ other.published_at.strftime('%d.%m.%Y') if other.published_at else '' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Back to list -->
|
||||
<div class="sidebar-section" style="text-align: center;">
|
||||
<a href="{{ url_for('announcements_list') }}" class="btn btn-secondary" style="width: 100%;">
|
||||
← Wszystkie ogloszenia
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(window.location.href).then(function() {
|
||||
const btn = document.querySelector('.share-btn.copy');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✓';
|
||||
btn.style.background = 'var(--success-bg)';
|
||||
btn.style.color = 'var(--success)';
|
||||
setTimeout(function() {
|
||||
btn.innerHTML = originalText;
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
{% endblock %}
|
||||
@ -1,106 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ogloszenia - Norda Biznes Hub{% endblock %}
|
||||
{% block title %}Aktualnosci - Norda Biznes Hub{% endblock %}
|
||||
|
||||
{% block meta_description %}Aktualnosci i ogloszenia dla czlonkow Norda Biznes. Wydarzenia, okazje biznesowe, informacje od czlonkow.{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl) 0;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-4xl);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--spacing-sm);
|
||||
font-size: var(--font-size-lg);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.announcements-list {
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.announcements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.announcement-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
border-left: 4px solid var(--primary);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.announcement-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.announcement-card.pinned {
|
||||
border-left-color: var(--warning);
|
||||
background: linear-gradient(to right, var(--warning-bg), var(--surface));
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.announcement-card.type-fees {
|
||||
border-left-color: var(--warning);
|
||||
.announcement-card.featured {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.announcement-card.type-important {
|
||||
border-left-color: var(--error);
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.announcement-card.type-urgent {
|
||||
border-left-color: var(--error);
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.announcement-header {
|
||||
.card-image-placeholder {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, var(--primary-bg) 0%, var(--background) 100%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-md);
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3em;
|
||||
color: var(--primary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.announcement-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
.card-body {
|
||||
padding: var(--spacing-lg);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.announcement-meta {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.announcement-content p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.type-badge.fees { background: var(--warning-bg); color: var(--warning); }
|
||||
.type-badge.important { background: var(--error-bg); color: var(--error); }
|
||||
.type-badge.urgent { background: var(--error); color: white; }
|
||||
.type-badge.event { background: var(--info-bg); color: var(--info); }
|
||||
.type-badge.general { background: var(--primary-bg); color: var(--primary); }
|
||||
.category-event { background: #e0f2fe; color: #0369a1; }
|
||||
.category-opportunity { background: #dcfce7; color: #15803d; }
|
||||
.category-member_news { background: #fef3c7; color: #b45309; }
|
||||
.category-partnership { background: #f3e8ff; color: #7c3aed; }
|
||||
|
||||
.pinned-badge {
|
||||
background: var(--warning);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
@ -108,58 +143,240 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card-title a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.card-excerpt {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
flex: 1;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-link {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.card-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-3xl);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pagination .current {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination .disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.announcements-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.filters-bar {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div class="container">
|
||||
<h1>Aktualnosci Norda Biznes</h1>
|
||||
<p>Informacje, wydarzenia i okazje dla czlonkow</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1>Ogloszenia</h1>
|
||||
<p>Aktualnosci i komunikaty od zarzadu Norda Biznes</p>
|
||||
<!-- Category filters -->
|
||||
<div class="filters-bar">
|
||||
<a href="{{ url_for('announcements_list') }}"
|
||||
class="filter-btn {% if not current_category %}active{% endif %}">
|
||||
Wszystkie
|
||||
</a>
|
||||
{% for cat in categories %}
|
||||
<a href="{{ url_for('announcements_list', category=cat) }}"
|
||||
class="filter-btn {% if current_category == cat %}active{% endif %}">
|
||||
{{ category_labels.get(cat, cat) }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if announcements %}
|
||||
<div class="announcements-list">
|
||||
<div class="announcements-grid">
|
||||
{% for ann in announcements %}
|
||||
<article class="announcement-card {% if ann.is_pinned %}pinned{% endif %} type-{{ ann.announcement_type }}">
|
||||
<div class="announcement-header">
|
||||
<h2 class="announcement-title">
|
||||
{% if ann.is_pinned %}<span class="pinned-badge">Przypiete</span>{% endif %}
|
||||
<span class="type-badge {{ ann.announcement_type }}">
|
||||
{% if ann.announcement_type == 'fees' %}Skladki
|
||||
{% elif ann.announcement_type == 'important' %}Wazne
|
||||
{% elif ann.announcement_type == 'urgent' %}Pilne
|
||||
{% elif ann.announcement_type == 'event' %}Wydarzenie
|
||||
{% else %}Ogolne
|
||||
<article class="announcement-card {% if ann.is_pinned %}pinned{% endif %} {% if ann.is_featured %}featured{% endif %}">
|
||||
{% if ann.image_url %}
|
||||
<img src="{{ ann.image_url }}" alt="{{ ann.title }}" class="card-image"
|
||||
onerror="this.outerHTML='<div class=\'card-image-placeholder\'>📰</div>'">
|
||||
{% else %}
|
||||
<div class="card-image-placeholder">📰</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
<span class="category-badge category-{{ ann.category }}">
|
||||
{{ category_labels.get(ann.category, ann.category) }}
|
||||
</span>
|
||||
{% if ann.is_pinned %}
|
||||
<span class="pinned-badge">📌 Przypiete</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="card-title">
|
||||
<a href="{{ url_for('announcement_detail', slug=ann.slug) }}">
|
||||
{{ ann.title }}
|
||||
</a>
|
||||
</h2>
|
||||
<div class="announcement-meta">
|
||||
<span>{{ ann.created_at.strftime('%d.%m.%Y') if ann.created_at else '' }}</span>
|
||||
<span>{{ ann.author.name if ann.author else 'Zarzad' }}</span>
|
||||
|
||||
<p class="card-excerpt">
|
||||
{% if ann.excerpt %}
|
||||
{{ ann.excerpt }}
|
||||
{% else %}
|
||||
{{ ann.content|striptags|truncate(150) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="card-date">
|
||||
{{ ann.published_at.strftime('%d.%m.%Y') if ann.published_at else '' }}
|
||||
</span>
|
||||
<a href="{{ url_for('announcement_detail', slug=ann.slug) }}" class="card-link">
|
||||
Czytaj wiecej →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="announcement-content">
|
||||
{{ ann.content|safe }}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('announcements_list', page=page-1, category=current_category) }}">« Poprzednia</a>
|
||||
{% else %}
|
||||
<span class="disabled">« Poprzednia</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
|
||||
<a href="{{ url_for('announcements_list', page=p, category=current_category) }}">{{ p }}</a>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<span>...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('announcements_list', page=page+1, category=current_category) }}">Nastepna »</a>
|
||||
{% else %}
|
||||
<span class="disabled">Nastepna »</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak aktualnych ogloszen.</p>
|
||||
<div class="empty-state-icon">📰</div>
|
||||
<h3>Brak ogloszen</h3>
|
||||
<p>
|
||||
{% if current_category %}
|
||||
Nie ma jeszcze ogloszen w kategorii "{{ category_labels.get(current_category, current_category) }}".
|
||||
<br><a href="{{ url_for('announcements_list') }}">Zobacz wszystkie ogloszenia</a>
|
||||
{% else %}
|
||||
Nie ma jeszcze zadnych ogloszen. Wkrotce pojawia sie nowe informacje.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user