- Split admin.css (3228 lines) into 12 focused files: variables, base, forms, buttons, layout, components, tables, skeleton, datepicker, filemanager, pagination, responsive - Extracted shared styles from offers.css and dashboard.css into components.css and forms.css (offers-* → admin-* prefix) - Standardized naming: dash-kpi-* → admin-kpi-*, session-* → dash-session-*, rich-editor → admin-rich-editor - Deleted duplicate offers-tabs (using admin-tabs everywhere) - Deduplicated DatePicker and FileManager CSS (~360 lines removed) - Added 16 utility classes to base.css (font sizes, widths, gaps, margins) - Deleted empty admin.css Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { motion } from "framer-motion";
|
|
import { useAlert } from "../../context/AlertContext";
|
|
import ConfirmModal from "../ConfirmModal";
|
|
import useModalLock from "../../hooks/useModalLock";
|
|
import apiFetch from "../../utils/api";
|
|
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface DeviceInfo {
|
|
icon?: string;
|
|
browser?: string;
|
|
os?: string;
|
|
}
|
|
|
|
interface Session {
|
|
id: number | string;
|
|
is_current: boolean;
|
|
device_info?: DeviceInfo;
|
|
ip_address: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface DeleteModalState {
|
|
isOpen: boolean;
|
|
session: Session | null;
|
|
}
|
|
|
|
function getDeviceIcon(iconType?: string) {
|
|
switch (iconType) {
|
|
case "smartphone":
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
|
|
<line x1="12" y1="18" x2="12" y2="18" />
|
|
</svg>
|
|
);
|
|
case "tablet":
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
|
|
<line x1="12" y1="18" x2="12" y2="18" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
|
<line x1="8" y1="21" x2="16" y2="21" />
|
|
<line x1="12" y1="17" x2="12" y2="21" />
|
|
</svg>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default function DashSessions() {
|
|
const alert = useAlert();
|
|
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [sessionsLoading, setSessionsLoading] = useState(true);
|
|
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
|
|
isOpen: false,
|
|
session: null,
|
|
});
|
|
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
useModalLock(deleteAllModal);
|
|
|
|
const fetchSessions = useCallback(async () => {
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/sessions`);
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setSessions(
|
|
Array.isArray(data.data) ? data.data : data.data?.sessions || [],
|
|
);
|
|
}
|
|
} catch {
|
|
// session fetch failed silently
|
|
} finally {
|
|
setSessionsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
}, [fetchSessions]);
|
|
|
|
const handleDeleteSession = async () => {
|
|
if (!deleteModal.session) {
|
|
return;
|
|
}
|
|
const sessionId = deleteModal.session.id;
|
|
setDeleting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
|
|
method: "DELETE",
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setDeleteModal({ isOpen: false, session: null });
|
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId));
|
|
alert.success("Relace byla ukončena");
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se ukončit relaci");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAllSessions = async () => {
|
|
setDeleting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
|
|
method: "DELETE",
|
|
});
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
setDeleteAllModal(false);
|
|
setSessions((prev) => prev.filter((s) => s.is_current));
|
|
alert.success(data.message || "Ostatní relace byly ukončeny");
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se ukončit relace");
|
|
}
|
|
} catch {
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.15 }}
|
|
>
|
|
<div
|
|
className="admin-card-header"
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: "0.75rem",
|
|
}}
|
|
>
|
|
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
|
{sessions.filter((s) => !s.is_current).length > 0 && (
|
|
<button
|
|
onClick={() => setDeleteAllModal(true)}
|
|
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
>
|
|
Odhlásit ostatní
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
{sessionsLoading && (
|
|
<div
|
|
className="admin-skeleton"
|
|
style={{ padding: "1rem", gap: "1rem" }}
|
|
>
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line circle" />
|
|
<div className="flex-1">
|
|
<div
|
|
className="admin-skeleton-line w-1/2"
|
|
style={{ marginBottom: "0.5rem" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line w-1/3"
|
|
style={{ height: "10px" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!sessionsLoading && sessions.length === 0 && (
|
|
<div
|
|
className="text-secondary"
|
|
style={{
|
|
padding: "1.5rem",
|
|
textAlign: "center",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
Žádné aktivní relace
|
|
</div>
|
|
)}
|
|
{!sessionsLoading && sessions.length > 0 && (
|
|
<div className="dash-sessions-list">
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
|
>
|
|
<div className="dash-session-icon">
|
|
{getDeviceIcon(session.device_info?.icon)}
|
|
</div>
|
|
<div className="dash-session-info">
|
|
<div className="dash-session-device">
|
|
{session.device_info?.browser} na{" "}
|
|
{session.device_info?.os}
|
|
{session.is_current && (
|
|
<span
|
|
className="admin-badge admin-badge-success"
|
|
style={{ marginLeft: "0.5rem" }}
|
|
>
|
|
Aktuální
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="dash-session-meta">
|
|
<span>{session.ip_address}</span>
|
|
<span className="dash-session-meta-separator">|</span>
|
|
<span>{formatSessionDate(session.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="dash-session-actions">
|
|
{!session.is_current && (
|
|
<button
|
|
onClick={() =>
|
|
setDeleteModal({ isOpen: true, session })
|
|
}
|
|
className="admin-btn-icon danger"
|
|
title="Ukončit relaci"
|
|
aria-label="Ukončit relaci"
|
|
>
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
<polyline points="16 17 21 12 16 7" />
|
|
<line x1="21" y1="12" x2="9" y2="12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
<ConfirmModal
|
|
isOpen={deleteModal.isOpen}
|
|
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
|
onConfirm={handleDeleteSession}
|
|
title="Ukončit relaci"
|
|
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
|
confirmText="Ukončit"
|
|
cancelText="Zrušit"
|
|
type="danger"
|
|
loading={deleting}
|
|
/>
|
|
<ConfirmModal
|
|
isOpen={deleteAllModal}
|
|
onClose={() => setDeleteAllModal(false)}
|
|
onConfirm={handleDeleteAllSessions}
|
|
title="Odhlásit ostatní zařízení"
|
|
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
|
confirmText="Odhlásit vše"
|
|
cancelText="Zrušit"
|
|
type="warning"
|
|
loading={deleting}
|
|
/>
|
|
</>
|
|
);
|
|
}
|