mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
dev day #67
This commit is contained in:
@@ -533,9 +533,14 @@ const CalendarPage: React.FC = () => {
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
let ourIsAway = false;
|
||||
if (clubId && m.home_id && m.away_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
ourIsAway = m.away_id === clubId;
|
||||
if (clubId) {
|
||||
// Check each team ID individually - even if one is missing, we can still match the other
|
||||
if (m.home_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
}
|
||||
if (m.away_id) {
|
||||
ourIsAway = m.away_id === clubId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to name matching if IDs not available or no match
|
||||
|
||||
+143
-65
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import '../styles/theme.css';
|
||||
@@ -16,6 +16,7 @@ import { getArticles as apiGetArticles, Article as ApiArticle } from '../service
|
||||
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
|
||||
import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
|
||||
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
|
||||
import MyUIbrixErrorBoundary from '../components/editor/MyUIbrixErrorBoundary';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
@@ -105,7 +106,19 @@ const HomePage: React.FC = () => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, loading: configLoading } = useAllPageElementConfigs('homepage');
|
||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
||||
|
||||
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||
id: typeof item.id === 'number' ? item.id : index,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt,
|
||||
image: item.image,
|
||||
date: item.date,
|
||||
category: item.category
|
||||
? { id: index, name: item.category }
|
||||
: undefined,
|
||||
slug: item.slug,
|
||||
})), [featured]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -287,25 +300,28 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
})
|
||||
);
|
||||
// Sort by datetime and pick upcoming first
|
||||
// Sort by datetime and filter to 14-day range (14 days past + 14 days future)
|
||||
const parseDT = (d: string, t: string) => new Date(`${d}T${(t || '00:00')}:00`).getTime();
|
||||
const now = Date.now();
|
||||
const upcoming = allMatches
|
||||
const fourteenDaysInMs = 14 * 24 * 60 * 60 * 1000;
|
||||
const minDate = now - fourteenDaysInMs;
|
||||
const maxDate = now + fourteenDaysInMs;
|
||||
const filteredMatches = allMatches
|
||||
.map((m: any & { __ts?: number }) => ({
|
||||
...m,
|
||||
__ts: parseDT(m.date, m.time)
|
||||
}))
|
||||
.filter((m: { __ts?: number }) => typeof m.__ts === 'number' && !isNaN(m.__ts!) && (m.__ts as number) >= now)
|
||||
.filter((m: { __ts?: number }) => {
|
||||
const ts = m.__ts;
|
||||
return typeof ts === 'number' && !isNaN(ts) && ts >= minDate && ts <= maxDate;
|
||||
})
|
||||
.sort((a: { __ts?: number }, b: { __ts?: number }) => (a.__ts as number) - (b.__ts as number))
|
||||
.map(({ __ts, ...rest }: any & { __ts?: number }) => rest);
|
||||
const chosen = upcoming.length ? upcoming : allMatches;
|
||||
setMatches(chosen);
|
||||
setMatches(filteredMatches);
|
||||
|
||||
// Build competitions with their matches for slider
|
||||
const comps = (facrClubJSON.competitions || []).map((c: any) => ({
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||
matches_link: c.matches_link,
|
||||
matches: (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => {
|
||||
// Build competitions with their matches for slider (also filter to 14-day range)
|
||||
const comps = (facrClubJSON.competitions || []).map((c: any) => {
|
||||
const compMatches = (Array.isArray(c.matches) ? c.matches : []).map((m: any, idx: number) => {
|
||||
const dt: string = String(m.date_time || '');
|
||||
const [d, t] = dt.includes(' ') ? dt.split(' ') : [dt, ''];
|
||||
const [day, month, year] = (d || '').split('.');
|
||||
@@ -324,8 +340,18 @@ const HomePage: React.FC = () => {
|
||||
report_url: m.report_url,
|
||||
venue: m.venue || '',
|
||||
};
|
||||
})
|
||||
}));
|
||||
});
|
||||
// Filter to 14-day range
|
||||
const filtered = compMatches.filter((m: any) => {
|
||||
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
||||
return !isNaN(ts) && ts >= minDate && ts <= maxDate;
|
||||
});
|
||||
return {
|
||||
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
|
||||
matches_link: c.matches_link,
|
||||
matches: filtered
|
||||
};
|
||||
});
|
||||
setFacrCompetitions(comps);
|
||||
|
||||
// Compute closest match index per competition to current time
|
||||
@@ -345,7 +371,7 @@ const HomePage: React.FC = () => {
|
||||
setClosestIndexByComp(closestIdx);
|
||||
|
||||
// Next match FACR link
|
||||
const first = chosen?.[0];
|
||||
const first = filteredMatches?.[0];
|
||||
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
|
||||
} else {
|
||||
setMatches(mapMatches(matchesJSON));
|
||||
@@ -464,7 +490,8 @@ const HomePage: React.FC = () => {
|
||||
table: (c.table?.overall || []).map((r: any, idx: number) => ({
|
||||
position: Number(r.rank || idx + 1),
|
||||
team: r.team || r.team_name || '-',
|
||||
team_logo_url: r.team_logo_url,
|
||||
team_logo_url: getOverrideLogo(r.team || r.team_name, r.team_logo_url),
|
||||
team_id: r.team_id,
|
||||
points: Number(r.points || r.pts || 0),
|
||||
played: Number(r.played || 0),
|
||||
wins: Number(r.wins || 0),
|
||||
@@ -1367,7 +1394,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||
<section data-element="hero" className="hero-grid">
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
{featured[0] ? (
|
||||
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
||||
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
|
||||
@@ -1409,7 +1436,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
{/* Banner: homepage_middle */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1423,7 +1450,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Sidebar banners (homepage_sidebar) */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
|
||||
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0' }}>
|
||||
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
|
||||
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
||||
@@ -1438,13 +1465,14 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section data-element="hero">
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogCardsScroller />
|
||||
</section>
|
||||
)}
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<section data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
|
||||
<BlogSwiper />
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogSwiper fallbackArticles={heroFallbackArticles}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1470,7 +1498,7 @@ const HomePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer' }}>
|
||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
|
||||
<button
|
||||
aria-label="Předchozí soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
|
||||
@@ -1504,7 +1532,7 @@ const HomePage: React.FC = () => {
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<section data-element="matches" className="next-match">
|
||||
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
||||
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
|
||||
@@ -1527,7 +1555,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Matches slider with scores by competition */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
<section className="matches-slider">
|
||||
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>Zápasy</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
||||
@@ -1603,7 +1631,7 @@ const HomePage: React.FC = () => {
|
||||
data-element="table"
|
||||
className="standings"
|
||||
data-variant={hasStandingsForCurrentTab ? undefined : 'standard'}
|
||||
style={{ marginTop: 32 }}
|
||||
style={{ marginTop: 32, ...getStyles('table') }}
|
||||
>
|
||||
<div>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
@@ -1637,37 +1665,84 @@ const HomePage: React.FC = () => {
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="standings">
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={idx} className="standing-row" onClick={handleClick}>
|
||||
<div className="pos">#{row.position ?? row.pos ?? row.rank ?? idx+1}</div>
|
||||
<div className="team">
|
||||
{row.team_logo_url && (
|
||||
<img src={assetUrl(row.team_logo_url)} alt={row.team?.name ?? row.team ?? row.club ?? '-'} />
|
||||
)}
|
||||
<span className="name">{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
<div className="pts">{row.points ?? row.pts ?? '-'}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
|
||||
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
|
||||
<thead>
|
||||
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
|
||||
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
|
||||
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
|
||||
const handleClick = () => {
|
||||
const clubData = {
|
||||
team: row.team?.name ?? row.team ?? row.club ?? '-',
|
||||
team_id: row.team_id || '',
|
||||
team_logo_url: row.team_logo_url,
|
||||
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
|
||||
played: row.played ?? row.matches ?? '-',
|
||||
wins: row.wins ?? row.win ?? '-',
|
||||
draws: row.draws ?? row.draw ?? '-',
|
||||
losses: row.losses ?? row.loss ?? '-',
|
||||
score: row.score ?? '-',
|
||||
points: row.points ?? row.pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = 'var(--card-border)';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--primary)', fontSize: '0.9rem' }}>#{row.position ?? row.pos ?? row.rank ?? idx+1}</td>
|
||||
<td style={{ padding: '10px 8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
{row.team_logo_url && (
|
||||
<img
|
||||
src={assetUrl(row.team_logo_url)}
|
||||
alt={row.team?.name ?? row.team ?? row.club ?? '-'}
|
||||
style={{ width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', background: 'var(--bg-soft)', border: '1px solid var(--card-border)', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.played ?? row.matches ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.wins ?? row.win ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.draws ?? row.draw ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.losses ?? row.loss ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)', display: 'none' }} className="hide-mobile">{row.score ?? '-'}</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 700, color: 'var(--secondary)', fontSize: '1rem' }}>{row.points ?? row.pts ?? '-'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1678,7 +1753,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Players scroller (optional) */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32 }}>
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<div className="section-head">
|
||||
<h3>Hráči</h3>
|
||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
@@ -1713,7 +1788,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection />
|
||||
</div>
|
||||
@@ -1722,7 +1797,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32 }}>
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection />
|
||||
</div>
|
||||
@@ -1730,7 +1805,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
</div>
|
||||
@@ -1739,7 +1814,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Newsletter subscription CTA */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
@@ -1748,7 +1823,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Banner: homepage_top */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_top') && (
|
||||
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-top" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_top').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1760,7 +1835,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Banner: homepage_footer */}
|
||||
{(banners || []).some(b => b.placement === 'homepage_footer') && (
|
||||
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center' }}>
|
||||
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
|
||||
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
|
||||
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
@@ -1784,6 +1859,7 @@ const HomePage: React.FC = () => {
|
||||
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
|
||||
boxSizing: 'border-box',
|
||||
...getStyles('sponsors')
|
||||
}}
|
||||
>
|
||||
<div className="section-head">
|
||||
@@ -1842,7 +1918,9 @@ const HomePage: React.FC = () => {
|
||||
console.log('Team clicked:', teamName);
|
||||
}}
|
||||
/>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
<MyUIbrixErrorBoundary>
|
||||
<MyUIbrixStyleEditor pageType="homepage" />
|
||||
</MyUIbrixErrorBoundary>
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -121,9 +121,14 @@ const MatchesPage: React.FC = () => {
|
||||
// First try ID-based matching (most reliable)
|
||||
let ourIsHome = false;
|
||||
let ourIsAway = false;
|
||||
if (clubId && m.home_id && m.away_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
ourIsAway = m.away_id === clubId;
|
||||
if (clubId) {
|
||||
// Check each team ID individually - even if one is missing, we can still match the other
|
||||
if (m.home_id) {
|
||||
ourIsHome = m.home_id === clubId;
|
||||
}
|
||||
if (m.away_id) {
|
||||
ourIsAway = m.away_id === clubId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to name matching if IDs not available or no match
|
||||
|
||||
@@ -57,6 +57,9 @@ import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
@@ -77,6 +80,8 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editing, setEditing] = useState<Partial<Event> | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
@@ -88,6 +93,31 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If event has ID, update it
|
||||
if (data.id) {
|
||||
return await updateEvent(data.id, data);
|
||||
}
|
||||
// If no ID and has title, create as draft
|
||||
if (data.title?.trim() && data.start_time) {
|
||||
const created = await createEvent(data);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title or start time
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-events'],
|
||||
queryFn: () => getEvents(),
|
||||
@@ -115,14 +145,28 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-activity-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
const openEdit = (ev: Event) => {
|
||||
setEditing({ ...ev });
|
||||
const openEdit = (ev: Event) => {
|
||||
// Set unique draft key for this event
|
||||
const key = `draft-activity-${ev.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({ ...ev });
|
||||
// Initialize map coordinates from event
|
||||
if ((ev as any).latitude && (ev as any).longitude) {
|
||||
setLocationLat((ev as any).latitude);
|
||||
@@ -131,13 +175,43 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
}
|
||||
onOpen();
|
||||
onOpen();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onClose();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<Partial<Event>>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
// Restore location if present
|
||||
if ((draft as any)?.latitude && (draft as any)?.longitude) {
|
||||
setLocationLat((draft as any).latitude);
|
||||
setLocationLng((draft as any).longitude);
|
||||
}
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
@@ -433,10 +507,13 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<ModalOverlay backdropFilter="blur(3px)" />
|
||||
<ModalContent maxW={{ base: '96vw', md: '920px' }} maxH={{ base: '90vh', md: '86vh' }} borderRadius="2xl" overflow="hidden" boxShadow="2xl">
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<HStack justify="space-between" align="start" w="full" pr={8}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} compact />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto" maxH={{ base: '76vh', md: '70vh' }}>
|
||||
@@ -946,6 +1023,17 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="aktivitu"
|
||||
/>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,9 @@ import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
@@ -173,6 +176,8 @@ const ArticlesAdminPage = () => {
|
||||
}
|
||||
|
||||
const [editing, setEditing] = useState<EditingArticle | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
|
||||
|
||||
|
||||
@@ -254,6 +259,53 @@ const ArticlesAdminPage = () => {
|
||||
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
||||
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If article has ID, update it as draft
|
||||
if (data.id) {
|
||||
return await updateArticle(data.id, { ...data as any, published: false });
|
||||
}
|
||||
// If no ID, create as draft
|
||||
if (data.title?.trim()) {
|
||||
const payload: CreateArticlePayload = {
|
||||
title: data.title || 'Koncept článku',
|
||||
content: data.content || '',
|
||||
image_url: data.image_url || '',
|
||||
category_name: data.category_name,
|
||||
published: false, // Always save as draft
|
||||
slug: data.slug || '',
|
||||
seo_title: data.seo_title || '',
|
||||
seo_description: data.seo_description || '',
|
||||
og_image_url: data.og_image_url || '',
|
||||
featured: data.featured || false,
|
||||
};
|
||||
const created = await createArticle(payload);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
// Check for draft on component mount
|
||||
React.useEffect(() => {
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) { // Less than 24 hours old
|
||||
setShowDraftRecovery(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch cached Zonerama gallery from prefetch
|
||||
const fetchCachedGallery = useCallback(async () => {
|
||||
try {
|
||||
@@ -616,13 +668,27 @@ const ArticlesAdminPage = () => {
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', content: '', featured: false, published: true } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
// Check for existing draft
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (a: Article) => {
|
||||
// Set unique draft key for this article
|
||||
const key = `draft-article-${a.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({
|
||||
...a,
|
||||
category_name: a.category?.name || a.category_name || ''
|
||||
@@ -652,6 +718,31 @@ const ArticlesAdminPage = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<EditingArticle>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
setActiveTabIndex(1); // Go to Základní tab
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: CreateArticlePayload) =>
|
||||
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
||||
@@ -1189,7 +1280,12 @@ const ArticlesAdminPage = () => {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="xl" isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh">
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</ModalHeader>
|
||||
<ModalHeader>
|
||||
<HStack justify="space-between" align="center" w="full" pr={8}>
|
||||
<Text>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</Text>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
|
||||
@@ -1839,20 +1935,28 @@ const ArticlesAdminPage = () => {
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">Článek ještě není uložen</Text>
|
||||
<Text fontSize="sm">
|
||||
Pro propojení anket s článkem musíte nejprve článek uložit. Klikněte na "Uložit" níže - článek se uloží jako koncept a poté budete moci přidat ankety.
|
||||
<Text fontWeight="semibold">
|
||||
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
|
||||
</Text>
|
||||
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
|
||||
{saveStatus === 'idle' && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
💡 Vyplňte název článku pro aktivaci automatického ukládání
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
// Save article as draft first, keep modal open
|
||||
// Force save if needed
|
||||
try {
|
||||
await onSubmit({ keepOpen: true });
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(5); // Poll tab is index 5
|
||||
} catch (error) {
|
||||
@@ -2164,6 +2268,17 @@ const ArticlesAdminPage = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="článek"
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -518,6 +518,34 @@ html {
|
||||
.table-card .standing-row .team .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 600; color: var(--text); }
|
||||
.table-card .standing-row .pts { text-align: right; font-weight: 800; color: var(--secondary); font-size: 1.1rem; }
|
||||
|
||||
/* Compact standings table with full statistics */
|
||||
.standings-table-compact {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.standings-table-compact thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
z-index: 1;
|
||||
border-bottom: 2px solid var(--card-border);
|
||||
}
|
||||
|
||||
.standings-table-compact tbody tr {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.standings-table-compact tbody tr + tr {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Hide score column on smaller screens to maintain compact layout */
|
||||
@media (max-width: 1200px) {
|
||||
.standings-table-compact .hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enriched standings table (logos, colors, motion) */
|
||||
.card.tables .table.enriched { margin-top: 8px; }
|
||||
.card.tables .table.enriched .tr {
|
||||
|
||||
Reference in New Issue
Block a user