Fix fakalyzer UI and realtime updates

This commit is contained in:
Your Name 2025-12-26 21:11:22 -05:00
parent b8ac7d2010
commit 55ecfbd34e
5 changed files with 398 additions and 22 deletions

View File

@ -246,6 +246,40 @@ export async function fetchPostById(postId) {
} }
} }
export async function fetchPostTruth(postId) {
if (!postId) return null;
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
try {
const res = await fetch(`${API_BASE}/post/truth?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.truth || null;
} catch {
return null;
}
}
export async function votePostTruth(postId, vote) {
if (!postId) throw new Error("post_id required");
const body = JSON.stringify({ post_id: postId, vote });
const res = await fetch(`${API_BASE}/post/truth-vote`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "truth vote failed");
}
const data = await res.json().catch(() => ({}));
return data?.truth || null;
}
export async function updatePostVisibility(postId, payload = {}, token) { export async function updatePostVisibility(postId, payload = {}, token) {
if (!postId) throw new Error("post_id required"); if (!postId) throw new Error("post_id required");
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };

View File

@ -71,6 +71,8 @@ export default function MapView({
const overlayTimerRef = useRef(null); const overlayTimerRef = useRef(null);
const pendingOpenPostIdRef = useRef(null); const pendingOpenPostIdRef = useRef(null);
const pendingOpenFullRef = useRef(false); const pendingOpenFullRef = useRef(false);
const searchFitQueryRef = useRef("");
const searchUserMovedRef = useRef(false);
const openOverlayWhenReady = useCallback( const openOverlayWhenReady = useCallback(
(post, opts = {}) => { (post, opts = {}) => {
@ -95,7 +97,7 @@ export default function MapView({
[mapRef, theme] [mapRef, theme]
); );
const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost } = const { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate } =
usePostsEngine({ usePostsEngine({
theme, theme,
mapRef, mapRef,
@ -312,12 +314,18 @@ export default function MapView({
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
try { try {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === "new_post" && msg.post) handleIncomingPost(msg.post); if (msg.type === "new_post" && msg.post) {
handleIncomingPost(msg.post);
return;
}
if (msg.type === "post_update") {
handlePostUpdate(msg);
}
} catch {} } catch {}
}; };
return () => ws && ws.close(); return () => ws && ws.close();
}, [handleIncomingPost]); }, [handleIncomingPost, handlePostUpdate]);
const handleFlyToMe = () => { const handleFlyToMe = () => {
const map = mapRef.current; const map = mapRef.current;
@ -422,13 +430,15 @@ export default function MapView({
} }
}; };
// Zoom intelligent: quand on cherche, zoom pour voir tous les résultats // Zoom intelligent: quand on cherche, zoom pour voir tous les résultats (une seule fois)
useEffect(() => { useEffect(() => {
if (!searchQuery || !visiblePosts.length) return; if (!searchQuery || !visiblePosts.length) return;
if (skipAutoZoomRef.current) { if (skipAutoZoomRef.current) {
skipAutoZoomRef.current = false; skipAutoZoomRef.current = false;
return; return;
} }
if (searchUserMovedRef.current) return;
if (searchFitQueryRef.current === searchQuery) return;
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
@ -472,12 +482,29 @@ export default function MapView({
duration: 1000 duration: 1000
} }
); );
searchFitQueryRef.current = searchQuery;
} }
}, 300); // Délai pour laisser les markers se créer }, 300); // Délai pour laisser les markers se créer
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchQuery, visiblePosts, mapRef]); }, [searchQuery, visiblePosts, mapRef]);
useEffect(() => {
searchFitQueryRef.current = "";
searchUserMovedRef.current = false;
}, [searchQuery]);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!searchQuery) return;
const handleMove = () => {
searchUserMovedRef.current = true;
};
map.on("movestart", handleMove);
return () => map.off("movestart", handleMove);
}, [mapRef, searchQuery]);
const handleProfileVisitIsland = (island) => { const handleProfileVisitIsland = (island) => {
console.log('[MapView] Visiting island from profile:', island.username); console.log('[MapView] Visiting island from profile:', island.username);
setSelectedProfile(null); // Close profile modal setSelectedProfile(null); // Close profile modal

View File

@ -18,6 +18,8 @@ import {
createPostComment, createPostComment,
fetchPostLikeState, fetchPostLikeState,
fetchProfile, fetchProfile,
fetchPostTruth,
votePostTruth,
} from "../../api/client"; } from "../../api/client";
import { trackEvent } from "../../utils/analytics"; import { trackEvent } from "../../utils/analytics";
@ -126,6 +128,38 @@ function updateTruth(modalWrap, post) {
if (label) label.textContent = `${score}`; if (label) label.textContent = `${score}`;
} }
function updateTruthVoteUI(modalWrap, stats) {
if (!modalWrap || !stats) return;
const aiEl = modalWrap.querySelector("[data-truth-ai]");
const userEl = modalWrap.querySelector("[data-truth-user]");
const countEl = modalWrap.querySelector("[data-truth-count]");
const rail = modalWrap.querySelector(".sw-truth-rail");
const marker = rail ? rail.querySelector(".sw-truth-rail-marker") : null;
const label = rail ? rail.querySelector(".sw-truth-rail-score") : null;
const aiScore = Number.isFinite(stats.ai_score) ? Math.round(stats.ai_score) : stats.AIScore;
const userScore = Number.isFinite(stats.user_score) ? Math.round(stats.user_score) : stats.UserScore;
const truthScoreValue = Number.isFinite(stats.truth_score) ? Math.round(stats.truth_score) : stats.TruthScore;
const totalVotes = Number.isFinite(stats.total_votes) ? stats.total_votes : stats.TotalVotes;
if (aiEl) aiEl.textContent = Number.isFinite(aiScore) ? String(aiScore) : "—";
if (userEl) userEl.textContent = Number.isFinite(userScore) ? String(userScore) : "—";
if (countEl) countEl.textContent = `${totalVotes || 0} votes`;
if (marker && Number.isFinite(truthScoreValue)) marker.style.top = `${100 - truthScoreValue}%`;
if (label && Number.isFinite(truthScoreValue)) label.textContent = `${truthScoreValue}`;
const vote = (stats.user_vote || stats.UserVote || "").toString();
const btns = modalWrap.querySelectorAll(".sw-truth-vote-btn");
btns.forEach((btn) => {
const v = btn.getAttribute("data-truth-vote");
btn.classList.toggle("is-active", vote && v === vote);
});
const railUp = modalWrap.querySelector(".sw-truth-rail-btn-up");
const railDown = modalWrap.querySelector(".sw-truth-rail-btn-down");
if (railUp) railUp.classList.toggle("is-active", vote === "true");
if (railDown) railDown.classList.toggle("is-active", vote === "false");
}
function isAuthed() { function isAuthed() {
try { try {
const auth = loadAuth(); const auth = loadAuth();
@ -272,6 +306,34 @@ function mountCard(container, spec, data, theme, post) {
) )
); );
const rawScore = Number(
post?.truth_score ??
post?.truthScore ??
post?.truth_ai ??
post?.truthAi ??
post?.ai_score ??
post?.AIScore
);
const hasAI = Number.isFinite(
Number(post?.truth_ai ?? post?.truthAi ?? post?.ai_score ?? post?.AIScore)
);
const hasScore = Number.isFinite(rawScore) && !(rawScore === 50 && !hasAI);
if (!hasScore && post?.id) {
fetchPostTruth(post.id).then((stats) => {
if (!stats) return;
const nextScore = Number(stats.truth_score ?? stats.TruthScore);
if (!Number.isFinite(nextScore)) return;
post.truth_score = nextScore;
const markerEl = container.querySelector(".sw-truth-mini-marker");
const scoreEl = container.querySelector(".sw-truth-mini-score");
if (markerEl) markerEl.style.top = `${100 - nextScore}%`;
if (scoreEl) {
scoreEl.style.background = truthColor(nextScore);
scoreEl.textContent = `${Math.round(nextScore)}`;
}
});
}
return () => { return () => {
try { root.unmount(); } catch {} try { root.unmount(); } catch {}
}; };
@ -353,16 +415,18 @@ function getPostTime(post) {
} }
function truthScore(post) { function truthScore(post) {
const raw = Number(post?.truth_score || post?.truthScore); const raw = Number(
post?.truth_score ??
post?.truthScore ??
post?.truth_ai ??
post?.truthAi ??
post?.ai_score ??
post?.AIScore
);
if (Number.isFinite(raw)) { if (Number.isFinite(raw)) {
return clamp(Math.round(raw), 0, 100); return clamp(Math.round(raw), 0, 100);
} }
const seed = `${post?.id || ""}|${post?.url || ""}|${post?.title || ""}`; return 50;
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
}
return 35 + (h % 61);
} }
function truthColor(score) { function truthColor(score) {
@ -414,6 +478,16 @@ function closeCenteredOverlay(map, opts = {}) {
map.__swCenteredOverlay = null; map.__swCenteredOverlay = null;
try {
const st = ov.interactionState || {};
if (map) {
if (st.dragPan) map.dragPan?.enable?.();
if (st.scrollZoom) map.scrollZoom?.enable?.();
if (st.doubleClickZoom) map.doubleClickZoom?.enable?.();
if (st.touchZoomRotate) map.touchZoomRotate?.enable?.();
}
} catch {}
if (!skipHistory && ov.historyPushed && !ov.historyClosed) { if (!skipHistory && ov.historyPushed && !ov.historyClosed) {
ov.historyClosed = true; ov.historyClosed = true;
try { window.history.back(); } catch {} try { window.history.back(); } catch {}
@ -426,6 +500,17 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true }); closeCenteredOverlay(map, { force: true, reason: "switch", skipHistory: true });
const interactionState = {
dragPan: map.dragPan && map.dragPan.isEnabled ? map.dragPan.isEnabled() : null,
scrollZoom: map.scrollZoom && map.scrollZoom.isEnabled ? map.scrollZoom.isEnabled() : null,
doubleClickZoom: map.doubleClickZoom && map.doubleClickZoom.isEnabled ? map.doubleClickZoom.isEnabled() : null,
touchZoomRotate: map.touchZoomRotate && map.touchZoomRotate.isEnabled ? map.touchZoomRotate.isEnabled() : null,
};
try { if (map.dragPan) map.dragPan.disable(); } catch {}
try { if (map.scrollZoom) map.scrollZoom.disable(); } catch {}
try { if (map.doubleClickZoom) map.doubleClickZoom.disable(); } catch {}
try { if (map.touchZoomRotate) map.touchZoomRotate.disable(); } catch {}
let postData = { ...(post || {}) }; let postData = { ...(post || {}) };
const data = adaptPostToTemplateData(postData); const data = adaptPostToTemplateData(postData);
const cat = normCatLabel(postData); const cat = normCatLabel(postData);
@ -494,12 +579,12 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
<div class="sw-modal-hero-row"> <div class="sw-modal-hero-row">
<div class="sw-truth-rail" data-score="${truth}"> <div class="sw-truth-rail" data-score="${truth}">
<div class="sw-truth-rail-top">TRUE</div> <button class="sw-truth-rail-btn sw-truth-rail-btn-up" type="button">TRUE</button>
<div class="sw-truth-rail-bar"> <div class="sw-truth-rail-bar">
<div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div> <div class="sw-truth-rail-marker" style="top:${100 - truth}%"></div>
</div> </div>
<div class="sw-truth-rail-score">${truth}</div> <div class="sw-truth-rail-score">${truth}</div>
<div class="sw-truth-rail-bottom">FALSE</div> <button class="sw-truth-rail-btn sw-truth-rail-btn-down" type="button">FALSE</button>
</div> </div>
<div class="sw-modal-hero"> <div class="sw-modal-hero">
${ ${
@ -509,7 +594,6 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
} }
</div> </div>
</div> </div>
<div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div> <div class="sw-modal-title">${escapeHtml(data?.headline || postData?.title || "Untitled")}</div>
<div class="sw-modal-summary"> <div class="sw-modal-summary">
@ -612,7 +696,11 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
closeCenteredOverlay(map, { force: true }); closeCenteredOverlay(map, { force: true });
}); });
// inside click doesn't close // inside click doesn't close
modalWrap.addEventListener("click", (e) => e.stopPropagation()); const stopPropagation = (e) => e.stopPropagation();
modalWrap.addEventListener("click", stopPropagation);
modalWrap.addEventListener("pointerdown", stopPropagation);
modalWrap.addEventListener("mousedown", stopPropagation);
modalWrap.addEventListener("touchstart", stopPropagation, { passive: true });
// close button // close button
const xbtn = modalWrap.querySelector(".sw-modal-x"); const xbtn = modalWrap.querySelector(".sw-modal-x");
@ -668,6 +756,12 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}); });
} }
if (postID > 0) {
fetchPostTruth(postID).then((stats) => {
if (stats) updateTruthVoteUI(modalWrap, stats);
});
}
let liked = false; let liked = false;
const likeBtn = modalWrap.querySelector('[data-action="like"]'); const likeBtn = modalWrap.querySelector('[data-action="like"]');
const commentInput = modalWrap.querySelector(".sw-livechat-input"); const commentInput = modalWrap.querySelector(".sw-livechat-input");
@ -784,6 +878,26 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
}); });
} }
const rail = modalWrap.querySelector(".sw-truth-rail");
if (rail && postID > 0) {
const doVote = async (vote) => {
if (!isAuthed()) {
requestAuth("Please sign in or create a free account to vote on truth.");
return;
}
try {
rail.classList.add("sw-truth-vote-disabled");
const stats = await votePostTruth(postID, vote);
if (stats) updateTruthVoteUI(modalWrap, stats);
} catch {}
rail.classList.remove("sw-truth-vote-disabled");
};
const voteUp = rail.querySelector(".sw-truth-rail-btn-up");
const voteDown = rail.querySelector(".sw-truth-rail-btn-down");
if (voteUp) voteUp.addEventListener("click", () => doVote("true"));
if (voteDown) voteDown.addEventListener("click", () => doVote("false"));
}
applyAuthUI(); applyAuthUI();
const onAuth = () => { const onAuth = () => {
if (postID > 0 && isAuthed()) { if (postID > 0 && isAuthed()) {
@ -808,6 +922,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
postId: postData?.id, postId: postData?.id,
modalWrapRef: modalWrap, modalWrapRef: modalWrap,
backdrop, backdrop,
interactionState,
onKey, onKey,
onPop, onPop,
onAuth, onAuth,

View File

@ -533,7 +533,7 @@ export function usePostsEngine({
); );
const refreshVisibleAndMarkers = useCallback( const refreshVisibleAndMarkers = useCallback(
(tf) => { (tf, force = false) => {
const vAll = computeVisible(tf); const vAll = computeVisible(tf);
const map = mapRef.current; const map = mapRef.current;
let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map); let v = (searchQuery || "").trim() ? vAll : filterByBounds(vAll, map);
@ -543,7 +543,10 @@ export function usePostsEngine({
} }
// reduce wall blink: don't update if same ids // reduce wall blink: don't update if same ids
setVisiblePosts((prev) => (sameIdList(prev, v) ? prev : v)); setVisiblePosts((prev) => {
if (force) return v;
return sameIdList(prev, v) ? prev : v;
});
// keep status silent (no annoying bubbles) // keep status silent (no annoying bubbles)
setStatus(""); setStatus("");
@ -843,5 +846,85 @@ export function usePostsEngine({
[mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers] [mainFilter, subFilter, timeFilter, searchQuery, mapRef, markersRef, expandedElRef, theme, refreshVisibleAndMarkers]
); );
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost }; const handlePostUpdate = useCallback(
(payload) => {
const postId = payload?.post_id || payload?.postId || payload?.id;
if (!postId) return;
const counts = payload?.counts || null;
const truth = payload?.truth || null;
const truthScore = Number(
truth?.truth_score ??
truth?.TruthScore ??
payload?.truth_score ??
payload?.truthScore
);
const updated = [];
let changed = false;
for (const p of allPostsRef.current || []) {
if ((p?.id || p?.ID) !== postId) {
updated.push(p);
continue;
}
const next = { ...p };
if (counts) {
if (Number.isFinite(counts.view_count)) next.view_count = counts.view_count;
if (Number.isFinite(counts.like_count)) next.like_count = counts.like_count;
if (Number.isFinite(counts.share_count)) next.share_count = counts.share_count;
if (Number.isFinite(counts.comment_count)) next.comment_count = counts.comment_count;
}
if (Number.isFinite(truthScore)) next.truth_score = truthScore;
updated.push(next);
changed = true;
}
if (changed) {
allPostsRef.current = updated;
refreshVisibleAndMarkers(timeFilter, true);
}
try {
if (Number.isFinite(truthScore)) {
const markers = markersRef.current || [];
for (const item of markers) {
if (!item || item.id !== postId) continue;
const el = item.el;
if (!el) continue;
const marker = el.querySelector(".sw-truth-mini-marker");
const scoreEl = el.querySelector(".sw-truth-mini-score");
if (marker) marker.style.top = `${100 - truthScore}%`;
if (scoreEl) {
scoreEl.style.background = `hsl(${Math.round((truthScore / 100) * 120)} 80% 42%)`;
scoreEl.textContent = `${Math.round(truthScore)}`;
}
}
}
const map = mapRef.current;
const ov = map?.__swCenteredOverlay;
if (ov && ov.postId === postId && ov.modalWrapRef) {
const wrap = ov.modalWrapRef;
if (counts) {
const mapStat = (key, value) => {
if (!Number.isFinite(value)) return;
const el = wrap.querySelector(`[data-stat="${key}"] .sw-stat-num`);
if (el) el.textContent = String(value);
};
mapStat("views", counts.view_count);
mapStat("likes", counts.like_count);
mapStat("shares", counts.share_count);
mapStat("comments", counts.comment_count);
}
if (Number.isFinite(truthScore)) {
const marker = wrap.querySelector(".sw-truth-rail-marker");
const label = wrap.querySelector(".sw-truth-rail-score");
if (marker) marker.style.top = `${100 - truthScore}%`;
if (label) label.textContent = `${Math.round(truthScore)}`;
}
}
} catch {}
},
[mapRef, refreshVisibleAndMarkers, timeFilter]
);
return { status, visiblePosts, loadingPosts, loadError, handleIncomingPost, handlePostUpdate };
} }

View File

@ -598,18 +598,22 @@ body[data-theme="light"] .sw-chat-bubble::before{
.sw-modal-hero-row{ .sw-modal-hero-row{
display:flex; display:flex;
gap:10px; gap:16px;
align-items: stretch; align-items: stretch;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
padding-left: 0;
} }
.sw-truth-rail{ .sw-truth-rail{
width: 16px; width: 22px;
min-width: 16px; min-width: 22px;
height: 180px; height: 180px;
min-height: 180px; min-height: 180px;
margin-left: 3px; margin-left: -2px;
margin-right: 10px;
position: relative;
z-index: 2;
display:flex; display:flex;
flex-direction:column; flex-direction:column;
align-items:center; align-items:center;
@ -619,6 +623,8 @@ body[data-theme="light"] .sw-chat-bubble::before{
letter-spacing: .08em; letter-spacing: .08em;
text-transform: uppercase; text-transform: uppercase;
color:#e2e8f0; color:#e2e8f0;
pointer-events: auto;
touch-action: manipulation;
} }
.sw-truth-rail-bar{ .sw-truth-rail-bar{
@ -629,6 +635,7 @@ body[data-theme="light"] .sw-chat-bubble::before{
border: 2px solid rgba(0,0,0,0.45); border: 2px solid rgba(0,0,0,0.45);
position: relative; position: relative;
box-shadow: inset 0 0 10px rgba(0,0,0,0.35); box-shadow: inset 0 0 10px rgba(0,0,0,0.35);
margin: 0 auto;
} }
.sw-truth-rail-marker{ .sw-truth-rail-marker{
@ -651,12 +658,122 @@ body[data-theme="light"] .sw-chat-bubble::before{
padding: 4px 6px; padding: 4px 6px;
border-radius: 999px; border-radius: 999px;
box-shadow: 0 6px 12px rgba(0,0,0,0.35); box-shadow: 0 6px 12px rgba(0,0,0,0.35);
margin: 0 auto;
position: relative;
left: -6px;
}
.sw-truth-rail-btn{
width: 100%;
border: 0;
background: rgba(56,189,248,0.16);
color:#e8f5ff;
font-size: 9px;
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
padding: 6px 2px;
border-radius: 10px;
cursor: pointer;
text-align: center;
pointer-events: auto;
touch-action: manipulation;
position: relative;
left: -8px;
}
.sw-truth-rail-btn-down{
background: rgba(239,68,68,0.18);
}
.sw-truth-rail-btn.is-active{
background: rgba(34,197,94,0.35);
color: #eafff3;
}
.sw-truth-rail-btn-down.is-active{
background: rgba(239,68,68,0.35);
color: #ffecec;
}
.sw-truth-vote-disabled{
opacity: 0.7;
pointer-events: none;
} }
.sw-modal-hero-row{ .sw-modal-hero-row{
display:flex; display:flex;
gap:12px; gap:12px;
align-items: stretch; align-items: stretch;
min-width: 0;
width: 100%;
}
.sw-truth-vote-row{
display:flex;
gap:12px;
align-items:center;
justify-content:space-between;
background: rgba(3, 14, 32, 0.55);
border: 1px solid rgba(90, 190, 255, 0.22);
border-radius: 14px;
padding: 10px 12px;
margin: 6px 0 10px;
}
.sw-truth-vote-meta{
display:flex;
flex-direction:column;
gap:4px;
min-width:0;
}
.sw-truth-vote-title{
font-size: 11px;
font-weight: 900;
letter-spacing: .08em;
text-transform: uppercase;
color: #c7e3ff;
}
.sw-truth-vote-scores{
display:flex;
gap:10px;
font-size: 12px;
color:#e8f5ff;
font-weight: 700;
}
.sw-truth-vote-count{
font-size: 11px;
color: rgba(215,233,255,0.7);
}
.sw-truth-vote-actions{
display:flex;
gap:8px;
}
.sw-truth-vote-btn{
background: rgba(56,189,248,0.14);
border: 1px solid rgba(56,189,248,0.35);
color: #e8f5ff;
font-weight: 900;
padding: 6px 10px;
border-radius: 10px;
cursor:pointer;
}
.sw-truth-vote-btn.is-active{
background: rgba(34,197,94,0.25);
border-color: rgba(34,197,94,0.6);
color:#eafff3;
}
.sw-truth-vote-btn[data-truth-vote="false"].is-active{
background: rgba(239,68,68,0.25);
border-color: rgba(239,68,68,0.6);
color:#ffecec;
} }
.sw-card-wrap{ .sw-card-wrap{