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:
Maciej Pienczyn 2026-01-26 22:10:54 +01:00
parent 587d000b9b
commit e14d95394d
10 changed files with 2056 additions and 148 deletions

430
app.py
View File

@ -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)
# ============================================================

View File

@ -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)
# ============================================================

View 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';

View File

@ -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) | ❌ | ❌ | ✅ |

Binary file not shown.

Binary file not shown.

View File

@ -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">&#128204;</span>{% endif %}
</a>
{% if ann.is_pinned %}<span class="pinned-icon" title="Przypiete">&#128204;</span>{% endif %}
{% if ann.is_featured %}<span class="featured-icon" title="Wyroznienie">&#11088;</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);">&#10067;</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 || '&#10067;';
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: '&#10003;', error: '&#10007;', warning: '&#9888;', info: '&#8505;' };
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]||'&#8505;'}</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: '&#128227;',
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: '&#128230;',
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: '&#128465;',
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 %}

View File

@ -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 &#8599;
</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: &lt;p&gt;, &lt;h3&gt;, &lt;ul&gt;, &lt;li&gt;, &lt;a href=""&gt;, &lt;strong&gt;</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>&#128204; 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>&#11088; 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 %}

View 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">
&larr; Powrot do listy ogloszen
</a>
{% if announcement.is_pinned %}
<div class="pinned-notice">
&#128204; 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">
&#128065; {{ 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">
&#127760; Wiecej informacji znajdziesz na zewnetrznej stronie
</div>
<a href="{{ announcement.external_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
Przejdz do strony &rarr;
</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()">
&#128279;
</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%;">
&larr; 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 = '&#10003;';
btn.style.background = 'var(--success-bg)';
btn.style.color = 'var(--success)';
setTimeout(function() {
btn.innerHTML = originalText;
btn.style.background = '';
btn.style.color = '';
}, 2000);
});
}
{% endblock %}

View File

@ -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\'>&#128240;</div>'">
{% else %}
<div class="card-image-placeholder">&#128240;</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">&#128204; 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 &rarr;
</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) }}">&laquo; Poprzednia</a>
{% else %}
<span class="disabled">&laquo; 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 &raquo;</a>
{% else %}
<span class="disabled">Nastepna &raquo;</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">
<p>Brak aktualnych ogloszen.</p>
<div class="empty-state-icon">&#128240;</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>