poradniki

This commit is contained in:
2026-08-20 16:24:47 +02:00
parent f9f030ba14
commit 6c35589e70
17 changed files with 4570 additions and 89 deletions
+272
View File
@@ -0,0 +1,272 @@
/**
* Panel administratora: moderacja propozycji artykułów użytkowników ("Poradniki - prośby").
* Filtr po statusie, podgląd treści i załączników, akcje: akceptuj / odrzuć / usuń.
* Komponent `Icon` przekazywany jest propem, aby uniknąć cyklicznego importu z App.tsx.
*/
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
import { SafeHtml } from '../RichText';
import { GUIDE_TOPICS, articlePath } from '../routes';
import { Link } from 'react-router-dom';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type ArticleStatus = 'PENDING' | 'PUBLISHED' | 'REJECTED';
type AdminArticle = {
id: number;
topicSlug: string;
title: string;
excerpt: string;
content: string;
attachments: string[];
authorName: string;
authorNick: string | null;
authorEmail: string;
contactName: string | null;
contactEmail: string | null;
contactPhone: string | null;
readMinutes: number;
status: ArticleStatus;
rejectionReason: string | null;
reviewedByEmail: string | null;
reviewedAt: string | null;
createdAt: string;
};
const STATUS_FILTERS: { key: string; label: string }[] = [
{ key: 'PENDING', label: 'Oczekujące' },
{ key: 'PUBLISHED', label: 'Opublikowane' },
{ key: 'REJECTED', label: 'Odrzucone' },
{ key: 'ALL', label: 'Wszystkie' },
];
const STATUS_LABEL: Record<ArticleStatus, string> = {
PENDING: 'Oczekuje',
PUBLISHED: 'Opublikowany',
REJECTED: 'Odrzucony',
};
const STATUS_CLASS: Record<ArticleStatus, string> = {
PENDING: 'pending',
PUBLISHED: 'approved',
REJECTED: 'rejected',
};
function topicTitle(slug: string): string {
return GUIDE_TOPICS.find((topic) => topic.slug === slug)?.title ?? slug;
}
function formatDate(iso: string | null): string {
if (!iso) {
return '-';
}
try {
return new Intl.DateTimeFormat('pl-PL', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
} catch {
return '-';
}
}
type NotifyChannel = 'IN_APP' | 'EMAIL';
export function AdminArticleRequestsView({ Icon, onModerated }: { Icon: IconComponent; onModerated?: () => void }) {
const [statusFilter, setStatusFilter] = useState('PENDING');
const [items, setItems] = useState<AdminArticle[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
// Otwarty panel decyzji: dla którego artykułu i jaki typ (akceptacja / odrzucenie).
const [decision, setDecision] = useState<{ id: number; type: 'approve' | 'reject' } | null>(null);
const [channel, setChannel] = useState<NotifyChannel>('IN_APP');
const [rejectReason, setRejectReason] = useState('');
const load = useCallback(async () => {
setLoading(true);
try {
const data = await apiFetch<AdminArticle[]>(`/admin/articles?status=${statusFilter}`);
setItems(Array.isArray(data) ? data : []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
void load();
}, [load]);
const openDecision = (id: number, type: 'approve' | 'reject') => {
setDecision({ id, type });
setChannel('IN_APP');
setRejectReason('');
};
const runAction = async (id: number, action: () => Promise<unknown>) => {
setBusyId(id);
try {
await action();
await load();
onModerated?.();
} catch (err) {
window.alert(err instanceof Error ? err.message : 'Nie udało się wykonać akcji.');
} finally {
setBusyId(null);
}
};
const confirmDecision = (id: number, type: 'approve' | 'reject') =>
runAction(id, async () => {
const path = type === 'approve' ? 'approve' : 'reject';
const payload = type === 'approve' ? { channel } : { channel, reason: rejectReason.trim() };
await apiFetch(`/admin/articles/${id}/${path}`, { method: 'POST', body: JSON.stringify(payload) });
setDecision(null);
setRejectReason('');
});
const remove = (id: number) => {
if (!window.confirm('Usunąć tę propozycję na stałe? Tej operacji nie można cofnąć.')) {
return;
}
void runAction(id, () => apiFetch(`/admin/articles/${id}`, { method: 'DELETE' }));
};
const channelPicker = (
<div className="admin-article-channel">
<span>Powiadom autora przez:</span>
<label className={channel === 'IN_APP' ? 'active' : ''}>
<input type="radio" name="channel" checked={channel === 'IN_APP'} onChange={() => setChannel('IN_APP')} />
<Icon name="bell" /> Wiadomość w aplikacji
</label>
<label className={channel === 'EMAIL' ? 'active' : ''}>
<input type="radio" name="channel" checked={channel === 'EMAIL'} onChange={() => setChannel('EMAIL')} />
<Icon name="mail" /> E-mail
</label>
</div>
);
return (
<div className="admin-articles">
<div className="admin-articles-filters">
{STATUS_FILTERS.map((filter) => (
<button
key={filter.key}
type="button"
className={statusFilter === filter.key ? 'active' : ''}
onClick={() => setStatusFilter(filter.key)}
>
{filter.label}
</button>
))}
</div>
{loading ? (
<div className="admin-card"><p className="admin-empty">Wczytywanie propozycji...</p></div>
) : items.length === 0 ? (
<div className="admin-card"><p className="admin-empty">Brak propozycji w tym widoku.</p></div>
) : (
<div className="admin-articles-list">
{items.map((item) => {
const expanded = expandedId === item.id;
return (
<article className={`admin-article-card status-${STATUS_CLASS[item.status]}`} key={item.id}>
<div className="admin-article-head">
<div className="admin-article-title">
<span className="admin-article-topic"><Icon name="document" /> {topicTitle(item.topicSlug)}</span>
<h3>{item.title}</h3>
<p>{item.excerpt}</p>
</div>
<em className={`admin-status ${STATUS_CLASS[item.status]}`}>{STATUS_LABEL[item.status]}</em>
</div>
<div className="admin-article-meta">
<span><Icon name="user" /> {item.authorName}{item.authorNick ? ` (@${item.authorNick})` : ''}</span>
<span><Icon name="clock" /> {item.readMinutes} min • {formatDate(item.createdAt)}</span>
{item.attachments.length > 0 && <span><Icon name="camera" /> {item.attachments.length} zał.</span>}
</div>
<div className="admin-article-contact">
<span className="admin-article-contact-label"><Icon name="phone" /> Kontakt:</span>
<strong>{item.contactName || item.authorName}</strong>
<a href={`mailto:${item.contactEmail || item.authorEmail}`}>{item.contactEmail || item.authorEmail}</a>
{item.contactPhone && <a href={`tel:${item.contactPhone.replace(/\s+/g, '')}`}>{item.contactPhone}</a>}
</div>
{item.status === 'REJECTED' && item.rejectionReason && (
<p className="admin-article-reject-note"><Icon name="warning" /> Powód odrzucenia: {item.rejectionReason}</p>
)}
{item.reviewedByEmail && (
<p className="admin-article-reviewed">Sprawdzone przez {item.reviewedByEmail} • {formatDate(item.reviewedAt)}</p>
)}
<button type="button" className="admin-article-toggle" onClick={() => setExpandedId(expanded ? null : item.id)}>
<Icon name="eye" /> {expanded ? 'Ukryj podgląd' : 'Pokaż pełną treść'}
</button>
{expanded && (
<div className="admin-article-preview">
<SafeHtml className="guide-user-content" html={item.content} />
{item.attachments.length > 0 && (
<div className="admin-article-attachments">
{item.attachments.map((src, index) => (
<img key={index} src={src} alt={`Załącznik ${index + 1}`} />
))}
</div>
)}
</div>
)}
{decision?.id === item.id ? (
<div className="admin-article-decision">
{decision.type === 'reject' && (
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="Powód odrzucenia (opcjonalny) - trafi do autora w powiadomieniu."
rows={2}
/>
)}
{channelPicker}
<div className="admin-article-actions">
<button type="button" className="admin-btn" onClick={() => setDecision(null)}>Anuluj</button>
<button
type="button"
className={`admin-btn ${decision.type === 'approve' ? 'approve' : 'reject'}`}
disabled={busyId === item.id}
onClick={() => confirmDecision(item.id, decision.type)}
>
{decision.type === 'approve' ? 'Potwierdź publikację i powiadom' : 'Potwierdź odrzucenie i powiadom'}
</button>
</div>
</div>
) : (
<div className="admin-article-actions">
{item.status === 'PUBLISHED' && (
<Link className="admin-btn view" to={articlePath(item.id)} target="_blank" rel="noopener">
Zobacz na stronie
</Link>
)}
{item.status !== 'PUBLISHED' && (
<button type="button" className="admin-btn approve" disabled={busyId === item.id} onClick={() => openDecision(item.id, 'approve')}>
<Icon name="check" /> Akceptuj i publikuj
</button>
)}
{item.status !== 'REJECTED' && (
<button type="button" className="admin-btn reject" disabled={busyId === item.id} onClick={() => openDecision(item.id, 'reject')}>
Odrzuć
</button>
)}
<button type="button" className="admin-btn danger" disabled={busyId === item.id} onClick={() => remove(item.id)}>
<Icon name="trash" /> Usuń
</button>
</div>
)}
</article>
);
})}
</div>
)}
</div>
);
}