mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
de day #74
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
REACT_APP_API_URL=/api/v1
|
||||
REACT_APP_ASSET_BASE_URL=http://127.0.0.1:8080
|
||||
REACT_APP_NAME=Fotbal Club Manager
|
||||
REACT_APP_ENV=development
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.3ca1fc6e.css",
|
||||
"main.js": "/static/js/main.8c4bb3a7.js",
|
||||
"runtime.js": "/static/js/runtime.18a1ba68.js",
|
||||
"static/js/453.54292a4b.chunk.js": "/static/js/453.54292a4b.chunk.js",
|
||||
"static/css/290.1124e12e.css": "/static/css/290.1124e12e.css",
|
||||
"static/js/290.0640644c.js": "/static/js/290.0640644c.js",
|
||||
"index.html": "/index.html",
|
||||
"main.3ca1fc6e.css.map": "/static/css/main.3ca1fc6e.css.map",
|
||||
"main.8c4bb3a7.js.map": "/static/js/main.8c4bb3a7.js.map",
|
||||
"runtime.18a1ba68.js.map": "/static/js/runtime.18a1ba68.js.map",
|
||||
"453.54292a4b.chunk.js.map": "/static/js/453.54292a4b.chunk.js.map",
|
||||
"290.1124e12e.css.map": "/static/css/290.1124e12e.css.map",
|
||||
"290.0640644c.js.map": "/static/js/290.0640644c.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/runtime.18a1ba68.js",
|
||||
"static/css/290.1124e12e.css",
|
||||
"static/js/290.0640644c.js",
|
||||
"static/css/main.3ca1fc6e.css",
|
||||
"static/js/main.8c4bb3a7.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Oficiální webové stránky fotbalového klubu - aktuality, zápasy, tabulky, hráči a fotogalerie"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>Fotbal Club</title><script defer="defer" src="/static/js/runtime.18a1ba68.js"></script><script defer="defer" src="/static/js/290.0640644c.js"></script><script defer="defer" src="/static/js/main.8c4bb3a7.js"></script><link href="/static/css/290.1124e12e.css" rel="stylesheet"><link href="/static/css/main.3ca1fc6e.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,121 @@
|
||||
/*!
|
||||
* @kurkle/color v0.3.4
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2024 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/
|
||||
|
||||
/*!
|
||||
* Chart.js v4.4.1
|
||||
* https://www.chartjs.org
|
||||
* (c) 2023 Chart.js Contributors
|
||||
* Released under the MIT License
|
||||
*/
|
||||
|
||||
/*!
|
||||
* Quill Editor v1.3.7
|
||||
* https://quilljs.com/
|
||||
* Copyright (c) 2014, Jason Chen
|
||||
* Copyright (c) 2013, salesforce.com
|
||||
*/
|
||||
|
||||
/*! @license DOMPurify 3.2.6 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.6/LICENSE */
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license React
|
||||
* use-sync-external-store-shim.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @license lucide-react v0.379.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @remix-run/router v1.23.0
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router DOM v6.30.1
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* React Router v6.30.1
|
||||
*
|
||||
* Copyright (c) Remix Software Inc.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE.md file in the root directory of this source tree.
|
||||
*
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
"use strict";(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[453],{6453:(e,t,n)=>{n.r(t),n.d(t,{getCLS:()=>y,getFCP:()=>g,getFID:()=>C,getLCP:()=>P,getTTFB:()=>D});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver(function(e){return e.getEntries().map(t)});return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",function(t){t.persisted&&e(t)},!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,d=function(){return"hidden"===document.visibilityState?0:1/0},p=function(){f(function(e){var t=e.timeStamp;v=t},!0)},l=function(){return v<0&&(v=d(),p(),s(function(){setTimeout(function(){v=d(),p()},0)})),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s(function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,n(!0)})})}))},h=!1,T=-1,y=function(e,t){h||(g(function(e){T=e.value}),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},d=c("layout-shift",v);d&&(n=m(i,r,t),f(function(){d.takeRecords().map(v),n(!0)}),s(function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)}))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach(function(t){t(e)}),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach(function(t){return e(t,b,E)})},C=function(e,t){var n,a=l(),v=u("FID"),d=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},p=c("first-input",d);n=m(e,v,t),p&&f(function(){p.takeRecords().map(d),p.disconnect()},!0),p&&s(function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=d,o.push(a),S()})},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach(function(e){addEventListener(e,v,{once:!0,capture:!0})}),f(v,!0),s(function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame(function(){requestAnimationFrame(function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)})})})}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",function(){return setTimeout(t,0)})}}}]);
|
||||
//# sourceMappingURL=453.54292a4b.chunk.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
(()=>{"use strict";var e={},t={};function r(o){var n=t[o];if(void 0!==n)return n.exports;var a=t[o]={id:o,loaded:!1,exports:{}};return e[o].call(a.exports,a,a.exports,r),a.loaded=!0,a.exports}r.m=e,(()=>{var e=[];r.O=(t,o,n,a)=>{if(!o){var i=1/0;for(d=0;d<e.length;d++){o=e[d][0],n=e[d][1],a=e[d][2];for(var l=!0,u=0;u<o.length;u++)(!1&a||i>=a)&&Object.keys(r.O).every(e=>r.O[e](o[u]))?o.splice(u--,1):(l=!1,a<i&&(i=a));if(l){e.splice(d--,1);var f=n();void 0!==f&&(t=f)}}return t}a=a||0;for(var d=e.length;d>0&&e[d-1][2]>a;d--)e[d]=e[d-1];e[d]=[o,n,a]}})(),r.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return r.d(t,{a:t}),t},(()=>{var e,t=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;r.t=function(o,n){if(1&n&&(o=this(o)),8&n)return o;if("object"===typeof o&&o){if(4&n&&o.__esModule)return o;if(16&n&&"function"===typeof o.then)return o}var a=Object.create(null);r.r(a);var i={};e=e||[null,t({}),t([]),t(t)];for(var l=2&n&&o;("object"==typeof l||"function"==typeof l)&&!~e.indexOf(l);l=t(l))Object.getOwnPropertyNames(l).forEach(e=>i[e]=()=>o[e]);return i.default=()=>o,r.d(a,i),a}})(),r.d=(e,t)=>{for(var o in t)r.o(t,o)&&!r.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:t[o]})},r.f={},r.e=e=>Promise.all(Object.keys(r.f).reduce((t,o)=>(r.f[o](e,t),t),[])),r.u=e=>"static/js/"+e+".54292a4b.chunk.js",r.miniCssF=e=>{},r.g=function(){if("object"===typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"===typeof window)return window}}(),r.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),(()=>{var e={},t="frontend:";r.l=(o,n,a,i)=>{if(e[o])e[o].push(n);else{var l,u;if(void 0!==a)for(var f=document.getElementsByTagName("script"),d=0;d<f.length;d++){var c=f[d];if(c.getAttribute("src")==o||c.getAttribute("data-webpack")==t+a){l=c;break}}l||(u=!0,(l=document.createElement("script")).charset="utf-8",l.timeout=120,r.nc&&l.setAttribute("nonce",r.nc),l.setAttribute("data-webpack",t+a),l.src=o),e[o]=[n];var s=(t,r)=>{l.onerror=l.onload=null,clearTimeout(p);var n=e[o];if(delete e[o],l.parentNode&&l.parentNode.removeChild(l),n&&n.forEach(e=>e(r)),t)return t(r)},p=setTimeout(s.bind(null,void 0,{type:"timeout",target:l}),12e4);l.onerror=s.bind(null,l.onerror),l.onload=s.bind(null,l.onload),u&&document.head.appendChild(l)}}})(),r.r=e=>{"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),r.p="/",(()=>{var e={121:0};r.f.j=(t,o)=>{var n=r.o(e,t)?e[t]:void 0;if(0!==n)if(n)o.push(n[2]);else if(121!=t){var a=new Promise((r,o)=>n=e[t]=[r,o]);o.push(n[2]=a);var i=r.p+r.u(t),l=new Error;r.l(i,o=>{if(r.o(e,t)&&(0!==(n=e[t])&&(e[t]=void 0),n)){var a=o&&("load"===o.type?"missing":o.type),i=o&&o.target&&o.target.src;l.message="Loading chunk "+t+" failed.\n("+a+": "+i+")",l.name="ChunkLoadError",l.type=a,l.request=i,n[1](l)}},"chunk-"+t,t)}else e[t]=0},r.O.j=t=>0===e[t];var t=(t,o)=>{var n,a,i=o[0],l=o[1],u=o[2],f=0;if(i.some(t=>0!==e[t])){for(n in l)r.o(l,n)&&(r.m[n]=l[n]);if(u)var d=u(r)}for(t&&t(o);f<i.length;f++)a=i[f],r.o(e,a)&&e[a]&&e[a][0](),e[a]=0;return r.O(d)},o=self.webpackChunkfrontend=self.webpackChunkfrontend||[];o.forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o))})(),r.nc=void 0})();
|
||||
//# sourceMappingURL=runtime.18a1ba68.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
# Allow larger uploads (default is 1MB) – must be >= backend MAX_UPLOAD_SIZE
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Enable gzip compression for text-based assets
|
||||
gzip on;
|
||||
@@ -78,6 +80,8 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Accept-Encoding gzip;
|
||||
# Ensure Nginx accepts large uploads on this route as well
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Enable buffering for better performance
|
||||
proxy_buffering on;
|
||||
@@ -86,6 +90,24 @@ server {
|
||||
proxy_busy_buffers_size 8k;
|
||||
}
|
||||
|
||||
# Short links and tracked redirect - must bypass SPA and hit backend
|
||||
location ^~ /s/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location = /r {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy backend-served assets so the frontend can use relative URLs
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:8080;
|
||||
|
||||
+13
-2
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import './styles/custom-scrollbar.css';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import AuthPage from './pages/AuthPage';
|
||||
@@ -51,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
|
||||
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
||||
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
||||
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
||||
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
|
||||
import SemiAdminPage from './pages/SemiAdminPage';
|
||||
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
||||
// Admin pages render their own AdminLayout internally
|
||||
@@ -75,6 +76,7 @@ import ForbiddenPage from './pages/ForbiddenPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
import VideosPage from './pages/VideosPage';
|
||||
import SearchPage from './pages/SearchPage';
|
||||
import ShortRedirectPage from './pages/ShortRedirectPage';
|
||||
import ClothingPage from './pages/ClothingPage';
|
||||
import PollsPage from './pages/PollsPage';
|
||||
import { useUmami } from './hooks/useUmami';
|
||||
@@ -258,6 +260,12 @@ const FontLoader: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Redirect /news -> /blog while preserving query parameters
|
||||
const NewsRedirect: React.FC = () => {
|
||||
const loc = useLocation();
|
||||
return <Navigate to={`/blog${loc.search || ''}`} replace />;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Uses shared ProtectedRoute component for auth guard
|
||||
|
||||
@@ -352,7 +360,9 @@ const App: React.FC = () => {
|
||||
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
||||
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
||||
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/news" element={<Navigate to="/blog" replace />} />
|
||||
{/* Short links - forward to backend origin if frontend captured it */}
|
||||
<Route path="/s/:code" element={<ShortRedirectPage />} />
|
||||
<Route path="/news" element={<NewsRedirect />} />
|
||||
{/* Slug routes must precede id route to avoid conflicts */}
|
||||
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
||||
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
|
||||
@@ -440,6 +450,7 @@ const App: React.FC = () => {
|
||||
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
|
||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
|
||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
||||
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
|
||||
import { Box, Button, Flex, Text, Link, Checkbox } from '@chakra-ui/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'cookie_consent';
|
||||
@@ -41,6 +41,18 @@ const CookieBanner: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Allow opening the preferences from anywhere (e.g. Cookie Policy page)
|
||||
useEffect(() => {
|
||||
const openHandler = (_e: Event) => {
|
||||
setManaging(true);
|
||||
setVisible(true);
|
||||
};
|
||||
window.addEventListener('cookie-consent-open', openHandler);
|
||||
return () => {
|
||||
window.removeEventListener('cookie-consent-open', openHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveAndClose = (c: Consent) => {
|
||||
const payload = { ...c, timestamp: new Date().toISOString() };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
@@ -62,46 +74,59 @@ const CookieBanner: React.FC = () => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Box role="dialog" aria-live="polite" position="fixed" bottom={0} left={0} right={0} bg="gray.900" color="gray.100" zIndex={1000} py={4} px={4}>
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
position="fixed"
|
||||
bottom={{ base: 4, md: 6 }}
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
bg="blackAlpha.800"
|
||||
color="gray.100"
|
||||
zIndex={1000}
|
||||
px={{ base: 4, md: 6 }}
|
||||
py={{ base: 4, md: 5 }}
|
||||
borderRadius="xl"
|
||||
boxShadow="xl"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.300"
|
||||
w={{ base: 'calc(100% - 2rem)', sm: 'calc(100% - 3rem)', md: 'auto' }}
|
||||
maxW="3xl"
|
||||
style={{ backdropFilter: 'blur(6px)' }}
|
||||
>
|
||||
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
|
||||
<Box maxW={{ base: '100%', md: '70%' }}>
|
||||
<Box maxW={{ base: '100%', md: '75%' }}>
|
||||
<Text fontSize="sm" mb={2}>
|
||||
<span role="img" aria-label="cookie">🍪</span>{' '}
|
||||
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
|
||||
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v
|
||||
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
|
||||
</Text>
|
||||
{managing && (
|
||||
<Box mt={3} bg="gray.800" borderRadius="md" p={3} border="1px solid" borderColor="gray.700">
|
||||
<Box mt={3} bg="gray.800" borderRadius="lg" p={4} borderWidth="1px" borderColor="gray.700">
|
||||
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
|
||||
<Flex direction="column" gap={2}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked readOnly />
|
||||
<Checkbox isChecked isDisabled>
|
||||
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.preferences}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
|
||||
/>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={!!consent.preferences}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
|
||||
>
|
||||
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.analytics}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
|
||||
/>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={!!consent.analytics}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
|
||||
>
|
||||
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!consent.marketing}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
|
||||
/>
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={!!consent.marketing}
|
||||
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
|
||||
>
|
||||
<Text fontSize="sm">Marketingové cookies</Text>
|
||||
</label>
|
||||
</Checkbox>
|
||||
<Flex gap={2} mt={2} wrap="wrap">
|
||||
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
|
||||
@@ -111,8 +136,8 @@ const CookieBanner: React.FC = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Flex gap={2} align="center" wrap="wrap">
|
||||
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
|
||||
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</Button>
|
||||
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="ghost">Nastavit</Button>
|
||||
<Button size="sm" onClick={rejectNonEssential} variant="outline" colorScheme="gray">Odmítnout nepovinné</Button>
|
||||
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -52,6 +52,7 @@ import { getCachedYouTube } from '../services/youtube';
|
||||
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
||||
import { getMyNewsletterToken } from '../services/public/newsletter';
|
||||
import { API_URL } from '../services/api';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||
|
||||
@@ -164,15 +165,15 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
)}
|
||||
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
|
||||
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
|
||||
{hasActivities !== false && (
|
||||
{hasActivities === true && (
|
||||
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
|
||||
)}
|
||||
{hasPlayers !== false && (
|
||||
{hasPlayers === true && (
|
||||
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
|
||||
)}
|
||||
{hasTables ? (
|
||||
{hasTables === true && (
|
||||
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
|
||||
) : null}
|
||||
)}
|
||||
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
|
||||
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
|
||||
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
|
||||
@@ -191,7 +192,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{hasArticles !== false && (
|
||||
{hasArticles === true && (
|
||||
<>
|
||||
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
||||
{Array.isArray(categories) && categories.length > 0 && (
|
||||
@@ -210,11 +211,11 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hasVideos !== false && (
|
||||
{hasVideos === true && (
|
||||
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
|
||||
)}
|
||||
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
|
||||
{hasGallery !== false && (
|
||||
{hasGallery === true && (
|
||||
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
|
||||
)}
|
||||
{settings?.shop_url && (
|
||||
@@ -258,6 +259,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const accountPath = isAdmin ? '/admin/nastaveni' : '/semiadmin';
|
||||
const { data: settings } = usePublicSettings();
|
||||
const theme = useClubTheme();
|
||||
const location = useLocation();
|
||||
@@ -324,16 +326,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
// Set favicon/logo in head for fan pages (SPA)
|
||||
useEffect(() => {
|
||||
try {
|
||||
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
if (!url) return;
|
||||
// Normalize relative upload paths to API origin so favicon resolves on all pages
|
||||
try {
|
||||
const apiOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
|
||||
// If starts with /uploads or any absolute path, prefix API origin
|
||||
url = apiOrigin + url;
|
||||
}
|
||||
} catch {}
|
||||
const raw = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
if (!raw) return;
|
||||
const url = assetUrl(raw) || raw;
|
||||
|
||||
const setIcon = (rel: string) => {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
@@ -544,11 +539,29 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
}));
|
||||
}, [navCategories]);
|
||||
|
||||
// Filter dynamic navigation items based on available data (only show when data exists)
|
||||
const filteredDynamicNavItems = useMemo(() => {
|
||||
const filterItem = (item: NavigationItem): NavigationItem | null => {
|
||||
const url = item.url || '';
|
||||
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
|
||||
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
|
||||
if (url.startsWith('/blog') && hasArticles !== true) return null;
|
||||
if (url.startsWith('/videa') && hasVideos !== true) return null;
|
||||
if (url.startsWith('/galerie') && hasGallery !== true) return null;
|
||||
if (item.type === 'dropdown' && Array.isArray(item.children)) {
|
||||
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
|
||||
return { ...item, children };
|
||||
}
|
||||
return item;
|
||||
};
|
||||
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
|
||||
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
|
||||
|
||||
// Use dynamic navigation if available, otherwise fallback to hardcoded
|
||||
let NAV_LINKS: NavLink[] = useMemo(() => {
|
||||
if (!navLoading && dynamicNavItems.length > 0) {
|
||||
if (!navLoading && filteredDynamicNavItems.length > 0) {
|
||||
// Use dynamic navigation from API
|
||||
const navLinks = dynamicNavItems.map(convertToNavLink);
|
||||
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
|
||||
|
||||
// Inject categories into "Články" or "Blog" navigation item if it exists
|
||||
if (categoryItems.length > 0) {
|
||||
@@ -567,7 +580,18 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
}
|
||||
}
|
||||
|
||||
return navLinks;
|
||||
// Ensure we only show sections when there is data
|
||||
const filtered = navLinks.filter((link) => {
|
||||
const to = link.to || '';
|
||||
if (to.startsWith('/aktivity')) return hasActivities === true;
|
||||
if (to.startsWith('/hraci')) return hasPlayers === true;
|
||||
if (to.startsWith('/blog')) return hasArticles === true;
|
||||
if (to.startsWith('/videa')) return hasVideos === true;
|
||||
if (to.startsWith('/galerie')) return hasGallery === true;
|
||||
return true;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Fallback to hardcoded navigation
|
||||
@@ -607,40 +631,40 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
links = links.filter((n) => n.label !== 'Tabulky');
|
||||
}
|
||||
|
||||
// Hide Aktivity when there are no activities
|
||||
if (hasActivities === false) {
|
||||
// Hide Aktivity unless there are activities
|
||||
if (hasActivities !== true) {
|
||||
links = links.filter((n) => n.label !== 'Aktivity');
|
||||
}
|
||||
|
||||
// Hide Hráči when there are no players
|
||||
if (hasPlayers === false) {
|
||||
// Hide Hráči unless there are players
|
||||
if (hasPlayers !== true) {
|
||||
links = links.filter((n) => n.label !== 'Hráči');
|
||||
}
|
||||
|
||||
// Hide Články when there are no articles
|
||||
if (hasArticles === false) {
|
||||
// Hide Články unless there are articles
|
||||
if (hasArticles !== true) {
|
||||
links = links.filter((n) => n.label !== 'Články');
|
||||
}
|
||||
|
||||
// Hide Videa when there are no videos
|
||||
if (hasVideos === false) {
|
||||
// Hide Videa unless there are videos
|
||||
if (hasVideos !== true) {
|
||||
links = links.filter((n) => n.label !== 'Videa');
|
||||
}
|
||||
|
||||
// Hide Fotogalerie when there is no gallery content
|
||||
if (hasGallery === false) {
|
||||
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
|
||||
// Hide Fotogalerie unless there is gallery content
|
||||
if (hasGallery !== true) {
|
||||
links = links.filter((n) => n.to !== '/galerie');
|
||||
}
|
||||
|
||||
return links;
|
||||
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
|
||||
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
|
||||
|
||||
return (
|
||||
<Box position="sticky" top={0} zIndex={1000}>
|
||||
{/* Top bar with socials and quick external links */}
|
||||
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
|
||||
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
|
||||
<Container maxW={containerMaxW}>
|
||||
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
|
||||
<Flex align="center" justify="space-between" gap={2}>
|
||||
<HStack spacing={2}>
|
||||
{settings?.shop_url && (
|
||||
@@ -674,15 +698,15 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
boxShadow={scrolled ? 'sm' : 'none'}
|
||||
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
|
||||
>
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
|
||||
<Container maxW={containerMaxW}>
|
||||
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
|
||||
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
|
||||
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||
<HStack spacing={4} alignItems="center">
|
||||
{/* Club Logo only */}
|
||||
<HStack as={RouterLink} to="/" spacing={3} align="center">
|
||||
{(settings?.club_logo_url || theme.logoUrl) && (
|
||||
<Image
|
||||
src={settings?.club_logo_url || theme.logoUrl}
|
||||
src={assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl}
|
||||
alt={settings?.club_name || theme.name || 'Logo'}
|
||||
boxSize={{ base: '36px', md: '40px' }}
|
||||
objectFit="contain"
|
||||
@@ -826,9 +850,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
||||
<Avatar size="sm" name={user?.name || 'Uživatel'} />
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
|
||||
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
|
||||
<MenuItem onClick={openMyNewsletterPrefs}>E‑mailové preference</MenuItem>
|
||||
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
|
||||
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
||||
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
||||
</MenuList>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
|
||||
import { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||
import {
|
||||
FaTachometerAlt,
|
||||
FaUsers,
|
||||
@@ -30,13 +30,15 @@ import {
|
||||
FaTshirt,
|
||||
FaBullhorn,
|
||||
FaUserShield,
|
||||
FaFileAlt
|
||||
FaFileAlt,
|
||||
FaLink
|
||||
} from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUpcomingEvents } from '../../services/eventService';
|
||||
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
interface NavItemProps {
|
||||
icon: any;
|
||||
@@ -146,6 +148,7 @@ const getIconForPageType = (pageType?: string): any => {
|
||||
settings: FaPalette,
|
||||
files: FaFolder,
|
||||
docs: FaBook,
|
||||
shortlinks: FaLink,
|
||||
};
|
||||
return iconMap[pageType || ''] || FaFileAlt;
|
||||
};
|
||||
@@ -175,6 +178,9 @@ const AdminSidebar = ({
|
||||
// Dynamic navigation state
|
||||
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
|
||||
const [navLoading, setNavLoading] = useState(true);
|
||||
const hasShortlinks = useMemo(() => {
|
||||
return navItems.some(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks'));
|
||||
}, [navItems]);
|
||||
|
||||
// Restore scroll on mount
|
||||
useEffect(() => {
|
||||
@@ -287,7 +293,7 @@ const AdminSidebar = ({
|
||||
<Box px={3} mb={8}>
|
||||
<Flex align="center" gap={3} mb={2}>
|
||||
<Image
|
||||
src={publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
|
||||
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
|
||||
alt="Club Logo"
|
||||
boxSize="48px"
|
||||
objectFit="contain"
|
||||
@@ -365,6 +371,16 @@ const AdminSidebar = ({
|
||||
>
|
||||
MyUIbrix Editor
|
||||
</NavItem>
|
||||
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
|
||||
{!hasShortlinks && (
|
||||
<NavItem
|
||||
icon={FaLink}
|
||||
to="/admin/shortlinks"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zkrácené odkazy
|
||||
</NavItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Fallback to hardcoded navigation
|
||||
@@ -592,6 +608,13 @@ const AdminSidebar = ({
|
||||
>
|
||||
Nastavení
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaLink}
|
||||
to="/admin/shortlinks"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zkrácené odkazy
|
||||
</NavItem>
|
||||
<NavItem
|
||||
icon={FaFolder}
|
||||
to="/admin/soubory"
|
||||
|
||||
@@ -44,7 +44,7 @@ interface PollLinkerProps {
|
||||
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
|
||||
const toast = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [selectedPollId, setSelectedPollId] = useState<string>('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
@@ -363,7 +363,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
||||
|
||||
<Divider />
|
||||
|
||||
<Tabs size="sm" variant="enclosed">
|
||||
<Tabs size="sm" variant="enclosed" defaultIndex={1}>
|
||||
<TabList>
|
||||
<Tab>Propojit existující</Tab>
|
||||
<Tab>Vytvořit novou</Tab>
|
||||
|
||||
@@ -76,6 +76,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const quillRef = useRef<ReactQuill | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const selectedImageIdRef = useRef<string | null>(null);
|
||||
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// Ensure component is mounted before rendering Quill
|
||||
@@ -192,6 +194,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
},
|
||||
}), [toolbarConfig, onImageUpload, handleImageUpload]);
|
||||
|
||||
// Localize Quill toolbar tooltips/labels to Czech
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const editor = quillRef.current?.getEditor();
|
||||
if (!editor) return;
|
||||
const container = editor.root?.parentElement; // .ql-container
|
||||
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
|
||||
if (!toolbarEl) return;
|
||||
|
||||
const setTitle = (selector: string, title: string) => {
|
||||
toolbarEl.querySelectorAll(selector).forEach((el) => {
|
||||
(el as HTMLElement).setAttribute('title', title);
|
||||
(el as HTMLElement).setAttribute('aria-label', title);
|
||||
});
|
||||
};
|
||||
|
||||
// Basic formatting
|
||||
setTitle('button.ql-bold', 'Tučné');
|
||||
setTitle('button.ql-italic', 'Kurzíva');
|
||||
setTitle('button.ql-underline', 'Podtržení');
|
||||
setTitle('button.ql-strike', 'Přeškrtnutí');
|
||||
setTitle('button.ql-link', 'Vložit odkaz');
|
||||
setTitle('button.ql-image', 'Vložit obrázek');
|
||||
setTitle('button.ql-blockquote', 'Citace');
|
||||
setTitle('button.ql-clean', 'Vyčistit formátování');
|
||||
|
||||
// Lists
|
||||
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
|
||||
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
|
||||
|
||||
// Alignment
|
||||
setTitle('button.ql-align', 'Zarovnání');
|
||||
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
|
||||
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
|
||||
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
|
||||
setTitle('button.ql-align[value="justify"]', 'Do bloku');
|
||||
|
||||
// Colors and background
|
||||
setTitle('.ql-color .ql-picker-label', 'Barva textu');
|
||||
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
|
||||
|
||||
// Headers
|
||||
setTitle('.ql-header .ql-picker-label', 'Nadpis');
|
||||
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
|
||||
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
|
||||
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
|
||||
}, [isMounted, toolbar]);
|
||||
|
||||
// Get cropped blob
|
||||
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -368,18 +418,40 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
const editorRect = editor.root.getBoundingClientRect();
|
||||
const scrollTop = editor.root.scrollTop;
|
||||
const scrollLeft = editor.root.scrollLeft;
|
||||
const sizeLabel = document.createElement('div');
|
||||
sizeLabel.style.cssText = `
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
right: 0;
|
||||
background: rgba(26,32,44,0.9);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
`;
|
||||
const updateSizeLabel = (w: number) => {
|
||||
try {
|
||||
const edW = editor.root.clientWidth || w || 1;
|
||||
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
|
||||
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`;
|
||||
} catch {
|
||||
sizeLabel.textContent = `${Math.round(w)} px`;
|
||||
}
|
||||
};
|
||||
|
||||
// Create edge handles (right, bottom, left, top)
|
||||
const handles = [
|
||||
{ position: 'right', cursor: 'ew-resize', width: '8px', height: '60%' },
|
||||
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '8px' },
|
||||
{ position: 'left', cursor: 'ew-resize', width: '8px', height: '60%' },
|
||||
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '8px' },
|
||||
// Corner handles
|
||||
{ position: 'bottom-right', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
|
||||
{ position: 'bottom-left', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
|
||||
{ position: 'top-right', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
|
||||
{ position: 'top-left', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
|
||||
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
|
||||
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
|
||||
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
|
||||
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
|
||||
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
|
||||
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||
{ position: 'top-left', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
|
||||
];
|
||||
|
||||
const updateHandlePositions = () => {
|
||||
@@ -467,7 +539,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
handle.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
@@ -477,26 +549,19 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
startWidth = img.offsetWidth;
|
||||
const startHeight = img.offsetHeight;
|
||||
const aspectRatio = startWidth / startHeight;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
if (!isResizing) return;
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
const deltaX = ev.clientX - startX;
|
||||
const deltaY = ev.clientY - startY;
|
||||
let newWidth = startWidth;
|
||||
|
||||
// Calculate new width based on handle position
|
||||
if (position.includes('right')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
} else if (position.includes('left')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
} else if (position.includes('bottom') || position.includes('top')) {
|
||||
// For vertical handles, maintain aspect ratio
|
||||
newWidth = startWidth + (deltaY * aspectRatio);
|
||||
}
|
||||
|
||||
// Constrain width
|
||||
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
|
||||
|
||||
img.style.width = `${newWidth}px`;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.height = 'auto';
|
||||
@@ -508,25 +573,28 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
|
||||
} catch {}
|
||||
updateHandlePositions();
|
||||
updateSizeLabel(newWidth);
|
||||
};
|
||||
|
||||
const onMouseUp: (ev: MouseEvent) => void = () => {
|
||||
const onPointerUp = () => {
|
||||
isResizing = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
const id = selectedImageIdRef.current;
|
||||
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
});
|
||||
|
||||
container.appendChild(handle);
|
||||
});
|
||||
|
||||
updateHandlePositions();
|
||||
updateSizeLabel(img.offsetWidth || img.width || 0);
|
||||
editor.root.style.position = 'relative';
|
||||
editor.root.appendChild(container);
|
||||
container.appendChild(sizeLabel);
|
||||
resizeHandle = container;
|
||||
|
||||
return container;
|
||||
@@ -547,6 +615,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
}
|
||||
|
||||
selectedImage = img;
|
||||
// Ensure image has a persistent ID for reselection after content updates
|
||||
let id = img.getAttribute('data-img-id') || '';
|
||||
if (!id) {
|
||||
id = 'img-' + Date.now() + '-' + Math.random().toString(36).slice(2);
|
||||
try { img.setAttribute('data-img-id', id); } catch {}
|
||||
}
|
||||
selectedImageIdRef.current = id;
|
||||
img.style.outline = '3px solid #3182ce';
|
||||
img.style.cursor = 'move';
|
||||
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
||||
@@ -622,6 +697,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
setShowImageToolbar(true);
|
||||
};
|
||||
|
||||
// Expose reselection helper bound to current effect scope
|
||||
selectImageByIdRef.current = (id: string) => {
|
||||
const ed = quillRef.current?.getEditor();
|
||||
if (!ed) return;
|
||||
const node = ed.root.querySelector(`img[data-img-id="${id}"]`) as HTMLImageElement | null;
|
||||
if (node) {
|
||||
selectImage(node);
|
||||
}
|
||||
};
|
||||
|
||||
const deselectImage = () => {
|
||||
if (selectedImage) {
|
||||
selectedImage.style.outline = '';
|
||||
@@ -965,6 +1050,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
// Force overlay reposition
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
reselectAfterContentUpdate();
|
||||
|
||||
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
|
||||
} catch (e: any) {
|
||||
@@ -998,10 +1084,20 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
// Force overlay reposition
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
reselectAfterContentUpdate();
|
||||
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
|
||||
}
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
// Reselect helper after content updates (e.g., when value change triggers rerender)
|
||||
const reselectAfterContentUpdate = useCallback(() => {
|
||||
const id = selectedImageIdRef.current;
|
||||
if (!id) return;
|
||||
setTimeout(() => {
|
||||
try { selectImageByIdRef.current?.(id); } catch {}
|
||||
}, 30);
|
||||
}, []);
|
||||
|
||||
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
|
||||
if (!selectedImageElement) return;
|
||||
const editor = quillRef.current?.getEditor();
|
||||
@@ -1017,6 +1113,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
// Keep selection active for subsequent operations (e.g., 50% → 75%)
|
||||
reselectAfterContentUpdate();
|
||||
if (!opts?.silent) {
|
||||
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
|
||||
}
|
||||
@@ -1036,6 +1134,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
||||
onChangeRef.current(editor.root.innerHTML);
|
||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||
}
|
||||
reselectAfterContentUpdate();
|
||||
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
|
||||
}, [selectedImageElement, toast]);
|
||||
|
||||
|
||||
@@ -3,6 +3,34 @@ import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
|
||||
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
||||
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
||||
import '../../styles/logos.css';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
// Lightweight cached overrides loader
|
||||
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
|
||||
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
|
||||
const now = Date.now();
|
||||
if (__teamOverridesCache && now - __teamOverridesCache.ts < 60_000) {
|
||||
return __teamOverridesCache.data || {};
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
__teamOverridesCache = { ts: now, data: json || {} };
|
||||
return json || {};
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
|
||||
if (res2.ok) {
|
||||
const json = await res2.json();
|
||||
__teamOverridesCache = { ts: now, data: json || {} };
|
||||
return json || {};
|
||||
}
|
||||
} catch {}
|
||||
__teamOverridesCache = { ts: now, data: {} };
|
||||
return {};
|
||||
};
|
||||
|
||||
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
|
||||
teamId?: string;
|
||||
@@ -32,6 +60,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -40,11 +69,30 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
const url = await getTeamLogo(teamId, teamName, facrLogo);
|
||||
|
||||
if (mounted) {
|
||||
setLogoUrl(url);
|
||||
// Load admin overrides (cached)
|
||||
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }> } = {};
|
||||
try { overrides = await loadTeamOverrides(); } catch {}
|
||||
// Prefer local club logo for own team when IDs match
|
||||
if (
|
||||
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
|
||||
) {
|
||||
if (mounted) {
|
||||
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
|
||||
}
|
||||
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
|
||||
const v = overrides.by_id[teamId]!.logo_url as string;
|
||||
if (mounted) {
|
||||
if (typeof v === 'string' && v.startsWith('/')) {
|
||||
setLogoUrl(assetUrl(v) || v);
|
||||
} else {
|
||||
setLogoUrl(v);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const url = await getTeamLogo(teamId, teamName, facrLogo);
|
||||
if (mounted) {
|
||||
setLogoUrl(url);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch logo:', e);
|
||||
@@ -65,7 +113,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [teamId, teamName, facrLogo]);
|
||||
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url]);
|
||||
|
||||
// Size mapping
|
||||
const sizeMap = {
|
||||
@@ -101,7 +149,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={logoUrl || '/logo192.png'}
|
||||
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
|
||||
alt={alt || teamName || 'Team logo'}
|
||||
{...sizeProps}
|
||||
{...imageProps}
|
||||
|
||||
@@ -50,6 +50,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
|
||||
borderRadius={borderRadius}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
@@ -70,6 +71,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
|
||||
maxH="400px"
|
||||
objectFit="contain"
|
||||
borderRadius="md"
|
||||
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</PopoverBody>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||
import { getCategories, Category } from '../../services/public';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
// Minimal NavLink type used to render items
|
||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||
@@ -121,7 +122,7 @@ const SpartaNavbar: React.FC = () => {
|
||||
return links;
|
||||
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
|
||||
|
||||
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
const logoUrl = (assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl) || '/dist/img/logo-club-empty.svg';
|
||||
const clubName = settings?.club_name || theme.name || 'Klub';
|
||||
|
||||
return (
|
||||
|
||||
@@ -133,7 +133,17 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
|
||||
return null;
|
||||
}
|
||||
|
||||
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
|
||||
const encodedQuery = encodeURIComponent(location.trim());
|
||||
// Build external map URLs – prefer coordinates when available, otherwise fallback to address search
|
||||
const openStreetMapUrl = coords
|
||||
? `https://www.openstreetmap.org/?mlat=${coords.lat}&mlon=${coords.lon}#map=17/${coords.lat}/${coords.lon}`
|
||||
: `https://www.openstreetmap.org/search?query=${encodedQuery}`;
|
||||
const googleMapsUrl = coords
|
||||
? `https://www.google.com/maps/search/?api=1&query=${coords.lat},${coords.lon}`
|
||||
: `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`;
|
||||
const mapyCzUrl = coords
|
||||
? `https://mapy.cz/zakladni?x=${coords.lon}&y=${coords.lat}&z=17`
|
||||
: `https://mapy.cz/zakladni?q=${encodedQuery}`;
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
|
||||
@@ -148,9 +158,13 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
|
||||
<AlertIcon />
|
||||
<Box>
|
||||
<Text mb={1}>{error}</Text>
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
||||
Otevřít v OpenStreetMap
|
||||
</Link>
|
||||
<Text>
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">Otevřít v OpenStreetMap</Link>
|
||||
{' · '}
|
||||
<Link href={googleMapsUrl} isExternal color="blue.400">Otevřít v Google Maps</Link>
|
||||
{' · '}
|
||||
<Link href={mapyCzUrl} isExternal color="blue.400">Otevřít v Mapy.cz</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -172,10 +186,12 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
|
||||
)}
|
||||
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Přesnost určena pomocí otevřených mapových dat.{' '}
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
||||
Zobrazit v OpenStreetMap
|
||||
</Link>
|
||||
Přesnost určena pomocí otevřených mapových dat. Zobrazit v{' '}
|
||||
<Link href={openStreetMapUrl} isExternal color="blue.400">OpenStreetMap</Link>
|
||||
{' · '}
|
||||
<Link href={googleMapsUrl} isExternal color="blue.400">Google Maps</Link>
|
||||
{' · '}
|
||||
<Link href={mapyCzUrl} isExternal color="blue.400">Mapy.cz</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
|
||||
import { Box, Flex, Heading, HStack, Button, Text } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { facrApi } from '../../services/facr/facrApi';
|
||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
|
||||
const ClubHeader: React.FC = () => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
@@ -18,7 +19,15 @@ const ClubHeader: React.FC = () => {
|
||||
return (
|
||||
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
|
||||
<HStack spacing={4}>
|
||||
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
|
||||
<TeamLogo
|
||||
teamId={clubId}
|
||||
teamName={data?.name}
|
||||
facrLogo={data?.logo_url || undefined}
|
||||
size="custom"
|
||||
boxSize="64px"
|
||||
alt={data?.name || 'Club'}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Box>
|
||||
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
|
||||
|
||||
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
||||
|
||||
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'brand', fullBleed = false }) => {
|
||||
const { data: settings } = usePublicSettings();
|
||||
const theme = useClubTheme();
|
||||
const title = settings?.club_name || theme.name || 'Fotbalový klub';
|
||||
const tagline = 'Oficiální web klubu';
|
||||
const logo = assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||
const shopUrl = settings?.shop_url || undefined;
|
||||
const calendarUrl = '/kalendar';
|
||||
|
||||
return (
|
||||
<div className={cls('club-hero-topbar', fullBleed && 'full-bleed',
|
||||
variant === 'brand' && 'club-hero-topbar--brand',
|
||||
variant === 'minimal' && 'club-hero-topbar--minimal',
|
||||
variant === 'badge' && 'club-hero-topbar--badge'
|
||||
)}>
|
||||
<div className="club-hero-topbar__logo">
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img src={logo} alt={title} style={{ width: 36, height: 36, objectFit: 'contain' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div className="club-hero-topbar__title">{title}</div>
|
||||
<div className="club-hero-topbar__tagline">{tagline}</div>
|
||||
</div>
|
||||
<div className="club-hero-topbar__spacer" />
|
||||
<div className="club-hero-topbar__actions">
|
||||
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
|
||||
{shopUrl && (
|
||||
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClubHeroTopbar;
|
||||
@@ -139,11 +139,19 @@ const CompetitionMatches: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
|
||||
<TabList>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" size="sm">
|
||||
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
|
||||
}}>
|
||||
{sortedCompetitions.map((c) => {
|
||||
const label = c.alias || c.name;
|
||||
return <Tab key={c.id}>{label}</Tab>;
|
||||
return (
|
||||
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
|
||||
<Text as="span" noOfLines={1} maxW="220px" title={label}>{label}</Text>
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
interface HeaderVariantsProps {
|
||||
variant: 'unified' | 'edge' | 'minimal' | 'modern';
|
||||
@@ -15,9 +16,9 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
|
||||
clubLogo,
|
||||
clubId,
|
||||
}) => {
|
||||
const displayLogo = clubId
|
||||
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
|
||||
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
||||
: clubLogo || '/images/club-logo.png';
|
||||
: '/images/club-logo.png');
|
||||
|
||||
// Unified variant - classic header
|
||||
if (variant === 'unified') {
|
||||
|
||||
@@ -99,10 +99,16 @@ const MatchesSection: React.FC = () => {
|
||||
)}
|
||||
{isLoading && <Skeleton height="200px" />}
|
||||
{!isLoading && data && (
|
||||
<Tabs variant="enclosed-colored" isFitted>
|
||||
<TabList>
|
||||
<Tabs variant="enclosed-colored" size="sm">
|
||||
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
|
||||
}}>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab key={c.id}>{c.name}</Tab>
|
||||
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
|
||||
<Text as="span" noOfLines={1} maxW="220px" title={c.name}>{c.name}</Text>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
|
||||
@@ -44,6 +44,13 @@ const TableSection: React.FC = () => {
|
||||
const rankTopText = useColorModeValue('green.800', 'white');
|
||||
const pointsBg = useColorModeValue('blue.600', 'blue.400');
|
||||
const pointsText = 'white';
|
||||
// TabList theming constants (hoisted to avoid hooks in loops/conditions)
|
||||
const tabListBg = useColorModeValue('white', 'gray.800');
|
||||
const tabListBorder = useColorModeValue('gray.200', 'gray.700');
|
||||
const tabSelectedBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const tabSelectedColor = useColorModeValue('blue.700', 'blue.200');
|
||||
const tabSelectedBorderColor = useColorModeValue('blue.200', 'blue.600');
|
||||
const tabColor = useColorModeValue('gray.800', 'gray.200');
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['facr-table', clubId, clubType],
|
||||
queryFn: () => facrApi.getClubTable(clubId, clubType),
|
||||
@@ -135,15 +142,33 @@ const TableSection: React.FC = () => {
|
||||
</HStack>
|
||||
)}
|
||||
{!isLoading && !isError && data && data.competitions?.length > 0 && (
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted>
|
||||
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Tabs variant="enclosed" colorScheme="blue" size="sm">
|
||||
<TabList
|
||||
bg={tabListBg}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={tabListBorder}
|
||||
px={2}
|
||||
pt={2}
|
||||
overflowX="auto"
|
||||
overflowY="hidden"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
|
||||
}}
|
||||
>
|
||||
{data.competitions?.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
|
||||
color={useColorModeValue('gray.800', 'gray.200')}
|
||||
_selected={{ bg: tabSelectedBg, color: tabSelectedColor, borderColor: tabSelectedBorderColor }}
|
||||
color={tabColor}
|
||||
flex="0 0 auto"
|
||||
px={3}
|
||||
py={2}
|
||||
fontSize="sm"
|
||||
>
|
||||
{c.name}
|
||||
<Text as="span" noOfLines={1} maxW="240px" title={c.name}>{c.name}</Text>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers, Player } from '../../services/players';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
const TeamScroller: React.FC = () => {
|
||||
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
||||
@@ -13,7 +14,7 @@ const TeamScroller: React.FC = () => {
|
||||
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
|
||||
{players.map((p: Player) => (
|
||||
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
||||
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
|
||||
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -16,6 +16,9 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
|
||||
const headerVariant = getVariant('header', 'unified');
|
||||
const sponsorsVariant = getVariant('sponsors', 'grid');
|
||||
const footerVariant = getVariant('footer', 'standard');
|
||||
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
@@ -39,10 +42,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
return (
|
||||
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
||||
<Box id="top" position="absolute" top={0} left={0} />
|
||||
{headerInsideContainer ? (
|
||||
{headerIsInside ? (
|
||||
<>
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
|
||||
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
@@ -51,14 +54,16 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
</Box>
|
||||
{children}
|
||||
</Container>
|
||||
<SponsorsSection />
|
||||
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
|
||||
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
|
||||
<Footer />
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
|
||||
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
|
||||
{headerVariant === 'sparta_navbar' ? (
|
||||
<SpartaNavbar />
|
||||
) : (
|
||||
@@ -69,8 +74,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
||||
{children}
|
||||
</Container>
|
||||
{/* Global sponsors section across front-facing pages */}
|
||||
<SponsorsSection />
|
||||
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
|
||||
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
|
||||
<SponsorsSection />
|
||||
</Box>
|
||||
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
|
||||
<Footer />
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export type ActivityItem = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
image_url?: string | null;
|
||||
start_time: string;
|
||||
location?: string | null;
|
||||
};
|
||||
|
||||
const ActivitiesList: React.FC<{
|
||||
items: ActivityItem[];
|
||||
}> = ({ items }) => {
|
||||
const list = Array.isArray(items) ? items.slice(0, 4) : [];
|
||||
return (
|
||||
<div className="blog-list">
|
||||
{list.map((e) => (
|
||||
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{e.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
|
||||
{new Date(e.start_time).toLocaleDateString()} {e.location ? `• ${e.location}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivitiesList;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import { sanitizeClubName } from '../../utils/url';
|
||||
|
||||
export type SliderMatch = {
|
||||
id?: string | number;
|
||||
date?: string;
|
||||
time?: string;
|
||||
home_id?: any;
|
||||
home?: string;
|
||||
home_logo_url?: string;
|
||||
away_id?: any;
|
||||
away?: string;
|
||||
away_logo_url?: string;
|
||||
venue?: string;
|
||||
score?: string;
|
||||
};
|
||||
|
||||
export type CompetitionBucket = { name: string; matches: SliderMatch[] };
|
||||
|
||||
const MatchesSlider: React.FC<{
|
||||
title?: string;
|
||||
comps: CompetitionBucket[];
|
||||
activeIndex: number;
|
||||
onActiveChange: (idx: number) => void;
|
||||
onMatchClick?: (m: SliderMatch, compName?: string) => void;
|
||||
elementProps?: any;
|
||||
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
|
||||
|
||||
// Auto-center closest match by current time when comp/tab changes
|
||||
useEffect(() => {
|
||||
try {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const items = Array.isArray(current?.matches) ? current!.matches : [];
|
||||
const now = Date.now();
|
||||
let best = 0;
|
||||
let bestDiff = Number.POSITIVE_INFINITY;
|
||||
items.forEach((m, idx) => {
|
||||
const iso = `${m.date || ''}T${(m.time || '00:00')}:00`;
|
||||
const t = new Date(iso).getTime();
|
||||
if (!isNaN(t)) {
|
||||
const d = Math.abs(t - now);
|
||||
if (d < bestDiff) { bestDiff = d; best = idx; }
|
||||
}
|
||||
});
|
||||
const child = el.children?.[best] as HTMLElement | undefined;
|
||||
if (!child) return;
|
||||
const run = () => {
|
||||
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||
};
|
||||
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(run); else setTimeout(run, 0);
|
||||
} catch {}
|
||||
}, [activeIndex, JSON.stringify(current?.matches)]);
|
||||
|
||||
return (
|
||||
<section className="matches-slider" {...(elementProps || {})}>
|
||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h3>{title}</h3>
|
||||
<a href="/kalendar" className="see-all">Všechny zápasy</a>
|
||||
</div>
|
||||
<div className="matches-grid">
|
||||
<div className="matches-track" ref={trackRef}>
|
||||
{(current?.matches || []).map((m, idx) => (
|
||||
<div
|
||||
key={m.id || idx}
|
||||
className="match-card"
|
||||
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
|
||||
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
|
||||
>
|
||||
<div className="match-meta">
|
||||
<span>{(m.venue || '').split(',')[0] || ''}</span>
|
||||
<span>•</span>
|
||||
<span>{m.date ? new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString() : (m.time || '')}</span>
|
||||
</div>
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
alt={m.home}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.home || '')}</div>
|
||||
</div>
|
||||
<div className="score">
|
||||
{m.score ? (
|
||||
<>
|
||||
<span className="home">{String(m.score).split(':')[0]}</span>
|
||||
<span className="sep">:</span>
|
||||
<span className="away">{String(m.score).split(':')[1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="time">{m.time}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
alt={m.away}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.away || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="matches-tabs">
|
||||
{comps.map((c, i) => (
|
||||
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchesSlider;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
|
||||
export type NewsListItem = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
image?: string;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
const NewsList: React.FC<{
|
||||
items: NewsListItem[];
|
||||
emptyText?: string;
|
||||
seeAllHref?: string;
|
||||
seeAllLabel?: string;
|
||||
}> = ({ items, emptyText = 'Zatím nejsou k dispozici žádné aktuality.', seeAllHref, seeAllLabel = 'Zobrazit všechny aktuality' }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="blog-list">
|
||||
{items && items.length > 0 ? (
|
||||
items.slice(0, 4).map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{n.title}</h4>
|
||||
{n.excerpt && (
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>{emptyText}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{seeAllHref && items && items.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href={seeAllHref}>{seeAllLabel}</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsList;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import { TeamLogo } from '../common/TeamLogo';
|
||||
import { sanitizeClubName } from '../../utils/url';
|
||||
|
||||
export type NextMatchData = {
|
||||
competition?: string;
|
||||
home_id?: any;
|
||||
home?: string;
|
||||
home_logo_url?: string;
|
||||
away_id?: any;
|
||||
away?: string;
|
||||
away_logo_url?: string;
|
||||
};
|
||||
|
||||
const NextMatch: React.FC<{
|
||||
data: NextMatchData | null;
|
||||
competitionName?: string;
|
||||
countdown?: string;
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
onOpen?: () => void;
|
||||
elementProps?: any;
|
||||
}> = ({ data, competitionName, countdown, onPrev, onNext, onOpen, elementProps }) => {
|
||||
const show = data;
|
||||
return (
|
||||
<section
|
||||
className="next-match"
|
||||
{...(elementProps as any)}
|
||||
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
|
||||
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
|
||||
>
|
||||
{onPrev && (
|
||||
<button
|
||||
aria-label="Předchozí soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
|
||||
className="nav prev"
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronLeft size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.home_id}
|
||||
teamName={show?.home}
|
||||
facrLogo={show?.home_logo_url}
|
||||
size="custom"
|
||||
alt="Domácí"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.home || '')}</div>
|
||||
</div>
|
||||
|
||||
<div className="countdown">
|
||||
{competitionName && (
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{competitionName}</div>
|
||||
)}
|
||||
{countdown || '—'}
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
||||
</div>
|
||||
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.away_id}
|
||||
teamName={show?.away}
|
||||
facrLogo={show?.away_logo_url}
|
||||
size="custom"
|
||||
alt="Hosté"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.away || '')}</div>
|
||||
</div>
|
||||
|
||||
{onNext && (
|
||||
<button
|
||||
aria-label="Další soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
|
||||
className="nav next"
|
||||
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronRight size={24} />
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextMatch;
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface StandingRow {
|
||||
position?: number;
|
||||
pos?: number;
|
||||
rank?: number;
|
||||
team?: any;
|
||||
team_id?: string | number;
|
||||
team_logo_url?: string;
|
||||
club?: string;
|
||||
points?: number | string;
|
||||
pts?: number | string;
|
||||
played?: number | string;
|
||||
matches?: number | string;
|
||||
wins?: number | string;
|
||||
win?: number | string;
|
||||
draws?: number | string;
|
||||
draw?: number | string;
|
||||
losses?: number | string;
|
||||
loss?: number | string;
|
||||
score?: string;
|
||||
}
|
||||
|
||||
const StandingsCard: React.FC<{ rows: StandingRow[]; onRowClick?: (row: StandingRow, index: number) => void }>= ({ rows, onRowClick }) => {
|
||||
const safe = Array.isArray(rows) ? rows : [];
|
||||
return (
|
||||
<div className="table-card">
|
||||
<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>
|
||||
{safe.slice(0, 8).map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
onClick={() => onRowClick?.(row, idx)}
|
||||
style={{
|
||||
cursor: onRowClick ? 'pointer' : 'default',
|
||||
background: 'var(--card-bg)',
|
||||
border: '1px solid var(--card-border)',
|
||||
borderRadius: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLTableRowElement).style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLTableRowElement).style.boxShadow = 'none';
|
||||
(e.currentTarget as HTMLTableRowElement).style.borderColor = 'var(--card-border)';
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--secondary)' }}>{row.position ?? row.pos ?? row.rank ?? idx + 1}</td>
|
||||
<td style={{ padding: '10px 8px', fontWeight: 600 }}>{(row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).played ?? (row as any).matches ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).wins ?? (row as any).win ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).draws ?? (row as any).draw ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center' }}>{(row as any).losses ?? (row as any).loss ?? '-'}</td>
|
||||
<td style={{ padding: '10px 4px', textAlign: 'center', display: 'none' }} className="hide-mobile">{(row as any).score ?? '-'}</td>
|
||||
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 800 }}>{(row as any).points ?? (row as any).pts ?? '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StandingsCard;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolls, getPoll } from '../../services/polls';
|
||||
@@ -17,6 +18,7 @@ interface EmbeddedPollProps {
|
||||
videoUrl?: string;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
maxPolls?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
||||
videoUrl,
|
||||
title = 'Hlasování',
|
||||
showTitle = true,
|
||||
maxPolls,
|
||||
}) => {
|
||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||
|
||||
@@ -46,16 +49,31 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
||||
staleTime: 2 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Get full poll data for each
|
||||
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
|
||||
// Get full poll data for each (all linked polls)
|
||||
const pollsToDisplay = polls || [];
|
||||
|
||||
const preSortedLimited = React.useMemo(() => {
|
||||
const sorted = [...pollsToDisplay].sort((a, b) => {
|
||||
const aRating = a.type === 'rating' ? 1 : 0;
|
||||
const bRating = b.type === 'rating' ? 1 : 0;
|
||||
if (aRating !== bRating) return bRating - aRating;
|
||||
const aFeat = a.featured ? 1 : 0;
|
||||
const bFeat = b.featured ? 1 : 0;
|
||||
if (aFeat !== bFeat) return bFeat - aFeat;
|
||||
const aDate = new Date(a.created_at).getTime();
|
||||
const bDate = new Date(b.created_at).getTime();
|
||||
return bDate - aDate;
|
||||
});
|
||||
return typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
|
||||
}, [pollsToDisplay, maxPolls]);
|
||||
|
||||
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
|
||||
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
|
||||
queryKey: ['embedded-polls-details', preSortedLimited.map((p) => p.id)],
|
||||
queryFn: async () => {
|
||||
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
|
||||
const promises = preSortedLimited.map((poll) => getPoll(poll.id));
|
||||
return await Promise.all(promises);
|
||||
},
|
||||
enabled: pollsToDisplay.length > 0,
|
||||
enabled: preSortedLimited.length > 0,
|
||||
});
|
||||
|
||||
// Don't render anything if no content identifier provided
|
||||
@@ -84,35 +102,83 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
||||
|
||||
return (
|
||||
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
|
||||
<VStack spacing={6} maxW="3xl" mx="auto">
|
||||
<VStack spacing={6} maxW="6xl" mx="auto">
|
||||
{showTitle && (
|
||||
<Heading size="md" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
|
||||
<VStack spacing={4} w="full">
|
||||
{isLoadingPolls ? (
|
||||
<VStack py={8}>
|
||||
<Spinner />
|
||||
<Text>Načítání...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
pollsData.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id} w="full">
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</VStack>
|
||||
{isLoadingPolls ? (
|
||||
<VStack py={8}>
|
||||
<Spinner />
|
||||
<Text>Načítání...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
(() => {
|
||||
// Sort: rating first, then featured, then newest
|
||||
const sorted = [...(pollsData || [])].sort((a, b) => {
|
||||
const aRating = a.poll.type === 'rating' ? 1 : 0;
|
||||
const bRating = b.poll.type === 'rating' ? 1 : 0;
|
||||
if (aRating !== bRating) return bRating - aRating;
|
||||
const aFeat = a.poll.featured ? 1 : 0;
|
||||
const bFeat = b.poll.featured ? 1 : 0;
|
||||
if (aFeat !== bFeat) return bFeat - aFeat;
|
||||
const aDate = new Date(a.poll.created_at).getTime();
|
||||
const bDate = new Date(b.poll.created_at).getTime();
|
||||
return bDate - aDate;
|
||||
});
|
||||
const limited = typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
|
||||
const count = limited.length;
|
||||
if (count === 1) {
|
||||
const pollResponse = limited[0];
|
||||
return (
|
||||
<Box w="full">
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (count === 2) {
|
||||
return (
|
||||
<SimpleGrid w="full" columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{limited.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id}>
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SimpleGrid w="full" columns={{ base: 1, sm: 2, lg: 3 }} spacing={4}>
|
||||
{limited.map((pollResponse) => (
|
||||
<Box key={pollResponse.poll.id}>
|
||||
<PollCard
|
||||
poll={pollResponse.poll}
|
||||
hasVoted={pollResponse.has_voted}
|
||||
isActive={pollResponse.is_active}
|
||||
canShowResults={pollResponse.can_show_results}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmbeddedPoll;
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
FormLabel,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
|
||||
import {
|
||||
Poll,
|
||||
PollOption,
|
||||
PollResultsResponse,
|
||||
votePoll,
|
||||
getPollResults,
|
||||
generateSessionToken,
|
||||
@@ -87,6 +88,16 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Live results polling when results are visible and allowed
|
||||
const showLiveResults = canShowResults && showingResults;
|
||||
const { data: liveResultsData } = useQuery<PollResultsResponse>({
|
||||
queryKey: ['poll-results', poll.id],
|
||||
queryFn: () => getPollResults(poll.id),
|
||||
enabled: showLiveResults,
|
||||
refetchInterval: 4000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
// Vote mutation
|
||||
const voteMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -113,6 +124,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['polls'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['poll-results', poll.id] });
|
||||
|
||||
toast({
|
||||
title: 'Hlas zaznamenán!',
|
||||
@@ -227,13 +239,14 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
|
||||
// Show results if available
|
||||
if (showingResults && canShowResults) {
|
||||
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
|
||||
const totalVotesToShow = liveResultsData?.total_votes ?? poll.total_votes;
|
||||
const displayResults = liveResultsData?.results || (results.length > 0 ? results : poll.options.map(opt => ({
|
||||
option_id: opt.id,
|
||||
text: opt.text,
|
||||
vote_count: opt.vote_count,
|
||||
percentage: calculatePercentage(opt.vote_count),
|
||||
percentage: totalVotesToShow ? (opt.vote_count / totalVotesToShow) * 100 : 0,
|
||||
image_url: opt.image_url,
|
||||
}));
|
||||
})));
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -275,7 +288,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
|
||||
<VStack spacing={3} align="stretch">
|
||||
<Text fontWeight="bold" fontSize="sm" color="gray.500">
|
||||
Výsledky ({poll.total_votes} hlasů)
|
||||
Výsledky ({totalVotesToShow} hlasů)
|
||||
</Text>
|
||||
{displayResults.map((result) => (
|
||||
<Box key={result.option_id}>
|
||||
@@ -569,7 +582,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
||||
)}
|
||||
|
||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
||||
Celkem hlasů: {poll.total_votes}
|
||||
Celkem hlasů: {liveResultsData?.total_votes ?? poll.total_votes}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -83,6 +83,7 @@ export const MatchesWidget = () => {
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
const getLogo = (teamName?: string, original?: string) => {
|
||||
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
||||
const norm = (s: string) => String(s || '')
|
||||
@@ -147,12 +148,14 @@ export const MatchesWidget = () => {
|
||||
id: m.match_id,
|
||||
date_time: m.date_time || m.date,
|
||||
competitionName: m.competitionName,
|
||||
home: m.home || m.home_team,
|
||||
away: m.away || m.away_team,
|
||||
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
|
||||
away: (m.away_id && byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : (m.away || m.away_team),
|
||||
score: m.score,
|
||||
venue: m.venue,
|
||||
home_logo_url: getLogo(m.home || m.home_team, m.home_logo_url),
|
||||
away_logo_url: getLogo(m.away || m.away_team, m.away_logo_url),
|
||||
home_logo_url: (m.home_id && byId?.[m.home_id]?.logo_url) ? String(byId[m.home_id].logo_url) : getLogo(m.home || m.home_team, m.home_logo_url),
|
||||
away_logo_url: (m.away_id && byId?.[m.away_id]?.logo_url) ? String(byId[m.away_id].logo_url) : getLogo(m.away || m.away_team, m.away_logo_url),
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
})) as Match[];
|
||||
|
||||
return upcoming;
|
||||
|
||||
@@ -134,8 +134,8 @@ export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ chil
|
||||
textOnAccent = pickTextColor(accent!);
|
||||
}
|
||||
|
||||
// Prefer logo from logoapi.sportcreative.eu when club ID is known
|
||||
if (clubId) {
|
||||
// Prefer local logo from settings; only fetch from logoapi when no explicit local logo is configured
|
||||
if (!explicitLogo && clubId) {
|
||||
try {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
|
||||
if (apiLogo) {
|
||||
|
||||
@@ -6,7 +6,9 @@ import { PageElementConfig } from '../services/pageElements';
|
||||
// Elements that are actually implemented on HomePage
|
||||
// Only these should be available in the editor
|
||||
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||
'style-pack', // Global style pack selector
|
||||
'header', // Site navigation/header
|
||||
'hero-topbar', // Club bar above hero
|
||||
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
||||
'news', // Featured news articles
|
||||
'matches', // Upcoming/recent matches
|
||||
@@ -23,6 +25,14 @@ export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||
];
|
||||
|
||||
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'style-pack',
|
||||
variant: 'default',
|
||||
visible: true,
|
||||
display_order: -1,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'header',
|
||||
@@ -31,6 +41,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||
display_order: 0,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'hero-topbar',
|
||||
variant: 'brand',
|
||||
visible: true,
|
||||
display_order: 1,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
page_type: 'homepage',
|
||||
element_name: 'hero',
|
||||
|
||||
@@ -23,6 +23,7 @@ import { getCategories, CategoryItem } from '../services/categories';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
import { assetUrl } from '../utils/url';
|
||||
|
||||
type AboutPageData = {
|
||||
title: string;
|
||||
@@ -155,7 +156,7 @@ const AboutPage: React.FC = () => {
|
||||
<Helmet>
|
||||
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
|
||||
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
|
||||
{settings?.club_logo_url && <meta property="og:image" content={settings.club_logo_url} />}
|
||||
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
|
||||
</Helmet>
|
||||
<Container maxW="container.lg" py={8}>
|
||||
<Box textAlign="center" py={6}>
|
||||
@@ -177,7 +178,7 @@ const AboutPage: React.FC = () => {
|
||||
const seoTitle = data.seo_title || data.title;
|
||||
const seoDesc = data.seo_description || data.subtitle;
|
||||
const clubName = settings?.club_name || data.title;
|
||||
const clubLogo = settings?.club_logo_url;
|
||||
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
|
||||
const cleanContent = DOMPurify.sanitize(data.content);
|
||||
|
||||
const renderContent = () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
|
||||
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { trackEvent as umamiTrackEvent } from '../utils/umami';
|
||||
import EventLocationMap from '../components/events/EventLocationMap';
|
||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
@@ -35,6 +36,24 @@ const ActivityDetailPage: React.FC = () => {
|
||||
const typeLabel = (t?: string) => t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
|
||||
const typeColor = (t?: string) => t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray';
|
||||
|
||||
// Delegate click tracking for links inside activity description
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const a = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
|
||||
if (a && a.href) {
|
||||
const href = a.getAttribute('href') || a.href;
|
||||
try { umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'activity_content' }); } catch {}
|
||||
}
|
||||
};
|
||||
el.addEventListener('click', handler);
|
||||
return () => { el.removeEventListener('click', handler); };
|
||||
}, [contentRef.current]);
|
||||
|
||||
// Extract YouTube video ID from various URL formats
|
||||
const getYouTubeEmbedUrl = (url: string): string | null => {
|
||||
if (!url) return null;
|
||||
@@ -92,7 +111,14 @@ const ActivityDetailPage: React.FC = () => {
|
||||
<VStack align="stretch" spacing={5}>
|
||||
{data.image_url && (
|
||||
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
|
||||
<Image
|
||||
src={assetUrl(data.image_url) || data.image_url}
|
||||
alt={data.title}
|
||||
w="100%"
|
||||
maxH="420px"
|
||||
objectFit="cover"
|
||||
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -132,6 +158,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
||||
' img': { maxWidth: '100%', borderRadius: 'md' },
|
||||
}}
|
||||
ref={contentRef}
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
|
||||
/>
|
||||
)}
|
||||
@@ -161,7 +188,7 @@ const ActivityDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{data?.id && (
|
||||
<EmbeddedPoll eventId={data.id} />
|
||||
<EmbeddedPoll eventId={data.id} maxPolls={2} />
|
||||
)}
|
||||
|
||||
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio } from '@chakra-ui/react';
|
||||
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
|
||||
@@ -13,6 +13,11 @@ import React from 'react';
|
||||
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
import TeamLogo from '../components/common/TeamLogo';
|
||||
import { extractPalette } from '../utils/colors';
|
||||
import { getTeamLogo } from '../utils/sportLogosAPI';
|
||||
import FilePreview from '../components/common/FilePreview';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
|
||||
const toText = (html?: string) => {
|
||||
if (!html) return '';
|
||||
@@ -29,6 +34,22 @@ const ArticleDetailPage: React.FC = () => {
|
||||
enabled: Boolean(slug || id),
|
||||
});
|
||||
|
||||
|
||||
|
||||
// UI colors and public settings
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const cardBg = useColorModeValue('white','gray.900');
|
||||
const videoBg = useColorModeValue('gray.50','gray.800');
|
||||
const textMuted = useColorModeValue('gray.600','gray.400');
|
||||
// Hoist all color mode values to top-level to avoid conditional hook calls
|
||||
const videoTitleColor = useColorModeValue('gray.700','gray.300');
|
||||
const galleryBg = useColorModeValue('blue.50','blue.900');
|
||||
const galleryBorder = useColorModeValue('blue.200','blue.700');
|
||||
const attachmentsBg = useColorModeValue('gray.50','gray.800');
|
||||
|
||||
// Derive opponent color (for right edge fade) from team logo
|
||||
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
|
||||
|
||||
// Placeholders; moved tracking effects below to avoid using variables before declaration
|
||||
|
||||
// Track article view when data is loaded
|
||||
@@ -40,6 +61,27 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Delegated click tracking for normal links inside content
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
let target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
// Find nearest anchor
|
||||
const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
|
||||
if (anchor && anchor.href) {
|
||||
try {
|
||||
const href = anchor.getAttribute('href') || anchor.href;
|
||||
umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' });
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
el.addEventListener('click', handler);
|
||||
return () => { el.removeEventListener('click', handler); };
|
||||
}, [contentRef.current]);
|
||||
|
||||
// Fetch linked match (public)
|
||||
const matchLinkQuery = useQuery({
|
||||
queryKey: ['article-match-link', (data as any)?.id],
|
||||
@@ -81,10 +123,10 @@ const ArticleDetailPage: React.FC = () => {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Fetch gallery album if article has one
|
||||
// Fetch gallery album if article has one (fallback to URL when ID is missing)
|
||||
const galleryAlbumQuery = useQuery({
|
||||
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id],
|
||||
enabled: Boolean((data as any)?.gallery_album_id),
|
||||
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url],
|
||||
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
|
||||
queryFn: async () => {
|
||||
const albumId = (data as any)?.gallery_album_id;
|
||||
let photoIds: string[] = [];
|
||||
@@ -139,6 +181,24 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load by URL via proxy endpoint
|
||||
const albumUrl = (data as any)?.gallery_album_url;
|
||||
if (albumUrl) {
|
||||
const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' });
|
||||
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
|
||||
if (resp.ok) {
|
||||
const payload = await resp.json();
|
||||
let photos: any[] = [];
|
||||
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
|
||||
photos = payload.albums[0]?.photos || [];
|
||||
} else if (Array.isArray(payload?.photos)) {
|
||||
photos = payload.photos;
|
||||
}
|
||||
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
|
||||
return { id: albumUrl, title: 'Album', date: '', photos } as any;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
staleTime: 60_000,
|
||||
@@ -152,15 +212,6 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
||||
|
||||
const title = (data as any).seo_title || data.title;
|
||||
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
|
||||
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
||||
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
|
||||
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
|
||||
// Memoize the transformation function to prevent infinite loops
|
||||
const toAbsoluteUploads = React.useCallback((html?: string) => {
|
||||
@@ -177,13 +228,27 @@ const ArticleDetailPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const safeContentHTML = React.useMemo(() => {
|
||||
const transformed = toAbsoluteUploads(data.content);
|
||||
const transformed = toAbsoluteUploads((data as any)?.content);
|
||||
return DOMPurify.sanitize(transformed || '', {
|
||||
USE_PROFILES: { html: true },
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
|
||||
});
|
||||
}, [data.content, toAbsoluteUploads]);
|
||||
}, [(data as any)?.content, toAbsoluteUploads]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
|
||||
|
||||
const title = (data as any).seo_title || data.title;
|
||||
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
|
||||
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
|
||||
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
|
||||
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
|
||||
const publishedAt = (data as any).published_at || (data as any).created_at;
|
||||
const monthParam = (() => {
|
||||
if (!publishedAt) return '';
|
||||
try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; }
|
||||
})();
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
@@ -243,201 +308,238 @@ const ArticleDetailPage: React.FC = () => {
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={4}>
|
||||
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={2}>
|
||||
<Container maxW="7xl">
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
|
||||
<HStack spacing={4} fontSize="sm" color="gray.600">
|
||||
{((data as any).read_time || (data as any).estimated_read_minutes) && (
|
||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
|
||||
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
|
||||
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
|
||||
<HStack spacing={1}>
|
||||
<Clock size={16} />
|
||||
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
{publishedAt && (
|
||||
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
|
||||
)}
|
||||
{(data as any).view_count !== undefined && (data as any).view_count > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{(data as any)?.category?.id && (
|
||||
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
|
||||
)}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
|
||||
)}
|
||||
{(data as any).view_count ? (
|
||||
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
|
||||
<Eye size={16} />
|
||||
<Text>{(data as any).view_count} zobrazení</Text>
|
||||
</HStack>
|
||||
)}
|
||||
) : null}
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
<Container maxW="7xl">
|
||||
<Stack spacing={6}>
|
||||
{/* Featured Image - Top */}
|
||||
{/* Featured Image - smaller with subtle overlay */}
|
||||
{data.image_url && (
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} borderRadius="lg" />
|
||||
<Box position="relative" borderRadius="xl" overflow="hidden">
|
||||
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
|
||||
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
|
||||
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* YouTube Video Section - If attached to article */}
|
||||
{/* YouTube Video Section - smaller and rounded */}
|
||||
{(data as any)?.youtube_video_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
|
||||
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box
|
||||
as="iframe"
|
||||
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={(data as any).youtube_video_title || 'YouTube video'}
|
||||
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
||||
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
{ (data as any).youtube_video_title ? (
|
||||
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
|
||||
) : null }
|
||||
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Box
|
||||
as="iframe"
|
||||
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
title={(data as any).youtube_video_title || 'YouTube video'}
|
||||
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
||||
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
{(data as any).youtube_video_title ? (
|
||||
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Match Section - After Image */}
|
||||
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
||||
<HStack justify="space-between" align="start">
|
||||
<Box>
|
||||
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
|
||||
{facrMatchQuery.isLoading ? (
|
||||
<Text color="gray.600">Načítám údaje o zápasu…</Text>
|
||||
) : facrMatchQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} wrap="wrap">
|
||||
{facrMatchQuery.data.competitionName && (
|
||||
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
||||
)}
|
||||
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
|
||||
</HStack>
|
||||
<Text mt={2} fontWeight="600">
|
||||
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
|
||||
{' '}
|
||||
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
|
||||
{' '}
|
||||
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
|
||||
</Text>
|
||||
{facrMatchQuery.data.venue && (
|
||||
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
|
||||
)}
|
||||
{facrMatchQuery.data.report_url && (
|
||||
<Box mt={2}>
|
||||
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
|
||||
{/* Edge fades */}
|
||||
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
|
||||
{opponentColor && (
|
||||
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
|
||||
)}
|
||||
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
|
||||
{facrMatchQuery.isLoading ? (
|
||||
<Text color={textMuted}>Načítám údaje o zápasu…</Text>
|
||||
) : facrMatchQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} wrap="wrap" mb={3}>
|
||||
{facrMatchQuery.data.competitionName && (
|
||||
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
||||
)}
|
||||
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
|
||||
</HStack>
|
||||
<Flex align="center" justify="space-between" gap={4}>
|
||||
<VStack flex={1} spacing={2} minW="0">
|
||||
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
|
||||
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
|
||||
</VStack>
|
||||
<VStack minW={{ base: '100px', md: '140px' }}>
|
||||
{(() => {
|
||||
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
||||
const d = new Date(dRaw);
|
||||
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
|
||||
if (hasScore) {
|
||||
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
|
||||
return (<Heading size="2xl">{score}</Heading>);
|
||||
}
|
||||
const now = Date.now();
|
||||
const ms = d.getTime() - now;
|
||||
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
|
||||
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
|
||||
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
|
||||
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
|
||||
})()}
|
||||
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
|
||||
{(() => {
|
||||
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
||||
const d = new Date(dRaw);
|
||||
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
|
||||
})()}
|
||||
</VStack>
|
||||
<VStack flex={1} spacing={2} minW="0">
|
||||
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
|
||||
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
{(facrMatchQuery.data as any).report_url && (
|
||||
<Box mt={3}>
|
||||
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Article Content - Main Section */}
|
||||
{/* Article Content - Main Section with editor-like lists */}
|
||||
<Box
|
||||
className="article-content"
|
||||
bg="white"
|
||||
bg={useColorModeValue('white','gray.900')}
|
||||
borderRadius="lg"
|
||||
p={{ base: 4, md: 6 }}
|
||||
ref={contentRef}
|
||||
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
|
||||
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
||||
/>
|
||||
|
||||
{/* Gallery Section - At the End */}
|
||||
{(data as any)?.gallery_album_id && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
|
||||
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
|
||||
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
|
||||
<Box mb={3}>
|
||||
<HStack justify="space-between" align="center" mb={2}>
|
||||
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
||||
to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
rightIcon={<ArrowRight size={16} />}
|
||||
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
|
||||
>
|
||||
Zobrazit celé album
|
||||
Zobrazit galerii
|
||||
</Button>
|
||||
</HStack>
|
||||
{galleryAlbumQuery.isLoading ? (
|
||||
<Text color="gray.600">Načítám fotografie…</Text>
|
||||
) : galleryAlbumQuery.data ? (
|
||||
<>
|
||||
<HStack spacing={2} mb={3}>
|
||||
<Badge colorScheme="purple">
|
||||
{galleryAlbumQuery.data.title}
|
||||
</Badge>
|
||||
{galleryAlbumQuery.data.date && (
|
||||
<Badge>{galleryAlbumQuery.data.date}</Badge>
|
||||
)}
|
||||
<Badge colorScheme="blue">
|
||||
{galleryAlbumQuery.data.photos?.length || 0} foto
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={2}>
|
||||
{galleryAlbumQuery.data.photos.slice(0, 12).map((photo: any) => (
|
||||
<Box
|
||||
key={photo.id}
|
||||
as={RouterLink}
|
||||
to={`/galerie/album/${(data as any).gallery_album_id}`}
|
||||
position="relative"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'lg' }}
|
||||
onClick={() => umamiTrackEvent('Gallery Photo Click', { album_id: (data as any).gallery_album_id, photo_id: photo.id })}
|
||||
>
|
||||
<Image
|
||||
src={photo.image_1500}
|
||||
alt={`Fotka ${photo.id}`}
|
||||
w="100%"
|
||||
h="120px"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
{/* Custom 5-image mosaic */}
|
||||
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
|
||||
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
|
||||
if (photos.length < 5) {
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
|
||||
{photos.map((p: any) => (
|
||||
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box position="relative" sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1.2fr 1fr',
|
||||
gridTemplateRows: 'repeat(2, 140px)',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
|
||||
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Zonerama Attribution */}
|
||||
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
||||
<Text>📸 Fotografie z</Text>
|
||||
<Link
|
||||
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
||||
isExternal
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
Zonerama
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</HStack>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
|
||||
)}
|
||||
{/* Zonerama Attribution */}
|
||||
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
||||
<Text>📸 Fotografie z</Text>
|
||||
<Link
|
||||
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
||||
isExternal
|
||||
fontWeight="600"
|
||||
color="blue.600"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
Zonerama
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Embedded Poll - directly under content/gallery */}
|
||||
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Embedded Poll - shows polls related to this article */}
|
||||
{data?.id && <EmbeddedPoll articleId={data.id} />}
|
||||
{/* Attachments - bottom above CTA */}
|
||||
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||
<Container maxW="7xl" mt={4}>
|
||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
||||
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
|
||||
<Stack spacing={2}>
|
||||
{(data as any).attachments.map((f: any, idx: number) => (
|
||||
<HStack key={idx} justify="space-between">
|
||||
<Text noOfLines={1}>{f.name || f.url}</Text>
|
||||
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
|
||||
</HStack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Newsletter CTA */}
|
||||
<NewsletterCTA />
|
||||
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
{/* Sponsors Section */}
|
||||
<SponsorsSection />
|
||||
</MainLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetailPage;
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { getArticles, Article, Paginated } from '../services/articles';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
import { getCategories, CategoryItem } from '../services/categories';
|
||||
@@ -92,7 +92,14 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
|
||||
const BlogPage: React.FC = () => {
|
||||
const pageSize = 18;
|
||||
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
|
||||
const [categoryId, setCategoryId] = React.useState<number | ''>('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialCategory = React.useMemo(() => {
|
||||
const cid = searchParams.get('category_id');
|
||||
return cid ? Number(cid) : '';
|
||||
}, []);
|
||||
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
|
||||
const month = searchParams.get('month') || '';
|
||||
const matchId = searchParams.get('match_id') || '';
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
@@ -111,13 +118,15 @@ const BlogPage: React.FC = () => {
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useInfiniteQuery<Paginated<Article>>(
|
||||
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined }],
|
||||
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }],
|
||||
({ pageParam = 1 }) =>
|
||||
getArticles({
|
||||
page: pageParam,
|
||||
page_size: pageSize,
|
||||
published: true,
|
||||
...(categoryId ? { category_id: Number(categoryId) } : {}),
|
||||
...(month ? { month } : {}),
|
||||
...(matchId ? { match_id: matchId } : {}),
|
||||
}),
|
||||
{
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
@@ -159,7 +168,15 @@ const BlogPage: React.FC = () => {
|
||||
maxW={{ base: '52%', md: '320px' }}
|
||||
placeholder="Všechny kategorie"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value ? Number(e.target.value) : '';
|
||||
setCategoryId(val);
|
||||
const next: Record<string, string> = {};
|
||||
if (val) next.category_id = String(val);
|
||||
if (month) next.month = month;
|
||||
if (matchId) next.match_id = matchId;
|
||||
setSearchParams(next);
|
||||
}}
|
||||
>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { sortCategoriesWithOrder } from '../utils/categorySort';
|
||||
import ClubModal from '../components/home/ClubModal';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
|
||||
// Weekday headers (Czech, starting Monday)
|
||||
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||
@@ -226,9 +227,16 @@ const CalendarPage: React.FC = () => {
|
||||
} catch {}
|
||||
}
|
||||
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
|
||||
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
|
||||
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
|
||||
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
||||
const getOverrideLogo = (teamName?: string, original?: string) => {
|
||||
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
|
||||
// Prefer admin override by ID
|
||||
if (teamId && byId?.[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url as string;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
if (!teamName) return original;
|
||||
const exact = (byName || {})[teamName];
|
||||
const normName = normalize(teamName);
|
||||
@@ -260,17 +268,19 @@ const CalendarPage: React.FC = () => {
|
||||
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||
const time = (t || '00:00').slice(0,5);
|
||||
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
|
||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
||||
return {
|
||||
id: m.match_id || `${cIdx}-${idx}`,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: homeName,
|
||||
away: awayName,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
|
||||
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
|
||||
report_url: m.report_url,
|
||||
facr_link: m.facr_link,
|
||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
||||
@@ -309,13 +319,13 @@ const CalendarPage: React.FC = () => {
|
||||
id: m.match_id || `${cIdx}-${idx}`,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
|
||||
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
venue: m.venue,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url, m.home_id),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
|
||||
report_url: m.report_url,
|
||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
||||
} as MatchItem;
|
||||
@@ -381,7 +391,7 @@ const CalendarPage: React.FC = () => {
|
||||
})();
|
||||
return {
|
||||
position: Number(r.rank || idx + 1),
|
||||
team_name: teamName,
|
||||
team_name: (teamId && byId?.[teamId]?.name && String(byId[teamId].name).trim()) ? String(byId[teamId].name) : teamName,
|
||||
team_id: teamId,
|
||||
points: Number(r.points || r.pts || 0),
|
||||
played: Number(r.played || r.matches || 0),
|
||||
@@ -390,7 +400,7 @@ const CalendarPage: React.FC = () => {
|
||||
losses: Number(r.losses || r.loss || 0),
|
||||
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 0),
|
||||
goals_against: Number(r.goals_against ?? r.ga ?? r.goalsAgainst ?? r.conceded ?? 0),
|
||||
logo_url: r.team_logo_url || undefined,
|
||||
logo_url: (teamId && byId?.[teamId]?.logo_url) ? String(byId[teamId].logo_url) : (r.team_logo_url || undefined),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -660,10 +670,26 @@ const CalendarPage: React.FC = () => {
|
||||
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
|
||||
</Flex>
|
||||
<Flex align="center" gap={2} justify="center">
|
||||
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
boxSize="18px"
|
||||
alt={m.home}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text fontSize="sm">{m.home}</Text>
|
||||
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
|
||||
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
boxSize="18px"
|
||||
alt={m.away}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<Text fontSize="sm">{m.away}</Text>
|
||||
</Flex>
|
||||
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
|
||||
|
||||
+123
-322
@@ -3,6 +3,8 @@ import MainLayout from '../components/layout/MainLayout';
|
||||
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
import '../styles/theme.css';
|
||||
import '../styles/sparta-styles.css';
|
||||
import '../styles/club-styles.css';
|
||||
import '../styles/home-style-pack.css';
|
||||
import './styles/UnifiedHome.css';
|
||||
import { getPublicSettings } from '../services/settings';
|
||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||
@@ -25,6 +27,12 @@ import MatchModal from '../components/home/MatchModal';
|
||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||
import { API_URL } from '../services/api';
|
||||
import { TeamLogo } from '../components/common/TeamLogo';
|
||||
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
|
||||
import NewsList from '../components/pack/NewsList';
|
||||
import StandingsCard from '../components/pack/StandingsCard';
|
||||
import NextMatch from '../components/pack/NextMatch';
|
||||
import MatchesSlider from '../components/pack/MatchesSlider';
|
||||
import ActivitiesList from '../components/pack/ActivitiesList';
|
||||
|
||||
// Types for real API-driven data
|
||||
type NewsItem = {
|
||||
@@ -87,9 +95,7 @@ const HomePage: React.FC = () => {
|
||||
// Index for the NEXT MATCH competition carousel
|
||||
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
|
||||
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
|
||||
// Ref to the draggable matches track and per-competition closest index
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const [closestIndexByComp, setClosestIndexByComp] = useState<number[]>([]);
|
||||
// Matches slider auto-centering handled internally by MatchesSlider component
|
||||
|
||||
// API-driven players and sponsors
|
||||
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string };
|
||||
@@ -114,6 +120,16 @@ const HomePage: React.FC = () => {
|
||||
|
||||
// MyUIbrix element configuration hook for live preview
|
||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
||||
const stylePack = getVariant('style-pack', 'default');
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cls = `style-pack-${stylePack}`;
|
||||
const all = ['style-pack-default','style-pack-modern','style-pack-minimal','style-pack-sparta'];
|
||||
all.forEach(c => document.body.classList.remove(c));
|
||||
document.body.classList.add(cls);
|
||||
} catch {}
|
||||
}, [stylePack]);
|
||||
|
||||
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||
id: typeof item.id === 'number' ? item.id : index,
|
||||
@@ -360,22 +376,6 @@ const HomePage: React.FC = () => {
|
||||
});
|
||||
setFacrCompetitions(comps);
|
||||
|
||||
// Compute closest match index per competition to current time
|
||||
const nowTs = Date.now();
|
||||
const closestIdx: number[] = comps.map((c: { matches: any[] }) => {
|
||||
let bestIdx = -1;
|
||||
let bestDiff = Number.POSITIVE_INFINITY;
|
||||
(c.matches || []).forEach((m: any, idx: number) => {
|
||||
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
|
||||
if (!isNaN(ts)) {
|
||||
const diff = Math.abs(ts - nowTs);
|
||||
if (diff < bestDiff) { bestDiff = diff; bestIdx = idx; }
|
||||
}
|
||||
});
|
||||
return bestIdx;
|
||||
});
|
||||
setClosestIndexByComp(closestIdx);
|
||||
|
||||
// Next match FACR link
|
||||
const first = filteredMatches?.[0];
|
||||
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
|
||||
@@ -528,24 +528,7 @@ const HomePage: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll matches track to the closest match for current tab
|
||||
useEffect(() => {
|
||||
const el = trackRef.current;
|
||||
if (!el) return;
|
||||
const idx = (closestIndexByComp[matchesTab] ?? 0);
|
||||
const child = el.children?.[idx] as HTMLElement | undefined;
|
||||
if (!child) return;
|
||||
const run = () => {
|
||||
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
|
||||
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
|
||||
};
|
||||
// Wait for layout
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(run);
|
||||
} else {
|
||||
setTimeout(run, 0);
|
||||
}
|
||||
}, [facrCompetitions, matchesTab, closestIndexByComp]);
|
||||
// Removed: legacy auto-scroll. Handled by MatchesSlider.
|
||||
|
||||
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
||||
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
||||
@@ -1340,26 +1323,38 @@ const HomePage: React.FC = () => {
|
||||
return (
|
||||
<MainLayout headerInsideContainer>
|
||||
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
||||
{/* Header: logo + club name */}
|
||||
<div className="home-header">
|
||||
<TeamLogo
|
||||
teamId={settings?.club_id}
|
||||
teamName={clubName}
|
||||
facrLogo={assetUrl(clubLogo) || undefined}
|
||||
size="custom"
|
||||
alt="Klub"
|
||||
borderRadius="full"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{clubName}</h1>
|
||||
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
|
||||
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
|
||||
{/* Above-hero club bar (MyUIbrix managed) */}
|
||||
{isVisible('hero-topbar', true) && (
|
||||
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
|
||||
<ClubHeroTopbar
|
||||
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
|
||||
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{/* Header: logo + club name (legacy). Hidden when hero-topbar is visible */}
|
||||
{!isVisible('hero-topbar', true) && (
|
||||
<div className="home-header">
|
||||
<TeamLogo
|
||||
teamId={settings?.club_id}
|
||||
teamName={clubName}
|
||||
facrLogo={assetUrl(clubLogo) || undefined}
|
||||
size="custom"
|
||||
alt="Klub"
|
||||
borderRadius="full"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>{clubName}</h1>
|
||||
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<section key={`hero-grid-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} 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'})` }} />
|
||||
@@ -1401,7 +1396,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', ...getStyles('banner') }}>
|
||||
<section data-element="banner" data-variant={getVariant('banner', 'top')} 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 */}
|
||||
@@ -1415,7 +1410,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', ...getStyles('sidebar') }}>
|
||||
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} 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 }}>
|
||||
@@ -1430,12 +1425,12 @@ const HomePage: React.FC = () => {
|
||||
</section>
|
||||
)}
|
||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
|
||||
<BlogCardsScroller />
|
||||
</section>
|
||||
)}
|
||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
||||
<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') }}>
|
||||
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} 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>
|
||||
@@ -1457,155 +1452,54 @@ const HomePage: React.FC = () => {
|
||||
setSelectedMatch({
|
||||
...show,
|
||||
competition: comp?.name,
|
||||
competitionName: comp?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
} else if (link) {
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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); }}
|
||||
className="nav prev"
|
||||
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronLeft size={24} />
|
||||
</button>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.home_id}
|
||||
teamName={show?.home}
|
||||
facrLogo={show?.home_logo_url}
|
||||
size="custom"
|
||||
alt="Domácí"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.home || matches[0]?.homeTeam || clubName)}</div>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</div>
|
||||
{countdown || '—'}
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
className="logo"
|
||||
teamId={show?.away_id}
|
||||
teamName={show?.away}
|
||||
facrLogo={show?.away_logo_url}
|
||||
size="custom"
|
||||
alt="Hosté"
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div>{sanitizeClubName(show?.away || matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Další soutěž"
|
||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i + 1) % facrCompetitions.length); }}
|
||||
className="nav next"
|
||||
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
|
||||
>
|
||||
<FiChevronRight size={24} />
|
||||
</button>
|
||||
</section>
|
||||
<NextMatch
|
||||
data={show}
|
||||
competitionName={comp?.name}
|
||||
countdown={countdown}
|
||||
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
|
||||
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
|
||||
onOpen={handleNextMatchClick}
|
||||
elementProps={{
|
||||
'data-element': 'matches' as any,
|
||||
'data-variant': getVariant('matches', 'compact') as any,
|
||||
style: { ...getStyles('matches') },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : isVisible('matches', true) ? (
|
||||
<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>
|
||||
</div>
|
||||
<div className="countdown">
|
||||
{countdown || '—'}
|
||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
||||
{nextMatchLink && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<a href={nextMatchLink} target="_blank" rel="noopener noreferrer" style={{ color: '#fff', textDecoration: 'underline', fontSize: '0.85rem' }}>Detail na FACR</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
|
||||
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
|
||||
</div>
|
||||
</section>
|
||||
<NextMatch
|
||||
data={{
|
||||
home: matches[0]?.homeTeam || clubName,
|
||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||
away: matches[0]?.awayTeam || 'Soupeř',
|
||||
away_logo_url: matches[0]?.awayLogoURL,
|
||||
}}
|
||||
countdown={countdown}
|
||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||
{facrCompetitions.length > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
<div className="matches-grid">
|
||||
<div className="matches-track" ref={trackRef}>
|
||||
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
|
||||
const handleMatchClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setSelectedMatch({
|
||||
...m,
|
||||
competition: facrCompetitions[matchesTab]?.name,
|
||||
competitionName: facrCompetitions[matchesTab]?.name,
|
||||
});
|
||||
setIsMatchModalOpen(true);
|
||||
};
|
||||
return (
|
||||
<div key={m.id || idx} className="match-card" onClick={handleMatchClick} style={{ cursor: 'pointer' }}>
|
||||
<div className="match-meta">
|
||||
<span>{(m.venue || '').split(',')[0] || ''}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="teams">
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.home_id}
|
||||
teamName={m.home}
|
||||
facrLogo={m.home_logo_url}
|
||||
size="custom"
|
||||
alt={m.home}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.home)}</div>
|
||||
</div>
|
||||
<div className="score">
|
||||
{m.score ? (
|
||||
<>
|
||||
<span className="home">{String(m.score).split(':')[0]}</span>
|
||||
<span className="sep">:</span>
|
||||
<span className="away">{String(m.score).split(':')[1]}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="time">{m.time}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="team">
|
||||
<TeamLogo
|
||||
teamId={m.away_id}
|
||||
teamName={m.away}
|
||||
facrLogo={m.away_logo_url}
|
||||
size="custom"
|
||||
alt={m.away}
|
||||
borderRadius="full"
|
||||
/>
|
||||
<div className="name">{sanitizeClubName(m.away)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="matches-tabs">
|
||||
{facrCompetitions.map((c, i) => (
|
||||
<button key={`${c.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<MatchesSlider
|
||||
comps={facrCompetitions as any}
|
||||
activeIndex={matchesTab}
|
||||
onActiveChange={setMatchesTab}
|
||||
onMatchClick={(m: any, compName?: string) => {
|
||||
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
|
||||
setIsMatchModalOpen(true);
|
||||
}}
|
||||
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* News + Tables: split into two independent sections */}
|
||||
@@ -1631,121 +1525,40 @@ const HomePage: React.FC = () => {
|
||||
style={{ marginTop: 32 }}
|
||||
>
|
||||
{showNews && (
|
||||
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
|
||||
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Další aktuality</h3>
|
||||
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<div className="blog-list">
|
||||
{news.length > 0 ? news.slice(0, 4).map((n) => (
|
||||
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{n.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
|
||||
</div>
|
||||
</a>
|
||||
)) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
|
||||
<p>Zatím nejsou k dispozici žádné aktuality.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{news.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
|
||||
</div>
|
||||
)}
|
||||
<NewsList items={news as any} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showTable && (
|
||||
<div data-element="table" style={{ ...getStyles('table') }}>
|
||||
<div className="table-card">
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</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.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
|
||||
e.currentTarget.style.borderColor = 'var(--primary)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
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 && (
|
||||
<TeamLogo
|
||||
teamId={row.team_id}
|
||||
teamName={row.team?.name ?? row.team ?? row.club}
|
||||
facrLogo={row.team_logo_url}
|
||||
size="custom"
|
||||
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 data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
<h3>Tabulky</h3>
|
||||
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||
</div>
|
||||
<StandingsCard
|
||||
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
|
||||
onRowClick={(row) => {
|
||||
const clubData = {
|
||||
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
|
||||
team_id: (row as any).team_id || '',
|
||||
team_logo_url: (row as any).team_logo_url,
|
||||
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
|
||||
played: (row as any).played ?? (row as any).matches ?? '-',
|
||||
wins: (row as any).wins ?? (row as any).win ?? '-',
|
||||
draws: (row as any).draws ?? (row as any).draw ?? '-',
|
||||
losses: (row as any).losses ?? (row as any).loss ?? '-',
|
||||
score: (row as any).score ?? '-',
|
||||
points: (row as any).points ?? (row as any).pts ?? '-',
|
||||
};
|
||||
setSelectedClub(clubData);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -1755,32 +1568,20 @@ const HomePage: React.FC = () => {
|
||||
{/* Competition tables moved into right column below */}
|
||||
|
||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
||||
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<section data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<div className="section-head" style={{ marginTop: 0 }}>
|
||||
<h3>Aktivity</h3>
|
||||
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||
</div>
|
||||
<div className="blog-list">
|
||||
{upcomingEvents.slice(0,4).map((e) => (
|
||||
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
|
||||
<div>
|
||||
<h4>{e.title}</h4>
|
||||
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
|
||||
{new Date(e.start_time).toLocaleDateString()} {e.location ? `• ${e.location}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<ActivitiesList items={upcomingEvents as any} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Players scroller */}
|
||||
{players.length > 0 && isVisible('team', false) && (
|
||||
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
|
||||
<section data-element="team" data-variant={getVariant('team', 'grid')} 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>
|
||||
@@ -1799,7 +1600,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Gallery */}
|
||||
{isVisible('gallery', false) && (
|
||||
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<section data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<GallerySection zoneramaUrl={galleryUrl} />
|
||||
</div>
|
||||
@@ -1808,7 +1609,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Videos */}
|
||||
{isVisible('videos', false) && (
|
||||
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<VideosSection />
|
||||
</div>
|
||||
@@ -1816,7 +1617,7 @@ const HomePage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{isVisible('merch', true) && (
|
||||
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<section data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<MerchSection />
|
||||
</div>
|
||||
@@ -1825,7 +1626,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* Polls / Voting */}
|
||||
{isVisible('poll', false) && (
|
||||
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<section data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||
</div>
|
||||
@@ -1834,7 +1635,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', ...getStyles('banner') }}>
|
||||
<section data-element="banner" data-variant={getVariant('banner', 'bottom')} 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 */}
|
||||
@@ -1846,7 +1647,7 @@ const HomePage: React.FC = () => {
|
||||
|
||||
{/* CTA (Newsletter) moved up */}
|
||||
{isVisible('newsletter', false) && (
|
||||
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<section data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
|
||||
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||
<NewsletterSubscribe />
|
||||
</div>
|
||||
|
||||
@@ -277,8 +277,9 @@ const MatchesPage: React.FC = () => {
|
||||
setAliasMap(amap);
|
||||
|
||||
// Build override helpers
|
||||
const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
|
||||
const teamLogoOverridesJSON = (teamLogoOverridesAPI && (teamLogoOverridesAPI.by_name || teamLogoOverridesAPI.by_id)) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
|
||||
const byName: Record<string, string> = (teamLogoOverridesJSON?.by_name || {}) as any;
|
||||
const byId: Record<string, { name?: string; logo_url?: string }> = (teamLogoOverridesJSON?.by_id || {}) as any;
|
||||
const normalize = (s: string) => String(s)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
@@ -321,9 +322,27 @@ const MatchesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
// Prefer explicit admin override by ID
|
||||
if (teamId && byId?.[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url as string;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
// Prefer local club logo for our own team
|
||||
try {
|
||||
if (
|
||||
teamId && settingsJSON?.club_id && String(teamId) === String(settingsJSON.club_id) && settingsJSON?.club_logo_url
|
||||
) {
|
||||
const local = settingsJSON.club_logo_url as string;
|
||||
if (typeof local === 'string' && local.startsWith('/')) return resolveBackendUrl(local);
|
||||
return local;
|
||||
}
|
||||
} catch {}
|
||||
// If we have a team ID and no explicit override, use logoapi as a high-quality source
|
||||
if (teamId) {
|
||||
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
|
||||
}
|
||||
// Fallback to by-name overrides or original
|
||||
return getFallbackLogo(teamName, original);
|
||||
};
|
||||
|
||||
@@ -351,6 +370,8 @@ const MatchesPage: React.FC = () => {
|
||||
const [day, month, year] = (d || '').split('.');
|
||||
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||
const time = (t || '18:00').slice(0,5);
|
||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
||||
|
||||
// Check if match is in the future - if so, ignore score
|
||||
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
||||
@@ -361,12 +382,12 @@ const MatchesPage: React.FC = () => {
|
||||
id: m.match_id || idx + 1,
|
||||
date: isoDate,
|
||||
time,
|
||||
home: m.home,
|
||||
away: m.away,
|
||||
home: homeName,
|
||||
away: awayName,
|
||||
home_id: m.home_id,
|
||||
away_id: m.away_id,
|
||||
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
||||
home_logo_url: getOverrideLogo(homeName, m.home_id, m.home_logo_url),
|
||||
away_logo_url: getOverrideLogo(awayName, m.away_id, m.away_logo_url),
|
||||
score: actualScore,
|
||||
facr_link: m.facr_link,
|
||||
report_url: m.report_url,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
@@ -15,6 +16,11 @@ import {
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
SimpleGrid,
|
||||
Badge,
|
||||
Divider,
|
||||
Spinner,
|
||||
Spacer,
|
||||
} from '@chakra-ui/react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -54,8 +60,9 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
blogs: true,
|
||||
matches: true,
|
||||
events: true,
|
||||
scores: false,
|
||||
...(data?.preferences || {} as SubscriberPreferences),
|
||||
scores: true,
|
||||
weekly: true,
|
||||
...(data?.preferences || ({} as SubscriberPreferences)),
|
||||
}), [data]);
|
||||
|
||||
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
|
||||
@@ -117,7 +124,14 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Box maxW="640px" mx="auto" p={6}><Text>Načítání…</Text></Box>;
|
||||
return (
|
||||
<Container maxW="container.md" py={8}>
|
||||
<HStack>
|
||||
<Spinner />
|
||||
<Text>Načítání…</Text>
|
||||
</HStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
@@ -129,54 +143,83 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
// helpers to toggle all main content types on/off
|
||||
const setAll = (on: boolean) => setPrefs({
|
||||
...prefs,
|
||||
blogs: on,
|
||||
matches: on,
|
||||
events: on,
|
||||
scores: on,
|
||||
weekly: on,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box maxW="720px" mx="auto" p={6}>
|
||||
<Heading size="lg" mb={2}>Nastavení newsletteru</Heading>
|
||||
<Text color="gray.600" mb={6}>Spravujte, jaké e-maily chcete dostávat na adresu {data.email}.</Text>
|
||||
<Container maxW="container.md" py={8}>
|
||||
<HStack mb={2} align="center">
|
||||
<Heading size="lg">Nastavení newsletteru</Heading>
|
||||
<Spacer />
|
||||
<Button size="sm" variant="outline" onClick={() => setAll(true)}>Zapnout vše</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAll(false)}>Vypnout vše</Button>
|
||||
</HStack>
|
||||
<HStack mb={6} color="gray.600">
|
||||
<Text>Spravujte, jaké e-maily chcete dostávat na adresu</Text>
|
||||
<Badge colorScheme={data.is_active ? 'green' : 'red'}>{data.email}</Badge>
|
||||
</HStack>
|
||||
|
||||
<Card mb={6}><CardBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Články (blog)</FormLabel>
|
||||
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
||||
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Události</FormLabel>
|
||||
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
||||
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Preferované soutěže</FormLabel>
|
||||
{Array.isArray(competitions) && competitions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{competitions.map((c: any, idx: number) => {
|
||||
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
|
||||
const name = (c?.name || c?.code || code) as string;
|
||||
const checked = selectedCodes.has(String(code));
|
||||
return (
|
||||
<HStack key={code} justify="space-between">
|
||||
<Text>{name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
|
||||
)}
|
||||
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody></Card>
|
||||
<Card mb={6}>
|
||||
<CardBody>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Články (blog)</FormLabel>
|
||||
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
||||
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Události</FormLabel>
|
||||
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
||||
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
||||
</FormControl>
|
||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||
<FormLabel m={0}>Týdenní souhrn (digest)</FormLabel>
|
||||
<Switch isChecked={!!(prefs as any).weekly} onChange={(e) => setPrefs({ ...prefs, weekly: e.target.checked })} />
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
<HStack spacing={3}>
|
||||
<Divider />
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Preferované soutěže</FormLabel>
|
||||
{Array.isArray(competitions) && competitions.length > 0 ? (
|
||||
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{competitions.map((c: any, idx: number) => {
|
||||
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
|
||||
const name = (c?.name || c?.code || code) as string;
|
||||
const checked = selectedCodes.has(String(code));
|
||||
return (
|
||||
<HStack key={code} justify="space-between">
|
||||
<Text>{name}</Text>
|
||||
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
|
||||
</HStack>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
) : (
|
||||
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
|
||||
)}
|
||||
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<HStack spacing={3} pt={2} borderTopWidth="1px">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => saveMut.mutate()}
|
||||
@@ -192,6 +235,7 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
>
|
||||
Obnovit
|
||||
</Button>
|
||||
<Spacer />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
@@ -203,7 +247,7 @@ const NewsletterPreferencesPage: React.FC = () => {
|
||||
Zrušit odběr
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPlayers } from '../services/public';
|
||||
import type { Player } from '../services/public';
|
||||
import { assetUrl } from '../utils/url';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import MainLayout from '../components/layout/MainLayout';
|
||||
@@ -8,7 +9,7 @@ import SponsorsSection from '../components/common/SponsorsSection';
|
||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||
|
||||
const PlayersPage: React.FC = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
@@ -106,6 +106,7 @@ const SetupPage: React.FC = () => {
|
||||
const [smtpUser, setSmtpUser] = useState('');
|
||||
const [smtpPass, setSmtpPass] = useState('');
|
||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||
const [smtpUserEdited, setSmtpUserEdited] = useState(false);
|
||||
// Sender display name only; actual email is derived from smtpUser
|
||||
const [smtpFromName, setSmtpFromName] = useState('');
|
||||
const [smtpTLS, setSmtpTLS] = useState(true);
|
||||
@@ -192,10 +193,10 @@ const SetupPage: React.FC = () => {
|
||||
|
||||
// Auto-fill SMTP username from contact email
|
||||
useEffect(() => {
|
||||
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
|
||||
if (contactEmail && isValidEmail(contactEmail) && !smtpUserEdited) {
|
||||
setSmtpUser(contactEmail);
|
||||
}
|
||||
}, [contactEmail, smtpUser]);
|
||||
}, [contactEmail, smtpUserEdited]);
|
||||
|
||||
const handleSelectClub = async (item: SearchResult) => {
|
||||
const clubIdValue = item.club_id || '';
|
||||
@@ -791,7 +792,7 @@ const SetupPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
|
||||
<Input type="email" placeholder="kontakt@klub.cz" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUserEdited && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
|
||||
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
@@ -816,7 +817,7 @@ const SetupPage: React.FC = () => {
|
||||
</FormControl>
|
||||
<FormControl mb={3}>
|
||||
<FormLabel>SMTP uživatelské jméno</FormLabel>
|
||||
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
|
||||
<Input value={smtpUser} onChange={(e) => { setSmtpUser(e.target.value); setSmtpUserEdited(true); }} />
|
||||
</FormControl>
|
||||
<FormControl mb={3}>
|
||||
<FormLabel>SMTP heslo</FormLabel>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Center, Spinner, Text, VStack } from '@chakra-ui/react';
|
||||
import { API_URL } from '../services/api';
|
||||
|
||||
const ShortRedirectPage: React.FC = () => {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!code) return;
|
||||
try {
|
||||
const api = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
const backendOrigin = api.origin;
|
||||
const target = `${backendOrigin}/s/${code}`;
|
||||
window.location.replace(target);
|
||||
} catch {
|
||||
window.location.href = `/s/${code}`;
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<Center minH="60vh">
|
||||
<VStack spacing={3}>
|
||||
<Spinner />
|
||||
<Text>Přesměrování…</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortRedirectPage;
|
||||
@@ -141,15 +141,22 @@ const TablesPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{!!competitions.length && (
|
||||
<Tabs variant="enclosed">
|
||||
<TabList>
|
||||
<Tabs variant="enclosed" size="sm">
|
||||
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
|
||||
'&::-webkit-scrollbar': { height: '4px' },
|
||||
'&::-webkit-scrollbar-track': { background: 'transparent' },
|
||||
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
|
||||
}}>
|
||||
{competitions.map((c) => (
|
||||
<Tab
|
||||
key={c.id}
|
||||
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
|
||||
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
|
||||
flex="0 0 auto"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
{c.name}
|
||||
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
useColorModeValue,
|
||||
Image as ChakraImage,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiLink } from 'react-icons/fi';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Event } from '../../types/event';
|
||||
import { uploadFile } from '../../services/articles';
|
||||
@@ -60,9 +60,10 @@ 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 { FiVideo, FiYoutube } from 'react-icons/fi';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
|
||||
const types: Array<{ value: Event['type']; label: string }> = [
|
||||
{ value: 'match', label: 'Zápas' },
|
||||
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
});
|
||||
const events = data || [];
|
||||
|
||||
// Localized label for event type
|
||||
const typeLabel = (t?: string) => {
|
||||
const v = String(t || '').trim() as any;
|
||||
const found = types.find((x) => x.value === v);
|
||||
return found ? found.label : 'Jiné';
|
||||
};
|
||||
|
||||
// Load club YouTube videos
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const e = editing || {};
|
||||
// Build a helpful Czech prompt including known fields
|
||||
const lines: string[] = [];
|
||||
const clubName = String(settingsQ?.data?.club_name || '').trim();
|
||||
if (clubName) lines.push(`Klub: ${clubName}`);
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
if (e.location) lines.push(`Místo: ${e.location}`);
|
||||
if (e.start_time) {
|
||||
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.end_time) {
|
||||
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
|
||||
}
|
||||
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||
const base = lines.join('\n');
|
||||
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
|
||||
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
|
||||
const { data } = await api.post('/ai/blog/generate', {
|
||||
prompt,
|
||||
audience: 'Fanoušci klubu, oznámení/pozvánka',
|
||||
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
|
||||
min_words: 120,
|
||||
});
|
||||
|
||||
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
)}
|
||||
</Td>
|
||||
<Td>{ev.title}</Td>
|
||||
<Td>{ev.type}</Td>
|
||||
<Td>{typeLabel(ev.type as any)}</Td>
|
||||
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
||||
<Td>{ev.location || '-'}</Td>
|
||||
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<HStack>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
|
||||
<IconButton
|
||||
aria-label="Zkrátit odkaz"
|
||||
size="sm"
|
||||
icon={<FiLink />}
|
||||
title="Zkrátit odkaz pro sdílení"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const target = `${origin}/aktivita/${ev.id}`;
|
||||
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
|
||||
@@ -70,7 +70,8 @@ import {
|
||||
FiZap,
|
||||
FiTrendingUp,
|
||||
FiCalendar,
|
||||
FiSearch
|
||||
FiSearch,
|
||||
FiInfo
|
||||
} from 'react-icons/fi';
|
||||
|
||||
// Register ChartJS components
|
||||
@@ -188,6 +189,11 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
|
||||
name: 'Kliknutí na externí odkaz',
|
||||
source: 'Různé stránky',
|
||||
description: 'Uživatel klikl na odkaz vedoucí mimo web'
|
||||
},
|
||||
'Poll Vote': {
|
||||
name: 'Hlasování v anketě',
|
||||
source: 'Ankety',
|
||||
description: 'Uživatel hlasoval v anketě'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,6 +219,7 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
|
||||
const [hasData, setHasData] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
|
||||
const [selectedCountry, setSelectedCountry] = useState<{
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
const fetchAnalytics = async (days: string) => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
setNoDataInfo(null);
|
||||
try {
|
||||
const daysNum = parseInt(days);
|
||||
|
||||
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
|
||||
setPageviewsData(pageviewsDataArray);
|
||||
|
||||
// Determine if we have data
|
||||
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
|
||||
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
|
||||
setHasData(hasAnyStats || hasPageviews || hasMetrics);
|
||||
|
||||
// Set error message if no data
|
||||
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
|
||||
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
|
||||
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
|
||||
if (noData) {
|
||||
if (daysNum <= 1) {
|
||||
setNoDataInfo('Pro vybrané denní období zatím nebyla zaznamenána žádná návštěvnost. Zkuste později nebo zvolte delší období.');
|
||||
} else {
|
||||
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
|
||||
}
|
||||
} else {
|
||||
setNoDataInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics:', error);
|
||||
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* No data info for daily view */}
|
||||
{noDataInfo && (
|
||||
<Card bg="yellow.50" borderColor="yellow.300" borderWidth={2}>
|
||||
<CardBody>
|
||||
<HStack spacing={3} align="start">
|
||||
<Icon as={FiInfo} color="yellow.600" boxSize={6} mt={1} />
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text fontWeight="bold" color="yellow.800">Zatím žádná data</Text>
|
||||
<Text fontSize="sm" color="yellow.700">{noDataInfo}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
|
||||
<Card bg={bgColor} borderColor={borderColor}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
||||
} from '@chakra-ui/react';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
|
||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
|
||||
@@ -30,6 +30,7 @@ import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import { createShortLink } from '../../services/shortlinks';
|
||||
|
||||
// Inline small component to show match link badge in list (with short label)
|
||||
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
|
||||
return dateStr.includes(matchDateFilter);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by proximity to current date (recent matches first)
|
||||
const now = Date.now();
|
||||
const parseTime = (s?: string): number => {
|
||||
if (!s) return Number.MAX_SAFE_INTEGER;
|
||||
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
|
||||
if (m) {
|
||||
const d = parseInt(m[1], 10);
|
||||
const mo = parseInt(m[2], 10) - 1;
|
||||
const y = parseInt(m[3], 10);
|
||||
const h = m[4] ? parseInt(m[4], 10) : 0;
|
||||
const mi = m[5] ? parseInt(m[5], 10) : 0;
|
||||
return new Date(y, mo, d, h, mi).getTime();
|
||||
}
|
||||
const t = Date.parse(s);
|
||||
return isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
|
||||
};
|
||||
opts = opts.sort((a, b) => {
|
||||
const dateA = new Date(a.date || 0).getTime();
|
||||
const dateB = new Date(b.date || 0).getTime();
|
||||
const diffA = Math.abs(now - dateA);
|
||||
const diffB = Math.abs(now - dateB);
|
||||
return diffA - diffB; // Closest to today first
|
||||
const ta = parseTime(a.date);
|
||||
const tb = parseTime(b.date);
|
||||
const da = ta - now;
|
||||
const db = tb - now;
|
||||
const aUpcoming = da >= 0;
|
||||
const bUpcoming = db >= 0;
|
||||
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
|
||||
if (aUpcoming) return da - db;
|
||||
return Math.abs(da) - Math.abs(db);
|
||||
});
|
||||
|
||||
return opts;
|
||||
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
|
||||
<HStack spacing={1}>
|
||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
|
||||
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
|
||||
<IconButton
|
||||
aria-label="Zkrátit odkaz"
|
||||
size="sm"
|
||||
icon={<FiLink />}
|
||||
title="Zkrátit odkaz pro sdílení"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const slug = (a as any)?.slug || (a as any)?.Slug;
|
||||
const path = slug ? `/news/${slug}` : `/articles/${a.id}`;
|
||||
const target = `${origin}${path}`;
|
||||
const res = await createShortLink({ target_url: target, title: a.title, source_type: 'article', source_id: a.id });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Obsah</Tab>
|
||||
<Tab>Média</Tab>
|
||||
<Tab>SEO</Tab>
|
||||
<Tab>Anketa</Tab>
|
||||
<Tab>SEO</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
{/* AI first */}
|
||||
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anketa (Poll) Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
|
||||
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
|
||||
<Text fontSize="sm" color="gray.700" mb={3}>
|
||||
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<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 () => {
|
||||
// Force save if needed
|
||||
try {
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(4); // Poll tab is index 4 after reordering
|
||||
} catch (error) {
|
||||
// Error is handled by onSubmit
|
||||
}
|
||||
}}
|
||||
isLoading={createMut.isLoading}
|
||||
>
|
||||
Uložit jako koncept a přidat ankety
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
|
||||
{/* SEO last - minimized */}
|
||||
<TabPanel>
|
||||
<Text fontSize="sm" color="gray.600" mb={4}>
|
||||
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabPanel>
|
||||
|
||||
{/* Anketa (Poll) Tab */}
|
||||
<TabPanel>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
|
||||
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
|
||||
<Text fontSize="sm" color="gray.700" mb={3}>
|
||||
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{editing?.id ? (
|
||||
<PollLinker articleId={editing.id} onPollsChanged={() => {
|
||||
// Invalidate queries to refresh polls
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<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 () => {
|
||||
// Force save if needed
|
||||
try {
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(5); // Poll tab is index 5
|
||||
} catch (error) {
|
||||
// Error is handled by onSubmit
|
||||
}
|
||||
}}
|
||||
isLoading={createMut.isLoading}
|
||||
>
|
||||
Uložit jako koncept a přidat ankety
|
||||
</Button>
|
||||
</VStack>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
|
||||
</Modal>
|
||||
|
||||
{/* Zonerama Gallery Picker Modal */}
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Zonerama Gallery Picker Modal */}
|
||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxH="90vh">
|
||||
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto">
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* Loading State */}
|
||||
{galleryLoading && (
|
||||
<HStack spacing={2} justify="center" py={8}>
|
||||
<Spinner size="lg" color="purple.500" />
|
||||
<Text color="gray.600">Načítám alba z galerie...</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Albums Grid */}
|
||||
{!galleryLoading && cachedAlbums.length > 0 && (
|
||||
<VStack align="stretch" spacing={6}>
|
||||
{cachedAlbums.map((album) => (
|
||||
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">{album.date} • {album.photos.length} fotografií</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
|
||||
{album.photos.map((photo) => (
|
||||
<Box
|
||||
key={photo.id}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
|
||||
onClick={() => {
|
||||
pickZoneramaImage({
|
||||
id: photo.id,
|
||||
album_id: album.id,
|
||||
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
|
||||
page_url: photo.page_url,
|
||||
image_url: photo.image_1500,
|
||||
title: album.title
|
||||
});
|
||||
onGalleryPickerClose();
|
||||
}}
|
||||
>
|
||||
<AspectRatio ratio={1}>
|
||||
<Image
|
||||
src={photo.image_1500}
|
||||
alt={photo.id}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!galleryLoading && cachedAlbums.length === 0 && (
|
||||
<VStack py={8} spacing={3}>
|
||||
<Icon as={FiSearch} boxSize={12} color="gray.400" />
|
||||
<Text color="gray.600" textAlign="center">
|
||||
Žádná alba nebyla nalezena v cache.
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center">
|
||||
Zkontrolujte nastavení Zonerama nebo obnovte cache.
|
||||
</Text>
|
||||
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
|
||||
Obnovit seznam
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={onGalleryPickerClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
|
||||
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { parse } from 'date-fns';
|
||||
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
const [showScrollHint, setShowScrollHint] = useState(true);
|
||||
const thBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// Drag-to-scroll state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [lastX, setLastX] = useState(0);
|
||||
const [lastTime, setLastTime] = useState(0);
|
||||
const lastXRef = useRef(0);
|
||||
const lastTimeRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
|
||||
// Color modes for past/future matches
|
||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const futureMatchBg = useColorModeValue('white', 'gray.800');
|
||||
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const scrollRaf = useRef<number | null>(null);
|
||||
|
||||
const updateScrollShadow = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 0);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
const left = el.scrollLeft > 0;
|
||||
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||
if (left !== canScrollLeft) setCanScrollLeft(left);
|
||||
if (right !== canScrollRight) setCanScrollRight(right);
|
||||
};
|
||||
|
||||
// Drag-to-scroll handlers
|
||||
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||||
setScrollLeft(scrollRef.current.scrollLeft);
|
||||
setLastX(e.pageX);
|
||||
setLastTime(Date.now());
|
||||
lastXRef.current = e.pageX;
|
||||
lastTimeRef.current = Date.now();
|
||||
velocityRef.current = 0;
|
||||
scrollRef.current.style.cursor = 'grabbing';
|
||||
scrollRef.current.style.userSelect = 'none';
|
||||
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
|
||||
|
||||
// Calculate velocity for momentum
|
||||
const now = Date.now();
|
||||
const timeDelta = now - lastTime;
|
||||
const timeDelta = now - lastTimeRef.current;
|
||||
if (timeDelta > 0) {
|
||||
const currentX = e.pageX;
|
||||
const distance = currentX - lastX;
|
||||
const distance = currentX - lastXRef.current;
|
||||
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
||||
setLastX(currentX);
|
||||
setLastTime(now);
|
||||
lastXRef.current = currentX;
|
||||
lastTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
|
||||
setIsDragging(true);
|
||||
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
||||
setScrollLeft(scrollRef.current.scrollLeft);
|
||||
setLastX(touch.pageX);
|
||||
setLastTime(Date.now());
|
||||
lastXRef.current = touch.pageX;
|
||||
lastTimeRef.current = Date.now();
|
||||
velocityRef.current = 0;
|
||||
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
||||
};
|
||||
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
|
||||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||
|
||||
const now = Date.now();
|
||||
const timeDelta = now - lastTime;
|
||||
const timeDelta = now - lastTimeRef.current;
|
||||
if (timeDelta > 0) {
|
||||
const currentX = touch.pageX;
|
||||
const distance = currentX - lastX;
|
||||
const distance = currentX - lastXRef.current;
|
||||
velocityRef.current = distance / timeDelta * 16;
|
||||
setLastX(currentX);
|
||||
setLastTime(now);
|
||||
lastXRef.current = currentX;
|
||||
lastTimeRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
|
||||
const headerText = useColorModeValue('text.onPrimary', 'white');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
|
||||
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
|
||||
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const futureMatchBg = useColorModeValue('white', 'gray.800');
|
||||
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
return (
|
||||
<AdminLayout requireAdmin={false}>
|
||||
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onScroll={(e) => {
|
||||
updateScrollShadow();
|
||||
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||
if (scrollRaf.current == null) {
|
||||
scrollRaf.current = requestAnimationFrame(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
updateScrollShadow();
|
||||
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||
}
|
||||
scrollRaf.current = null;
|
||||
});
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
scrollBehavior: 'smooth',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'transform',
|
||||
overscrollBehaviorX: 'contain',
|
||||
touchAction: 'pan-x',
|
||||
'th, td': { whiteSpace: 'nowrap' },
|
||||
'::-webkit-scrollbar': { height: '14px' },
|
||||
'::-webkit-scrollbar-thumb': {
|
||||
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
|
||||
{/* Gradient edges to indicate horizontal scroll */}
|
||||
{canScrollLeft && (
|
||||
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
|
||||
bgGradient={edgeGradientLeft}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
||||
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
|
||||
bgGradient={edgeGradientRight}
|
||||
zIndex={1}
|
||||
/>
|
||||
)}
|
||||
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
|
||||
alt={m.home || m.home_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
@@ -962,6 +980,9 @@ const MatchesAdminPage = () => {
|
||||
alt={m.away || m.away_team || ''}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
@@ -1167,6 +1188,7 @@ const MatchesAdminPage = () => {
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||
|
||||
type MailType = 'weekly' | 'matches' | 'scores' | 'blogs' | 'events';
|
||||
const mailTypeLabel: Record<MailType, string> = { weekly: 'Týdenní přehled', matches: 'Zápasy', scores: 'Výsledky', blogs: 'Novinky', events: 'Akce' };
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [activeType, setActiveType] = useState<MailType | null>(null);
|
||||
const [typePreview, setTypePreview] = useState<Record<string, { subject: string; html: string } | undefined>>({});
|
||||
const [detailsCompetitions, setDetailsCompetitions] = useState<string>('');
|
||||
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
|
||||
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
|
||||
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
|
||||
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
|
||||
const recipientsForType = (t: MailType): string[] => {
|
||||
const key = t === 'weekly' ? 'weekly' : t;
|
||||
return subscribers
|
||||
.filter((s: any) => s.is_active && s?.preferences && s.preferences[key] === true)
|
||||
.map((s: any) => s.email);
|
||||
};
|
||||
const getRecipientsFor = (t: MailType, comps?: string): string[] => {
|
||||
const key = t === 'weekly' ? 'weekly' : t;
|
||||
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[key] === true);
|
||||
if (!comps || !comps.trim() || (t !== 'matches' && t !== 'scores')) {
|
||||
return base.map((s: any) => s.email);
|
||||
}
|
||||
const list = comps.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
|
||||
const filtered = base.filter((s: any) => {
|
||||
const prefs: any = s?.preferences || {};
|
||||
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions ? prefs.competitions : (typeof prefs.categories === 'string' ? prefs.categories : '');
|
||||
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
|
||||
if (arr.length === 0) return true;
|
||||
return arr.some((v: string) => list.includes(v));
|
||||
});
|
||||
return filtered.map((s: any) => s.email);
|
||||
};
|
||||
const exportRecipientsCSV = (t: MailType, comps?: string) => {
|
||||
const list = getRecipientsFor(t, comps);
|
||||
const safeCSV = (value: any) => {
|
||||
const s = String(value ?? '');
|
||||
return /^[=+\-@]/.test(s) ? `'${s}` : s;
|
||||
};
|
||||
const header = ['email','type','competitions'];
|
||||
const lines = [
|
||||
header.join(','),
|
||||
...list.map(e => [safeCSV(e), t, (comps || '').trim()].join(','))
|
||||
];
|
||||
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `newsletter_recipients_${t}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const loadPreviewForType = async (t: MailType, comps?: string) => {
|
||||
const prefs: any = {};
|
||||
if (t === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; }
|
||||
else { (prefs as any)[t] = true; }
|
||||
if (comps && comps.trim()) { prefs.competitions = comps.trim(); }
|
||||
const res = await previewNewsletter({ preferences: prefs });
|
||||
setTypePreview(prev => ({ ...prev, [t]: { subject: res.subject, html: res.html } }));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (detailsOpen && activeType && !typePreview[activeType]) {
|
||||
(async () => {
|
||||
try {
|
||||
setDetailsLoading(true);
|
||||
await loadPreviewForType(activeType!, detailsCompetitions);
|
||||
} finally {
|
||||
setDetailsLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [detailsOpen, activeType]);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const testModal = useDisclosure();
|
||||
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
|
||||
<Heading size="md" mb={3}>Typy e‑mailů</Heading>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{(['weekly','matches','scores','blogs','events'] as MailType[]).map((t)=>{
|
||||
const count = recipientsForType(t).length;
|
||||
const enabled = t === 'weekly' ? !!settings?.enable_weekly : t === 'matches' ? !!settings?.enable_match_reminders : t === 'scores' ? !!settings?.enable_results : undefined;
|
||||
return (
|
||||
<Flex key={t} align="center" justify="space-between" p={3} borderWidth="1px" borderRadius="md" _hover={{ bg: hoverBg }}>
|
||||
<HStack spacing={3}>
|
||||
<Text fontWeight="600">{mailTypeLabel[t]}</Text>
|
||||
{enabled !== undefined && (
|
||||
<Badge colorScheme={enabled ? 'green' : 'gray'}>{enabled ? 'Zapnuto' : 'Vypnuto'}</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={4}>
|
||||
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
|
||||
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel p={0}>
|
||||
{/* Scheduling controls */}
|
||||
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel>Den v týdnu</FormLabel>
|
||||
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
|
||||
<option value="sun">Neděle</option>
|
||||
<option value="mon">Pondělí</option>
|
||||
<option value="tue">Úterý</option>
|
||||
<option value="wed">Středa</option>
|
||||
<option value="thu">Čtvrtek</option>
|
||||
<option value="fri">Pátek</option>
|
||||
<option value="sat">Sobota</option>
|
||||
<option value="sun">Neděle</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">Připomínky zápasů</Text>
|
||||
<Switch isChecked={enableMatchReminders} onChange={(e)=> setEnableMatchReminders(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="220px">
|
||||
<FormLabel>Odeslat před (hodin)</FormLabel>
|
||||
<Input type="number" min={1} max={168} value={reminderLead} onChange={(e)=> setReminderLead(Math.max(1, Math.min(168, Number(e.target.value)||0)))} />
|
||||
<FormHelperText>Výchozí 48 h před výkopem. Systém posílá i upozornění v den zápasu.</FormHelperText>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontWeight="600">Výsledky po zápase</Text>
|
||||
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Tiché hodiny od</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
</FormControl>
|
||||
<FormControl maxW="160px">
|
||||
<FormLabel>Tiché hodiny do</FormLabel>
|
||||
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
|
||||
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
|
||||
<HStack pt={2}>
|
||||
<Button colorScheme="blue" onClick={()=> saveScheduleMutation.mutate()} isLoading={saveScheduleMutation.isLoading}>Uložit plánování</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={detailsOpen} onClose={closeDetails} size="5xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="95vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>
|
||||
{activeType ? `Detail: ${mailTypeLabel[activeType]}` : 'Detail'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack spacing={4} align="flex-end">
|
||||
<FormControl maxW="360px">
|
||||
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
|
||||
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
|
||||
</FormControl>
|
||||
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
|
||||
{activeType && typePreview[activeType]?.subject && (
|
||||
<Badge colorScheme="blue">{typePreview[activeType]!.subject}</Badge>
|
||||
)}
|
||||
<Button colorScheme="blue" variant="solid" isLoading={sendNowLoading} onClick={async()=>{
|
||||
if(!activeType) return;
|
||||
const ok = window.confirm(`Odeslat "${mailTypeLabel[activeType]}" nyní? E‑mail bude odeslán všem aktivním odběratelům.`);
|
||||
if(!ok) return;
|
||||
try {
|
||||
setSendNowLoading(true);
|
||||
await sendNewsletterDigest(activeType as DigestType, (detailsCompetitions || '').trim() || undefined);
|
||||
toast({ title: 'Digest odeslán', status: 'success' });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
|
||||
} finally {
|
||||
setSendNowLoading(false);
|
||||
}
|
||||
}}>Odeslat nyní</Button>
|
||||
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
|
||||
</HStack>
|
||||
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
|
||||
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading size="sm" mb={2}>Příjemci</Heading>
|
||||
{(() => {
|
||||
let list: string[] = [];
|
||||
if (activeType) {
|
||||
const typeKey = activeType === 'weekly' ? 'weekly' : activeType;
|
||||
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[typeKey] === true);
|
||||
let filtered = base;
|
||||
const compsInput = (detailsCompetitions || '').trim();
|
||||
if (compsInput && (activeType === 'matches' || activeType === 'scores')) {
|
||||
const comps = compsInput.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
|
||||
if (comps.length > 0) {
|
||||
filtered = base.filter((s: any) => {
|
||||
const prefs: any = s?.preferences || {};
|
||||
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions
|
||||
? prefs.competitions
|
||||
: (typeof prefs.categories === 'string' ? prefs.categories : '');
|
||||
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
|
||||
if (arr.length === 0) return true;
|
||||
return arr.some((v: string) => comps.includes(v));
|
||||
});
|
||||
}
|
||||
}
|
||||
list = filtered.map((s: any) => s.email);
|
||||
}
|
||||
const shown = list.slice(0, 50);
|
||||
return (
|
||||
<>
|
||||
{shown.length === 0 ? (
|
||||
<Text color="gray.600">Žádní příjemci pro tento typ.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={1} maxH="240px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
||||
{shown.map((e)=> (<Text key={e} fontFamily="mono">{e}</Text>))}
|
||||
{list.length > shown.length && (
|
||||
<Text color="gray.600">… a dalších {list.length - shown.length}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={closeDetails}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* SMTP Test Modal */}
|
||||
<Modal isOpen={smtpModal.isOpen} onClose={smtpModal.onClose} size="lg">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
|
||||
<ModalHeader>Otestovat SMTP</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<HStack spacing={3} align="flex-end">
|
||||
<FormControl>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<Input placeholder="smtp.example.com" value={smtpHost} onChange={(e)=> setSmtpHost(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl maxW="140px">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<Input type="number" placeholder="465" value={smtpPort} onChange={(e)=> setSmtpPort(Number(e.target.value)||0)} />
|
||||
</FormControl>
|
||||
<FormControl maxW="140px">
|
||||
<FormLabel> </FormLabel>
|
||||
<Checkbox isChecked={smtpTLS} onChange={(e)=> setSmtpTLS(e.target.checked)}>TLS/SSL</Checkbox>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>Uživatel</FormLabel>
|
||||
<Input value={smtpUser} onChange={(e)=> setSmtpUser(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Heslo</FormLabel>
|
||||
<InputGroup>
|
||||
<Input type={showSmtpPass ? 'text' : 'password'} value={smtpPass} onChange={(e)=> setSmtpPass(e.target.value)} />
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={()=> setShowSmtpPass(v=> !v)}>
|
||||
{showSmtpPass ? 'Skrýt' : 'Zobrazit'}
|
||||
</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<FormControl>
|
||||
<FormLabel>From</FormLabel>
|
||||
<Input placeholder="club@example.com" value={smtpFrom} onChange={(e)=> setSmtpFrom(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>To (kam poslat test)</FormLabel>
|
||||
<Input placeholder="you@example.com" value={smtpTo} onChange={(e)=> setSmtpTo(e.target.value)} />
|
||||
</FormControl>
|
||||
</HStack>
|
||||
<FormControl>
|
||||
<FormLabel>Předmět</FormLabel>
|
||||
<Input value={smtpSubject} onChange={(e)=> setSmtpSubject(e.target.value)} />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Tělo zprávy (HTML)</FormLabel>
|
||||
<Textarea rows={6} value={smtpBody} onChange={(e)=> setSmtpBody(e.target.value)} />
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={smtpModal.onClose}>Zavřít</Button>
|
||||
<Button colorScheme="blue" onClick={()=> adminSmtpTestMutation.mutate()} isLoading={adminSmtpTestMutation.isLoading}>Odeslat test</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Test Email Modal */}
|
||||
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
|
||||
<ModalOverlay />
|
||||
|
||||
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
const HEIGHT_MIN = 0;
|
||||
const HEIGHT_MAX = 250;
|
||||
const WEIGHT_MIN = 0;
|
||||
const WEIGHT_MAX = 200;
|
||||
const WEIGHT_MAX = 400;
|
||||
|
||||
// Local state to persist partial DOB selections so the user sees what they picked
|
||||
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
|
||||
@@ -325,6 +325,11 @@ const PlayersAdminPage: React.FC = () => {
|
||||
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Require date of birth: all three values must be selected
|
||||
if (!dobParts.day || !dobParts.month || !dobParts.year) {
|
||||
toast({ title: 'Datum narození je povinné', description: 'Vyberte den, měsíc i rok.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
// Build payload by including only present values to satisfy backend validation
|
||||
const payload: any = {
|
||||
first_name: fn,
|
||||
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
|
||||
{/* Custom DOB picker: day / month / year (timezone-safe) */}
|
||||
<FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Datum narození</FormLabel>
|
||||
<HStack>
|
||||
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
|
||||
<option value="">Den</option>
|
||||
<option value="" disabled>Den</option>
|
||||
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
|
||||
<option value="">Měsíc</option>
|
||||
<option value="" disabled>Měsíc</option>
|
||||
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||
</Select>
|
||||
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
|
||||
<option value="">Rok</option>
|
||||
<option value="" disabled>Rok</option>
|
||||
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
|
||||
</Select>
|
||||
</HStack>
|
||||
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<FormControl>
|
||||
<FormLabel>Fotka</FormLabel>
|
||||
<HStack>
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
|
||||
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
|
||||
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
|
||||
<Input
|
||||
type="file"
|
||||
|
||||
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Validate that all options have text
|
||||
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
|
||||
if (invalidOptions.length > 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Všechny možnosti musí mít vyplněný text',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
if (formData.type !== 'rating') {
|
||||
const invalidOptions = formData.options.filter(
|
||||
(opt) => !opt.text || opt.text.trim() === ''
|
||||
);
|
||||
if (invalidOptions.length > 0) {
|
||||
toast({
|
||||
title: 'Chyba',
|
||||
description: 'Všechny možnosti musí mít vyplněný text',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (editingPoll) {
|
||||
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
|
||||
}
|
||||
}, [isOpen, clubVideos.length, toast]);
|
||||
|
||||
// Keep rating polls consistent: enforce style and auto-generate options
|
||||
useEffect(() => {
|
||||
if (formData.type !== 'rating') return;
|
||||
const currentStyle = (formData as any).style || 'auto';
|
||||
const desiredStyle = currentStyle === 'rating-scale' ? 'rating-scale' : 'rating-stars';
|
||||
const count = desiredStyle === 'rating-scale' ? 10 : 5;
|
||||
const optionsMatch =
|
||||
formData.options.length === count &&
|
||||
formData.options.every((opt, idx) => String(opt.text) === String(idx + 1));
|
||||
|
||||
if (
|
||||
currentStyle !== desiredStyle ||
|
||||
formData.allow_multiple ||
|
||||
(formData.max_choices || 1) !== 1 ||
|
||||
!optionsMatch
|
||||
) {
|
||||
setFormData({
|
||||
...formData,
|
||||
style: desiredStyle as any,
|
||||
allow_multiple: false,
|
||||
max_choices: 1,
|
||||
options: Array.from({ length: count }).map((_, i) => ({
|
||||
text: String(i + 1),
|
||||
display_order: i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [formData.type, (formData as any).style, formData.options, formData.allow_multiple, formData.max_choices]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
draft: 'gray',
|
||||
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Základní</Tab>
|
||||
<Tab>Možnosti</Tab>
|
||||
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
|
||||
<Tab>Nastavení</Tab>
|
||||
</TabList>
|
||||
|
||||
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
|
||||
{/* Basic Info Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<HStack w="full" justify="space-between">
|
||||
<Text fontWeight="semibold">Doporučené předvolby</Text>
|
||||
<HStack>
|
||||
<Button size="sm" onClick={() => applyPreset('rating5')}>Hodnocení (5 hvězd)</Button>
|
||||
<Button size="sm" onClick={() => applyPreset('rating10')}>Hodnocení (1–10)</Button>
|
||||
<Button size="sm" onClick={() => applyPreset('attendance')}>Docházka</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Název ankety</FormLabel>
|
||||
<Input
|
||||
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
|
||||
</FormControl>
|
||||
</SimpleGrid>
|
||||
|
||||
{formData.type === 'rating' && (
|
||||
<Box w="full" borderWidth="1px" borderRadius="md" p={3} bg="gray.50">
|
||||
<Text fontSize="sm" mb={2}>Možnosti se generují automaticky podle stylu:</Text>
|
||||
<Text fontSize="sm">
|
||||
{Array.from({ length: ((formData as any).style === 'rating-scale' ? 10 : 5) })
|
||||
.map((_, i) => String(i + 1))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SimpleGrid columns={2} spacing={4} w="full">
|
||||
<FormControl>
|
||||
<FormLabel>Datum zahájení</FormLabel>
|
||||
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
|
||||
</TabPanel>
|
||||
|
||||
{/* Options Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{formData.options.map((option, index) => (
|
||||
<Card key={index}>
|
||||
<CardBody>
|
||||
<HStack align="start">
|
||||
<VStack flex={1} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'text', e.target.value)
|
||||
}
|
||||
placeholder="Text možnosti"
|
||||
{formData.type !== 'rating' && (
|
||||
<TabPanel>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{formData.options.map((option, index) => (
|
||||
<Card key={index}>
|
||||
<CardBody>
|
||||
<HStack align="start">
|
||||
<VStack flex={1} spacing={3}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||
<Input
|
||||
value={option.text}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'text', e.target.value)
|
||||
}
|
||||
placeholder="Text možnosti"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={option.description || ''}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Doplňující informace"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{formData.options.length > 2 && (
|
||||
<IconButton
|
||||
aria-label="Odstranit možnost"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(index)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>Popis (volitelné)</FormLabel>
|
||||
<Input
|
||||
value={option.description || ''}
|
||||
onChange={(e) =>
|
||||
updateOption(index, 'description', e.target.value)
|
||||
}
|
||||
placeholder="Doplňující informace"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
{formData.options.length > 2 && (
|
||||
<IconButton
|
||||
aria-label="Odstranit možnost"
|
||||
icon={<DeleteIcon />}
|
||||
colorScheme="red"
|
||||
variant="ghost"
|
||||
onClick={() => removeOption(index)}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={addOption}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
<Button
|
||||
leftIcon={<AddIcon />}
|
||||
onClick={addOption}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
>
|
||||
Přidat možnost
|
||||
</Button>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
<TabPanel>
|
||||
<VStack spacing={4}>
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_multiple}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{formData.type !== 'rating' && (
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||
<Switch
|
||||
isChecked={formData.allow_multiple}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{formData.allow_multiple && (
|
||||
{formData.type !== 'rating' && formData.allow_multiple && (
|
||||
<FormControl>
|
||||
<FormLabel>Max. počet voleb</FormLabel>
|
||||
<NumberInput
|
||||
|
||||
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
|
||||
smtp_from: (settings as any).smtp_from,
|
||||
smtp_from_name: (settings as any).smtp_from_name,
|
||||
smtp_encryption: (settings as any).smtp_encryption as any,
|
||||
smtp_auth: (settings as any).smtp_auth as any,
|
||||
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
|
||||
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
|
||||
// videos module
|
||||
videos_module_enabled: (settings as any).videos_module_enabled as any,
|
||||
@@ -193,8 +193,9 @@ const SettingsAdminPage: React.FC = () => {
|
||||
location_latitude: (settings as any).location_latitude as any,
|
||||
location_longitude: (settings as any).location_longitude as any,
|
||||
map_zoom_level: (settings as any).map_zoom_level as any,
|
||||
// Auto-enable map display if coordinates are set
|
||||
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
|
||||
show_map_on_homepage:
|
||||
(typeof (settings as any).location_latitude === 'number') &&
|
||||
(typeof (settings as any).location_longitude === 'number'),
|
||||
map_style: (settings as any).map_style,
|
||||
// homepage matches display
|
||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useToast,
|
||||
Text,
|
||||
VStack,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
Link as ChakraLink,
|
||||
Badge,
|
||||
} from '@chakra-ui/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
|
||||
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
|
||||
|
||||
const ShortlinksAdminPage: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [targetUrl, setTargetUrl] = React.useState('');
|
||||
const [title, setTitle] = React.useState('');
|
||||
const [code, setCode] = React.useState('');
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const statsModal = useDisclosure();
|
||||
const [statsLink, setStatsLink] = React.useState<any>(null);
|
||||
const [statsData, setStatsData] = React.useState<any>(null);
|
||||
|
||||
const linksQ = useQuery({
|
||||
queryKey: ['admin-shortlinks'],
|
||||
queryFn: listShortLinks,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
const t = targetUrl.trim();
|
||||
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
|
||||
await navigator.clipboard.writeText(res.short_url);
|
||||
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
|
||||
setTargetUrl(''); setTitle(''); setCode('');
|
||||
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openStats = async (item: any) => {
|
||||
try {
|
||||
setStatsLink(item);
|
||||
setStatsData(null);
|
||||
statsModal.onOpen();
|
||||
const data = await getShortLinkStats(item.id);
|
||||
setStatsData(data);
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Načtení statistik selhalo', status: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
|
||||
<IconButton aria-label="Obnovit" icon={<FiRefreshCcw />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-shortlinks'] })} />
|
||||
</HStack>
|
||||
|
||||
{/* Create form */}
|
||||
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="bg.card">
|
||||
<Text fontWeight="semibold" mb={2}>Vytvořit nový odkaz</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
|
||||
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
|
||||
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
|
||||
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* List */}
|
||||
<Box borderWidth="1px" borderRadius="lg" overflowX="auto" bg="bg.card">
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Kód</Th>
|
||||
<Th>Cíl</Th>
|
||||
<Th>Titulek</Th>
|
||||
<Th>Zdroj</Th>
|
||||
<Th>Prokliky</Th>
|
||||
<Th>Akce</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{linksQ.data?.items?.map((it: any) => {
|
||||
const shortUrl = `${window.location.origin}/s/${it.code}`;
|
||||
const source = it.source_type ? `${it.source_type}${it.source_id ? `#${it.source_id}` : ''}` : '-';
|
||||
return (
|
||||
<Tr key={it.id}>
|
||||
<Td><Badge colorScheme="blue">{it.code}</Badge></Td>
|
||||
<Td maxW="420px">
|
||||
<ChakraLink href={it.target_url} isExternal color="blue.600">{it.target_url}</ChakraLink>
|
||||
</Td>
|
||||
<Td>{it.title || '-'}</Td>
|
||||
<Td>{source}</Td>
|
||||
<Td>{it.click_count ?? 0}</Td>
|
||||
<Td>
|
||||
<HStack>
|
||||
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
|
||||
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
|
||||
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
|
||||
</HStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
{(!linksQ.data?.items || linksQ.data.items.length === 0) && (
|
||||
<Tr><Td colSpan={6}><Text p={3}>Žádné odkazy</Text></Td></Tr>
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Stats modal */}
|
||||
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Statistiky: {statsLink?.code}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{!statsData ? (
|
||||
<Text>Načítání…</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>Prokliky za posledních 30 dní</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Den</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{statsData.timeseries?.map((row: any, idx: number) => (
|
||||
<Tr key={idx}><Td>{row.date}</Td><Td isNumeric>{row.count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>Referrers (Top)</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Referrer</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{(statsData.referrers || []).map((r: any, i: number) => (
|
||||
<Tr key={i}><Td>{r.Referrer || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" mb={1}>UTM kombinace (Top)</Text>
|
||||
<Table size="sm" variant="simple">
|
||||
<Thead><Tr><Th>Source</Th><Th>Medium</Th><Th>Campaign</Th><Th isNumeric>Počet</Th></Tr></Thead>
|
||||
<Tbody>
|
||||
{(statsData.utms || []).map((r: any, i: number) => (
|
||||
<Tr key={i}><Td>{r.Source || '-'}</Td><Td>{r.Medium || '-'}</Td><Td>{r.Campaign || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={statsModal.onClose}>Zavřít</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortlinksAdminPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
|
||||
import {
|
||||
Heading,
|
||||
Text,
|
||||
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
|
||||
});
|
||||
|
||||
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
|
||||
const mainClubId: string | undefined = (data?.club_id ? String(data.club_id).toLowerCase() : undefined);
|
||||
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
|
||||
// Backend origin (used to resolve relative URLs like /uploads/...)
|
||||
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||
|
||||
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
if (r.team_id) teamIds.add(r.team_id);
|
||||
else {
|
||||
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (derived) teamIds.add(derived);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ const TeamsAdminPage = () => {
|
||||
// Unify various dash characters to a simple hyphen
|
||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
||||
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
|
||||
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||
const orgPhrases = [
|
||||
'fotbalovy klub',
|
||||
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
|
||||
'futsal',
|
||||
];
|
||||
for (const phrase of orgPhrases) {
|
||||
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
|
||||
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
|
||||
out = out.replace(re, ' ');
|
||||
}
|
||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||
@@ -152,32 +160,46 @@ const TeamsAdminPage = () => {
|
||||
}
|
||||
return idx;
|
||||
}, [byName]);
|
||||
|
||||
// Derive FACR team UUID from the logo URL if team_id is missing in the row
|
||||
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
|
||||
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
|
||||
try {
|
||||
const u = String(url || '');
|
||||
if (!u) return undefined;
|
||||
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
||||
return m ? m[0].toLowerCase() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
|
||||
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
// Priority 0: Admin override by team ID
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
||||
const u = String(overridesById[teamId].logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
|
||||
// Priority 2: Try exact match from local overrides
|
||||
// Priority 1: Local admin override (exact + normalized)
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) {
|
||||
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
|
||||
const norm = normalize(teamName);
|
||||
overrideUrl = byNameNormalized[norm];
|
||||
}
|
||||
|
||||
// Priority 3: Use override if found
|
||||
if (overrideUrl) {
|
||||
// Resolve against backend for relative assets
|
||||
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
||||
return assetUrl(overrideUrl) as string;
|
||||
}
|
||||
return overrideUrl;
|
||||
}
|
||||
|
||||
// Priority 4: Use FACR original
|
||||
// Priority 2: logoapi.sportcreative.eu if we have a team ID
|
||||
if (teamId && sportLogosMap[teamId]) {
|
||||
return sportLogosMap[teamId];
|
||||
}
|
||||
|
||||
// Priority 3: FACR original
|
||||
if (original) {
|
||||
return original;
|
||||
}
|
||||
@@ -186,6 +208,13 @@ const TeamsAdminPage = () => {
|
||||
return '/dist/img/logo-club-empty.svg';
|
||||
};
|
||||
|
||||
const getName = (teamName?: string, teamId?: string) => {
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
||||
}
|
||||
return String(teamName || '');
|
||||
};
|
||||
|
||||
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
|
||||
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
||||
// Selected competition for quick switching (only applies in table mode)
|
||||
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
|
||||
name: string; // representative name
|
||||
logo: string;
|
||||
variants: string[]; // all raw names found
|
||||
teamId?: string;
|
||||
};
|
||||
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
|
||||
const map: Record<string, TeamAggregate> = {};
|
||||
for (const comp of competitions) {
|
||||
const rows: TableRow[] = comp?.table?.overall || [];
|
||||
for (const r of rows) {
|
||||
const teamName = (r.team || '').trim();
|
||||
if (!teamName) continue;
|
||||
const key = normalize(teamName);
|
||||
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
|
||||
const rawName = (r.team || '').trim();
|
||||
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
if (!teamId && mainClubId) {
|
||||
const rn = normalize(rawName);
|
||||
if (
|
||||
rn === mainClubBase ||
|
||||
rn.endsWith(' ' + mainClubBase) ||
|
||||
rn.startsWith(mainClubBase + ' ') ||
|
||||
rn.includes(' ' + mainClubBase + ' ')
|
||||
) {
|
||||
teamId = mainClubId;
|
||||
}
|
||||
}
|
||||
const canonicalName = getName(rawName, teamId);
|
||||
if (!canonicalName) continue;
|
||||
const key = teamId ? `id:${teamId}` : normalize(canonicalName);
|
||||
const logo = getLogo(canonicalName, teamId, r.team_logo_url);
|
||||
if (!map[key]) {
|
||||
map[key] = { key, name: teamName, logo, variants: [teamName] };
|
||||
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
|
||||
} else {
|
||||
map[key].variants.push(teamName);
|
||||
map[key].variants.push(rawName);
|
||||
map[key].variants.push(canonicalName);
|
||||
// Update logo - prefer non-empty logos
|
||||
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
|
||||
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
|
||||
if (currentIsEmpty && newIsNotEmpty) {
|
||||
map[key].logo = logo as string;
|
||||
}
|
||||
if (!map[key].teamId && teamId) {
|
||||
map[key].teamId = teamId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort by representative name
|
||||
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
|
||||
}, [competitions, getLogo]);
|
||||
}, [competitions, getLogo, mainClubBase, mainClubId]);
|
||||
|
||||
// Fast lookup from normalized name to variant list
|
||||
const variantsByKey = useMemo(() => {
|
||||
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
|
||||
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
|
||||
|
||||
const showExternalUploadInfo = useMemo(() => {
|
||||
try {
|
||||
if (uploadedFile) return true;
|
||||
const raw = (form.logo_url || '').trim();
|
||||
if (!raw) return false;
|
||||
const abs = raw.startsWith('/') ? new URL(raw, backendOrigin).toString() : raw;
|
||||
const u = new URL(abs);
|
||||
const host = u.hostname.toLowerCase();
|
||||
const path = u.pathname;
|
||||
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
|
||||
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
|
||||
const isLogoAPI = host === 'logoapi.sportcreative.eu';
|
||||
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
|
||||
return !isFacr && !isLogoAPI && isLocalUpload;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [uploadedFile, form.logo_url, backendOrigin]);
|
||||
|
||||
// Club search
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
|
||||
enabled: debounced.trim().length >= 2,
|
||||
});
|
||||
|
||||
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
|
||||
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[], teamId?: string) => {
|
||||
// If variants not explicitly provided (e.g., from table view), compute from normalized key
|
||||
let v = variantNames;
|
||||
if (!v || v.length === 0) {
|
||||
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
|
||||
v = variantsByKey[key] || [];
|
||||
}
|
||||
setSelected({ teamName, teamLogoUrl, variantNames: v });
|
||||
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
|
||||
setForm({ external_team_id: teamId || '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
|
||||
setQuery(teamName || '');
|
||||
setIsOpen(true);
|
||||
};
|
||||
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
|
||||
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||
}
|
||||
let logoUrl = (form.logo_url || '').trim();
|
||||
const primaryName = (selected?.teamName || form.team_name || '').trim();
|
||||
// Prefer the edited input over the pre-selected name
|
||||
const primaryName = (form.team_name || selected?.teamName || '').trim();
|
||||
// All variants to update (deduped), always include the primary name
|
||||
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
|
||||
if (logoUrl) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
||||
try {
|
||||
if (!uploadedFile && form.external_team_id) {
|
||||
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||
if (apiLogo) {
|
||||
logoUrl = apiLogo;
|
||||
}
|
||||
if (logoFileToUpload) {
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (logoUrl) {
|
||||
let shouldUpload = Boolean(uploadedFile);
|
||||
try {
|
||||
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
|
||||
const u = new URL(abs);
|
||||
const host = u.hostname.toLowerCase();
|
||||
const path = u.pathname;
|
||||
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
|
||||
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
|
||||
const isLogoAPI = host === 'logoapi.sportcreative.eu';
|
||||
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
|
||||
if (!shouldUpload) {
|
||||
shouldUpload = isLocalUpload;
|
||||
}
|
||||
if (isFacr || isLogoAPI) {
|
||||
shouldUpload = false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (shouldUpload) {
|
||||
setExternalUploadStatus('uploading');
|
||||
setExternalUploadError(null);
|
||||
try {
|
||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
||||
if (!logoFileToUpload && logoUrl) {
|
||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
if (logoFileToUpload) {
|
||||
const logaResult = await uploadToLogaSportcreative(
|
||||
form.external_team_id,
|
||||
logoFileToUpload,
|
||||
{
|
||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
||||
}
|
||||
);
|
||||
if (logaResult.success) {
|
||||
setExternalUploadStatus('success');
|
||||
if (logaResult.url) {
|
||||
logoUrl = logaResult.url;
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
}
|
||||
} else {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
||||
setExternalUploadError('Could not fetch logo file');
|
||||
}
|
||||
} else {
|
||||
} catch (error: any) {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError('Could not fetch logo file');
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setExternalUploadStatus('error');
|
||||
setExternalUploadError(error?.message || 'Upload failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Save override for each variant name so editing one updates all duplicates
|
||||
await Promise.all(
|
||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
||||
);
|
||||
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
|
||||
<Td py={1.5}>
|
||||
<HStack spacing={2} align="center">
|
||||
<Image
|
||||
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
|
||||
alt={r.team}
|
||||
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
|
||||
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
|
||||
boxSize="24px"
|
||||
objectFit="contain"
|
||||
/>
|
||||
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
|
||||
<Text fontSize="xs" noOfLines={1}>{getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}</Text>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
|
||||
@@ -504,7 +596,12 @@ const TeamsAdminPage = () => {
|
||||
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
|
||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||
<Td py={1.5}>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
|
||||
<Button size="xs" fontSize="xs" onClick={() => {
|
||||
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||
const displayName = getName(r.team, tid);
|
||||
const key = tid ? `id:${tid}` : normalize(displayName);
|
||||
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
|
||||
}}>Upravit</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
|
||||
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants, t.teamId)} w="full">Upravit</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
|
||||
px={3}
|
||||
py={2}
|
||||
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
|
||||
setQuery(r.name);
|
||||
try {
|
||||
const apiUrl = await fetchLogoFromLogoAPI(r.id, r.name);
|
||||
if (apiUrl) {
|
||||
setForm((f) => ({ ...f, logo_url: apiUrl }));
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
<HStack justify="space-between" spacing={3}>
|
||||
@@ -656,7 +759,7 @@ const TeamsAdminPage = () => {
|
||||
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
|
||||
</Alert>
|
||||
)}
|
||||
{form.logo_url && (
|
||||
{showExternalUploadInfo && (
|
||||
<Alert status="info" variant="left-accent">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={1}>
|
||||
|
||||
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
|
||||
Správa uživatelských účtů a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> má přístup ke všem funkcím.
|
||||
</Text>
|
||||
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
|
||||
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
onClick={() => openEditModal(user)}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<Heading size="md" mb={2}>Admini a editoři</Heading>
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.filter(u => u.role !== 'fan').map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
||||
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem onClick={async () => {
|
||||
try {
|
||||
await api.post(`/admin/users/${user.id}/reset-password`);
|
||||
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
|
||||
} catch (e: any) {
|
||||
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
|
||||
const errorDetails = e?.response?.data?.details;
|
||||
toast({
|
||||
title: 'Chyba při odesílání resetu hesla',
|
||||
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
|
||||
status: 'error',
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
|
||||
}
|
||||
}}>
|
||||
Odeslat reset hesla
|
||||
</MenuItem>
|
||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem
|
||||
icon={<DeleteIcon />}
|
||||
color="red.500"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
<Heading size="md" mb={2}>Fanoušci</Heading>
|
||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
||||
<Table variant="simple">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Email</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Actions</Th>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{users.filter(u => u.role === 'fan').map((user) => (
|
||||
<Tr key={user.id}>
|
||||
<Td>{user.name}</Td>
|
||||
<Td>{user.email}</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={'gray'}>
|
||||
Fan
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||
<Td>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
aria-label="Options"
|
||||
icon={<HamburgerIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{String(authUser?.id) !== String(user.id) && (
|
||||
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Add/Edit User Modal */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
||||
|
||||
@@ -1,31 +1,93 @@
|
||||
import { Box, Heading, Text, List, ListItem, Link, Container } from '@chakra-ui/react';
|
||||
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, Button, useColorModeValue } from '@chakra-ui/react';
|
||||
import MainLayout from '../../components/layout/MainLayout';
|
||||
|
||||
const CookiePolicyPage: React.FC = () => {
|
||||
const textColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const headingColor = useColorModeValue('gray.900', 'gray.100');
|
||||
const boxBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const boxText = useColorModeValue('blue.900', 'blue.100');
|
||||
|
||||
const openCookieSettings = () => {
|
||||
window.dispatchEvent(new Event('cookie-consent-open'));
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="3xl">
|
||||
<Heading as="h1" size="lg" mb={4}>Pravidla používání souborů Cookies</Heading>
|
||||
<Text mb={4}>
|
||||
Tento web používá soubory cookies pro zajištění správného fungování webu, analýzu návštěvnosti a zlepšení nabízených služeb.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Co jsou cookies?</Heading>
|
||||
<Text mb={4}>
|
||||
Cookies jsou malé textové soubory, které se ukládají do vašeho zařízení při návštěvě webových stránek. Umožňují webu rozpoznat vaše zařízení a přizpůsobit obsah.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Jaké typy cookies používáme</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6}>
|
||||
<ListItem>Nezbytné cookies – zajišťují základní funkce webu.</ListItem>
|
||||
<ListItem>Preferenční cookies – pamatují si vaše volby (např. jazyk).</ListItem>
|
||||
<ListItem>Analytické cookies – pomáhají nám porozumět, jak web používáte (anonymně).</ListItem>
|
||||
</List>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>Správa cookies</Heading>
|
||||
<Text mb={4}>
|
||||
Používání cookies můžete upravit nebo zakázat v nastavení vašeho prohlížeče. Upozorňujeme, že vypnutí některých cookies může ovlivnit funkčnost webu.
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
V případě dotazů nás kontaktujte na adrese <Link href="/kontakt">/kontakt</Link>.
|
||||
</Text>
|
||||
<Container maxW="3xl" py={8}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Heading as="h1" size="xl" mb={2} color={headingColor}>Pravidla používání souborů cookies</Heading>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
|
||||
<Box bg={boxBg} p={4} borderRadius="md">
|
||||
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||
<Text fontSize="sm" color={boxText}>
|
||||
Cookies používáme pro zajištění nezbytných funkcí webu, zlepšení uživatelského zážitku a měření návštěvnosti. Nepovinné cookies ukládáme pouze na základě vašeho souhlasu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Co jsou cookies?</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Cookies jsou malé textové soubory ukládané do vašeho zařízení při návštěvě webu. Umožňují rozpoznat prohlížeč, pamatovat si vaše volby a zajistit fungování webu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Jaké cookies používáme</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné:</strong> zajišťují bezpečnost a základní funkce webu. Vždy aktivní.</ListItem>
|
||||
<ListItem><strong>Preferenční:</strong> pamatují si vaše volby (např. jazyk, zobrazení).</ListItem>
|
||||
<ListItem><strong>Analytické:</strong> anonymní měření návštěvnosti a výkonu stránek.</ListItem>
|
||||
<ListItem><strong>Marketingové:</strong> přizpůsobení obsahu a případného marketingu.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Právní základy a účely</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné cookies:</strong> oprávněný zájem na provozu a zabezpečení webu (GDPR čl. 6 odst. 1 písm. f).</ListItem>
|
||||
<ListItem><strong>Preferenční, analytické, marketingové:</strong> pouze na základě vašeho souhlasu (GDPR čl. 6 odst. 1 písm. a), který můžete kdykoli odvolat.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Správa souhlasu</Heading>
|
||||
<Text color={textColor} mb={3}>
|
||||
Nastavení cookies můžete kdykoli změnit pomocí tlačítka níže nebo v nastavení prohlížeče. Odvolání souhlasu nemá vliv na zákonnost předchozího zpracování.
|
||||
</Text>
|
||||
<Button onClick={openCookieSettings} colorScheme="blue" size="sm">Otevřít nastavení cookies</Button>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Doba uchovávání</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Nezbytné cookies:</strong> po dobu nezbytnou pro fungování relace či bezpečnost.</ListItem>
|
||||
<ListItem><strong>Preferenční a analytické:</strong> typicky 6–26 měsíců, nebo do odvolání souhlasu.</ListItem>
|
||||
<ListItem><strong>Marketingové:</strong> dle konkrétní kategorie, nejdéle do odvolání souhlasu.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Třetí strany</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Pro analytiku nebo měření můžeme využívat nástroje třetích stran. Tyto služby zpracovávají údaje pouze v rozsahu nezbytném pro poskytování měření a jsou vázány smluvními závazky ochrany údajů.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. Další informace</Heading>
|
||||
<Text color={textColor} mb={2}>
|
||||
Podrobnosti o zpracování osobních údajů najdete v <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásadách ochrany osobních údajů</Link>.
|
||||
</Text>
|
||||
<Text color={textColor}>
|
||||
V případě dotazů nás kontaktujte prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const PrivacyPolicyPage: React.FC = () => {
|
||||
<ListItem><strong>Kontaktní údaje:</strong> jméno, příjmení, e-mailová adresa, telefonní číslo - poskytnuté dobrovolně prostřednictvím kontaktního formuláře nebo při registraci k newsletteru</ListItem>
|
||||
<ListItem><strong>IP adresa:</strong> automaticky zaznamenávaná při návštěvě webu pro účely bezpečnosti, analýzy návštěvnosti a prevence zneužití</ListItem>
|
||||
<ListItem><strong>Technické údaje:</strong> informace o zařízení, prohlížeči, operačním systému, datum a čas návštěvy, navštívené stránky</ListItem>
|
||||
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
|
||||
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/pravidla-cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
|
||||
<ListItem><strong>Analytické údaje:</strong> anonymizovaná data o chování na webu prostřednictvím analytických nástrojů</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
@@ -1,30 +1,108 @@
|
||||
import { Container, Heading, Text, List, ListItem } from '@chakra-ui/react';
|
||||
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, useColorModeValue } from '@chakra-ui/react';
|
||||
import MainLayout from '../../components/layout/MainLayout';
|
||||
|
||||
const TermsPage: React.FC = () => {
|
||||
const textColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const headingColor = useColorModeValue('gray.900', 'gray.100');
|
||||
const boxBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const boxText = useColorModeValue('blue.900', 'blue.100');
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Container maxW="3xl">
|
||||
<Heading as="h1" size="lg" mb={4}>Obchodní podmínky</Heading>
|
||||
<Text mb={4}>
|
||||
Tyto obchodní podmínky upravují používání webových stránek a poskytované služby.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>1. Obecná ustanovení</Heading>
|
||||
<Text mb={4}>
|
||||
Provozovatelem webu je subjekt uvedený v kontaktech. Používáním webu vyjadřujete souhlas s těmito podmínkami.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>2. Obsah webu</Heading>
|
||||
<Text mb={4}>
|
||||
Veškerý obsah je poskytován pro informační účely. Provozovatel si vyhrazuje právo na změny bez předchozího upozornění.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>3. Odpovědnost</Heading>
|
||||
<Text mb={4}>
|
||||
Provozovatel nenese odpovědnost za škody vzniklé v souvislosti s používáním webu, ledaže je stanoveno jinak právními předpisy.
|
||||
</Text>
|
||||
<Heading as="h2" size="md" mt={6} mb={2}>4. Kontakt</Heading>
|
||||
<Text mb={4}>
|
||||
V případě dotazů nás kontaktujte na stránce Kontakt.
|
||||
</Text>
|
||||
<Container maxW="3xl" py={8}>
|
||||
<VStack align="stretch" spacing={6}>
|
||||
<Heading as="h1" size="xl" mb={2} color={headingColor}>Obchodní podmínky</Heading>
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||
</Text>
|
||||
|
||||
<Box bg={boxBg} p={4} borderRadius="md">
|
||||
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||
<Text fontSize="sm" color={boxText}>
|
||||
Používáním tohoto webu souhlasíte s níže uvedenými podmínkami. Obsah slouží pro informační účely, bez záruky úplnosti a správnosti. Provozovatel může podmínky a obsah kdykoli měnit.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Provozovatel a kontakt</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Provozovatelem webových stránek je subjekt uvedený v sekci <Link href="/kontakt" color="brand.primary">Kontakt</Link>. Pro veškeré dotazy nebo podněty prosím využijte uvedené kontaktní údaje.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Rozsah a přijetí podmínek</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Tyto podmínky upravují používání tohoto webu a souvisejících služeb (např. newsletter). Vstupem na web a jeho používáním vyjadřujete souhlas s těmito podmínkami.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Obsah, zdroje dat a práva k duševnímu vlastnictví</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem><strong>Autorská práva:</strong> Texty, grafika a prvky webu podléhají ochraně autorského práva. Kopírování či šíření je možné pouze s předchozím souhlasem.</ListItem>
|
||||
<ListItem><strong>Externí data FAČR:</strong> Informace o zápasech, soutěžích a tabulkách pocházejí z veřejných zdrojů FAČR (<Link href="https://www.fotbal.cz" isExternal color="brand.primary">www.fotbal.cz</Link>) a jsou majetkem FAČR. Zobrazujeme je výhradně pro informační účely.</ListItem>
|
||||
<ListItem><strong>Loga klubů a týmů:</strong> Loga náleží příslušným klubům a organizacím a slouží pouze k identifikaci.</ListItem>
|
||||
<ListItem><strong>Fotografie a videa:</strong> Mediální obsah může pocházet z různých zdrojů, včetně YouTube a Zonerama. Veškerá práva náleží původním autorům.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Pravidla chování uživatelů</Heading>
|
||||
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
|
||||
<ListItem>Neporušujte právní předpisy ani práva třetích osob.</ListItem>
|
||||
<ListItem>Nevkládejte škodlivý kód a nepokoušejte se narušit bezpečnost webu.</ListItem>
|
||||
<ListItem>Nevyužívejte web k nevyžádané reklamě ani automatizovanému sběru dat (scraping) v rozporu s podmínkami.</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Newsletter a účty</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Přihlášení k odběru newsletteru je dobrovolné a lze jej kdykoli odhlásit prostřednictvím odkazu v e‑mailu nebo na stránce <Link href="/newsletter/preferences" color="brand.primary">nastavení newsletteru</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Odpovědnost a záruky</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Obsah webu je poskytován „tak jak je“, bez jakýchkoli záruk. Provozovatel neodpovídá za případné škody vzniklé užíváním webu, v nejširším rozsahu povoleném právem. Neodpovídáme za dostupnost a obsah externích odkazů (např. FAČR, YouTube, Zonerama).
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. E‑shop a externí služby</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Pokud web odkazuje na externí e‑shop nebo platební službu, nákup se řídí podmínkami daného poskytovatele. Provozovatel tohoto webu není stranou takového smluvního vztahu, pokud není výslovně uvedeno jinak.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>8. Ochrana osobních údajů a cookies</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Zpracování osobních údajů se řídí dokumentem <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásady ochrany osobních údajů</Link>. Informace o cookies naleznete v <Link href="/pravidla-cookies" color="brand.primary">Pravidlech používání cookies</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>9. Změny podmínek</Heading>
|
||||
<Text color={textColor} mb={4}>
|
||||
Provozovatel si vyhrazuje právo tyto podmínky kdykoli upravit nebo doplnit. Změny jsou účinné zveřejněním na tomto webu.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>10. Rozhodné právo</Heading>
|
||||
<Text color={textColor} mb={2}>
|
||||
Tyto podmínky se řídí právním řádem České republiky. Případné spory budou řešeny u příslušných soudů České republiky.
|
||||
</Text>
|
||||
<Text color={textColor}>
|
||||
Máte‑li dotazy, kontaktujte nás prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
|
||||
fullUrl = `${apiOrigin}${logoUrl}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl);
|
||||
const apiOrigin = new URL(API_URL).origin;
|
||||
let fetchUrl = fullUrl;
|
||||
try {
|
||||
const u = new URL(fullUrl);
|
||||
if (u.origin !== apiOrigin) {
|
||||
fetchUrl = `${apiOrigin}/api/v1/proxy/image?url=${encodeURIComponent(fullUrl)}`;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
return await response.blob();
|
||||
|
||||
@@ -101,6 +101,8 @@ export async function getArticles(params: {
|
||||
featured?: boolean;
|
||||
q?: string;
|
||||
slug?: string;
|
||||
match_id?: string | number;
|
||||
month?: string; // YYYY-MM
|
||||
} = {}) {
|
||||
// Backend returns shape: { items, total, page, page_size }
|
||||
// Normalize to { data, total, page, page_size } expected by the frontend.
|
||||
|
||||
@@ -49,10 +49,14 @@ const resolveBackendUrl = (path: string) => {
|
||||
|
||||
// Lazy-load public overrides with lightweight cache
|
||||
let overridesCache: { data: any; ts: number } | null = null;
|
||||
const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
type OverridesPayload = {
|
||||
by_name?: Record<string, string>;
|
||||
by_id?: Record<string, { name?: string; logo_url?: string }>;
|
||||
};
|
||||
const loadOverrides = async (): Promise<OverridesPayload> => {
|
||||
const now = Date.now();
|
||||
if (overridesCache && now - overridesCache.ts < 60_000) {
|
||||
return (overridesCache.data?.by_name || {}) as Record<string, string>;
|
||||
return (overridesCache.data || {}) as OverridesPayload;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
|
||||
@@ -65,7 +69,7 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
// Invalidate internal FACR GET cache so consumers refetch with new logos
|
||||
cache.clear();
|
||||
}
|
||||
return (json?.by_name || {}) as Record<string, string>;
|
||||
return (json || {}) as OverridesPayload;
|
||||
}
|
||||
} catch {}
|
||||
// Fallback to cached file if API failed
|
||||
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
|
||||
if (prev !== next) {
|
||||
cache.clear();
|
||||
}
|
||||
return (json?.by_name || {}) as Record<string, string>;
|
||||
return (json || {}) as OverridesPayload;
|
||||
}
|
||||
} catch {}
|
||||
overridesCache = { data: { by_name: {} }, ts: now };
|
||||
return {};
|
||||
return { by_name: {} };
|
||||
};
|
||||
|
||||
// Name normalization helpers
|
||||
@@ -99,14 +103,21 @@ const stripPrefixes = (s: string) => {
|
||||
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
|
||||
return x.replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) => {
|
||||
const applyOverridesToClub = (club: ClubInfo, overrides: OverridesPayload) => {
|
||||
if (!club?.competitions?.length) return club;
|
||||
const byName = overrides?.by_name || {};
|
||||
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
|
||||
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
|
||||
acc[norm(k)] = byName[k];
|
||||
return acc;
|
||||
}, {});
|
||||
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
|
||||
const pick = (teamName?: string, original?: string) => {
|
||||
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
|
||||
if (teamId && byId[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url!;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
if (!teamName) return original;
|
||||
const exact = (byName || {})[teamName];
|
||||
const n = norm(teamName);
|
||||
@@ -122,12 +133,18 @@ const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) =>
|
||||
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
|
||||
return chosen;
|
||||
};
|
||||
const pickName = (teamId?: string, original?: string) => {
|
||||
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
|
||||
return (v && v.trim().length > 0) ? v : original;
|
||||
};
|
||||
club.competitions = (club.competitions || []).map((c) => ({
|
||||
...c,
|
||||
matches: (c.matches || []).map((m: any) => ({
|
||||
...m,
|
||||
home_logo_url: pick(m.home, m.home_logo_url),
|
||||
away_logo_url: pick(m.away, m.away_logo_url),
|
||||
home: pickName(m.home_id, m.home),
|
||||
away: pickName(m.away_id, m.away),
|
||||
home_logo_url: pickLogo(m.home_id, m.home, m.home_logo_url),
|
||||
away_logo_url: pickLogo(m.away_id, m.away, m.away_logo_url),
|
||||
})),
|
||||
}));
|
||||
return club;
|
||||
@@ -232,8 +249,8 @@ export const facrApi = {
|
||||
try {
|
||||
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
|
||||
// Load overrides and apply before returning/caching consumers
|
||||
const byName = await loadOverrides();
|
||||
const patched = applyOverridesToClub(response.data, byName);
|
||||
const overrides = await loadOverrides();
|
||||
const patched = applyOverridesToClub(response.data, overrides);
|
||||
return patched;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
@@ -244,7 +261,47 @@ export const facrApi = {
|
||||
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
|
||||
try {
|
||||
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
|
||||
return response.data;
|
||||
const data = response.data as any;
|
||||
const overrides = await loadOverrides();
|
||||
const byName = overrides?.by_name || {};
|
||||
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
|
||||
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
|
||||
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
|
||||
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
|
||||
if (teamId && byId[teamId]?.logo_url) {
|
||||
const v = byId[teamId]!.logo_url!;
|
||||
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
|
||||
return v;
|
||||
}
|
||||
if (!teamName) return original;
|
||||
const exact = (byName || {})[teamName];
|
||||
const n = norm(teamName);
|
||||
let candidate = exact || byNameNorm[n];
|
||||
if (!candidate) {
|
||||
const s = stripPrefixes(teamName);
|
||||
for (const { key, url } of strippedPairs) { if (!key) continue; if (s.endsWith(key) || key.endsWith(s)) { candidate = url; break; } }
|
||||
}
|
||||
const chosen = candidate || original;
|
||||
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
|
||||
return chosen;
|
||||
};
|
||||
const pickName = (teamId?: string, original?: string) => {
|
||||
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
|
||||
return (v && v.trim().length > 0) ? v : original;
|
||||
};
|
||||
if (Array.isArray(data?.competitions)) {
|
||||
data.competitions = data.competitions.map((c: any) => ({
|
||||
...c,
|
||||
table: {
|
||||
overall: (c.table?.overall || []).map((r: any) => ({
|
||||
...r,
|
||||
team: pickName(r.team_id, r.team),
|
||||
team_logo_url: pickLogo(r.team_id, r.team, r.team_logo_url),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,9 @@ export interface PredefinedElement {
|
||||
|
||||
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
||||
// Layout - Rozvržení
|
||||
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
|
||||
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
|
||||
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
|
||||
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
|
||||
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
|
||||
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
|
||||
@@ -155,6 +157,12 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
||||
];
|
||||
|
||||
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
|
||||
'style-pack': [
|
||||
{ value: 'default', label: 'Výchozí', description: 'Základní sjednocený styl' },
|
||||
{ value: 'modern', label: 'Moderní', description: 'Zaoblené rohy, lehké stíny, více prostoru' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Čisté, bez stínů, tenké rámečky' },
|
||||
{ value: 'sparta', label: 'Sparta', description: 'Přiblížení k Sparta packu' },
|
||||
],
|
||||
header: [
|
||||
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
|
||||
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
|
||||
@@ -166,6 +174,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
|
||||
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
|
||||
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
|
||||
],
|
||||
'hero-topbar': [
|
||||
{ value: 'brand', label: 'Brand', description: 'Barevná lišta s klubovými barvami a akcemi' },
|
||||
{ value: 'minimal', label: 'Minimal', description: 'Průhledná/nenápadná lišta' },
|
||||
{ value: 'badge', label: 'Badge', description: 'Pill styl s klubovou barvou' },
|
||||
],
|
||||
hero: [
|
||||
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
|
||||
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
|
||||
|
||||
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
|
||||
event_id?: number;
|
||||
video_url?: string;
|
||||
}): Promise<Poll[]> => {
|
||||
const response = await api.get('/polls', { params });
|
||||
const response = await api.get('/polls', {
|
||||
params,
|
||||
headers: { 'X-Session-Token': generateSessionToken() },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPoll = async (id: number): Promise<PollResponse> => {
|
||||
const response = await api.get(`/polls/${id}`);
|
||||
const token = generateSessionToken();
|
||||
const response = await api.get(`/polls/${id}`, {
|
||||
headers: { 'X-Session-Token': token },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -183,12 +189,20 @@ export const votePoll = async (
|
||||
id: number,
|
||||
data: PollVoteRequest
|
||||
): Promise<{ message: string; poll: Poll }> => {
|
||||
const response = await api.post(`/polls/${id}/vote`, data);
|
||||
const token = data.session_token || generateSessionToken();
|
||||
const response = await api.post(
|
||||
`/polls/${id}/vote`,
|
||||
{ ...data, session_token: token },
|
||||
{ headers: { 'X-Session-Token': token } }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
|
||||
const response = await api.get(`/polls/${id}/results`);
|
||||
const token = generateSessionToken();
|
||||
const response = await api.get(`/polls/${id}/results`, {
|
||||
headers: { 'X-Session-Token': token },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,27 @@ export type Player = {
|
||||
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
|
||||
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
|
||||
|
||||
function normalizePlayer(p: any): Player {
|
||||
if (!p) return p as any;
|
||||
const id = p.id ?? p.ID;
|
||||
return {
|
||||
id: typeof id === 'string' ? Number(id) : id,
|
||||
first_name: p.first_name ?? p.FirstName ?? '',
|
||||
last_name: p.last_name ?? p.LastName ?? '',
|
||||
position: p.position ?? p.Position ?? undefined,
|
||||
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
|
||||
image_url: p.image_url ?? p.ImageURL ?? undefined,
|
||||
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
|
||||
nationality: p.nationality ?? p.Nationality ?? undefined,
|
||||
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
|
||||
height: p.height ?? p.Height ?? undefined,
|
||||
weight: p.weight ?? p.Weight ?? undefined,
|
||||
email: p.email ?? p.Email ?? undefined,
|
||||
phone: p.phone ?? p.Phone ?? undefined,
|
||||
team_id: p.team_id ?? p.TeamID ?? undefined,
|
||||
} as Player;
|
||||
}
|
||||
|
||||
export async function getMatches() {
|
||||
const res = await api.get<Match[] | { data: Match[] }>('/matches');
|
||||
return Array.isArray(res.data) ? res.data : res.data.data;
|
||||
@@ -33,15 +54,16 @@ export async function getStandings() {
|
||||
}
|
||||
|
||||
export async function getPlayers() {
|
||||
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
|
||||
if (Array.isArray(res.data)) return res.data as Player[];
|
||||
const d = res.data as any;
|
||||
return (d?.data || d?.items || []) as Player[];
|
||||
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
|
||||
const raw = Array.isArray(res.data)
|
||||
? res.data
|
||||
: ((res.data as any).data || (res.data as any).items);
|
||||
return (raw || []).map(normalizePlayer);
|
||||
}
|
||||
|
||||
export async function getPlayer(id: number | string) {
|
||||
const res = await api.get<Player>(`/players/${id}`);
|
||||
return res.data;
|
||||
const res = await api.get<any>(`/players/${id}`);
|
||||
return normalizePlayer(res.data);
|
||||
}
|
||||
|
||||
export async function getSponsors() {
|
||||
|
||||
+146
-86
@@ -35,60 +35,50 @@ export interface SearchResults {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Enhanced scoring function for relevance with keyword support
|
||||
// Enhanced scoring function for relevance with keyword support (accent-insensitive)
|
||||
const scoreMatch = (text: string, query: string): number => {
|
||||
const t = (text || '').toLowerCase();
|
||||
const q = (query || '').toLowerCase();
|
||||
if (!t || !q) return 0;
|
||||
|
||||
// Exact match - highest score
|
||||
if (t === q) return 100;
|
||||
|
||||
// Starts with query - very high score
|
||||
if (t.startsWith(q)) return 80;
|
||||
|
||||
// Contains query as whole substring
|
||||
const idx = t.indexOf(q);
|
||||
if (idx >= 0) return 60 - Math.min(idx, 30);
|
||||
|
||||
// Keyword matching - split query into words and check each
|
||||
const keywords = q.split(/\s+/).filter(k => k.length > 1);
|
||||
if (keywords.length > 1) {
|
||||
let matchedKeywords = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (t.includes(keyword)) {
|
||||
matchedKeywords++;
|
||||
const keywordIdx = t.indexOf(keyword);
|
||||
// Score based on position and keyword match
|
||||
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
|
||||
const base = (t: string, q: string): number => {
|
||||
if (!t || !q) return 0;
|
||||
if (t === q) return 100;
|
||||
if (t.startsWith(q)) return 80;
|
||||
const idx = t.indexOf(q);
|
||||
if (idx >= 0) return 60 - Math.min(idx, 30);
|
||||
const keywords = q.split(/\s+/).filter(k => k.length > 1);
|
||||
if (keywords.length > 1) {
|
||||
let matchedKeywords = 0;
|
||||
let totalScore = 0;
|
||||
for (const keyword of keywords) {
|
||||
if (t.includes(keyword)) {
|
||||
matchedKeywords++;
|
||||
const keywordIdx = t.indexOf(keyword);
|
||||
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
|
||||
}
|
||||
}
|
||||
if (matchedKeywords >= keywords.length / 2) {
|
||||
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
||||
}
|
||||
}
|
||||
|
||||
// If at least half the keywords match, return proportional score
|
||||
if (matchedKeywords >= keywords.length / 2) {
|
||||
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
||||
const chars = q.split('');
|
||||
let lastIdx = -1;
|
||||
let matched = 0;
|
||||
for (const char of chars) {
|
||||
const charIdx = t.indexOf(char, lastIdx + 1);
|
||||
if (charIdx > lastIdx) {
|
||||
matched++;
|
||||
lastIdx = charIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partial character matching for typos/fuzzy search
|
||||
const chars = q.split('');
|
||||
let lastIdx = -1;
|
||||
let matched = 0;
|
||||
for (const char of chars) {
|
||||
const charIdx = t.indexOf(char, lastIdx + 1);
|
||||
if (charIdx > lastIdx) {
|
||||
matched++;
|
||||
lastIdx = charIdx;
|
||||
if (matched >= chars.length * 0.8) {
|
||||
return Math.min(25, Math.floor((matched / chars.length) * 25));
|
||||
}
|
||||
}
|
||||
|
||||
if (matched >= chars.length * 0.8) {
|
||||
return Math.min(25, Math.floor((matched / chars.length) * 25));
|
||||
}
|
||||
|
||||
return 0;
|
||||
return 0;
|
||||
};
|
||||
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const t0 = (text || '').toLowerCase();
|
||||
const q0 = (query || '').toLowerCase();
|
||||
const t1 = strip(t0);
|
||||
const q1 = strip(q0);
|
||||
return Math.max(base(t0, q0), base(t1, q1));
|
||||
};
|
||||
|
||||
// Resolve backend URLs for assets
|
||||
@@ -105,6 +95,18 @@ const resolveBackendUrl = (path: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
// Small helper to fetch JSON from backend or cache with safe failure handling
|
||||
const fetchJSON = async <T>(path: string): Promise<T | null> => {
|
||||
try {
|
||||
const url = resolveBackendUrl(path);
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeName = (value: string) =>
|
||||
String(value || '')
|
||||
.normalize('NFD')
|
||||
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
galleryRes,
|
||||
] = await Promise.allSettled([
|
||||
relatedClubsPromise,
|
||||
// Clubs from FACR
|
||||
facrApi.searchClubs(query).catch(() => ({ results: [] })),
|
||||
|
||||
// Matches (upcoming)
|
||||
(async () => {
|
||||
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
|
||||
try {
|
||||
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
let arr: any = [];
|
||||
if (r.ok) arr = await r.json();
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
|
||||
return Array.isArray(fallback) ? fallback : [];
|
||||
}
|
||||
return arr;
|
||||
} catch {
|
||||
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
|
||||
return Array.isArray(fallback) ? fallback : [];
|
||||
}
|
||||
})(),
|
||||
|
||||
// Matches (past)
|
||||
(async () => {
|
||||
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
|
||||
const res = await fetch(url, { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
|
||||
try {
|
||||
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||
let arr: any = [];
|
||||
if (r.ok) arr = await r.json();
|
||||
if (Array.isArray(arr) && arr.length > 0) return arr;
|
||||
} catch {}
|
||||
// Build from FACR club cache as fallback
|
||||
const facr = await fetchJSON<any>(`/cache/prefetch/facr_club_info.json`);
|
||||
if (!facr || !Array.isArray(facr?.competitions)) return [];
|
||||
const now = new Date();
|
||||
const out: any[] = [];
|
||||
for (const c of facr.competitions) {
|
||||
const compName = String(c?.name || c?.code || '').trim();
|
||||
const matches = Array.isArray(c?.matches) ? c.matches : [];
|
||||
for (const m of matches) {
|
||||
const dt = String(m?.date_time || '').trim();
|
||||
if (!dt) continue;
|
||||
const [datePart, timePart = '00:00'] = dt.split(' ');
|
||||
const [day, month, year] = String(datePart || '').split('.');
|
||||
if (!day || !month || !year) continue;
|
||||
const isoDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const timeStr = String(timePart).slice(0, 5);
|
||||
const ts = new Date(`${isoDate}T${timeStr || '00:00'}:00`);
|
||||
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
|
||||
// Past only
|
||||
if (ts.getTime() >= now.getTime()) continue;
|
||||
out.push({
|
||||
id: m?.match_id || m?.matchId,
|
||||
home: m?.home,
|
||||
away: m?.away,
|
||||
competition: compName,
|
||||
date: isoDate,
|
||||
time: timeStr || undefined,
|
||||
venue: m?.venue,
|
||||
home_logo_url: m?.home_logo_url,
|
||||
away_logo_url: m?.away_logo_url,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})(),
|
||||
|
||||
// Articles
|
||||
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
|
||||
|
||||
// Players
|
||||
getPlayers(),
|
||||
|
||||
// Events
|
||||
getUpcomingEvents(),
|
||||
|
||||
// Sponsors
|
||||
getSponsors(),
|
||||
|
||||
// Teams
|
||||
(async () => {
|
||||
const res = await api.get('/teams');
|
||||
return Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||
})(),
|
||||
|
||||
// Contacts
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get('/contacts');
|
||||
// Backend returns { categories: {...}, uncategorized: [...] }
|
||||
// Flatten into a single array
|
||||
const grouped = res.data?.categories || {};
|
||||
const uncategorized = res.data?.uncategorized || [];
|
||||
const allContacts = [...uncategorized];
|
||||
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
|
||||
// Gallery albums
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get('/gallery/albums');
|
||||
@@ -232,14 +260,9 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
const clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
|
||||
const clubs: SearchResult[] = clubsData
|
||||
.filter((c: any) => {
|
||||
// Filter out clubs with no name or empty name
|
||||
const name = String(c.name || '').trim();
|
||||
if (!name) return false;
|
||||
|
||||
if (!hasRelatedFilter) return true;
|
||||
const idKey = String(c.club_id || c.id || '').toLowerCase();
|
||||
const nameKey = normalizeName(c.name);
|
||||
return (idKey && relatedById.has(idKey)) || (nameKey && relatedByName.has(nameKey));
|
||||
return true;
|
||||
})
|
||||
.map((c: any) => {
|
||||
const idKey = String(c.club_id || c.id || '').toLowerCase();
|
||||
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
// Process matches (upcoming)
|
||||
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
|
||||
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
|
||||
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
const queryMatches = (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
// If related filter is active, allow either related match OR explicit query match
|
||||
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
|
||||
})
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
return (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
})
|
||||
.map((m: any, idx: number) => ({
|
||||
type: 'match' as const,
|
||||
id: m.id || idx,
|
||||
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
||||
// Process matches (past)
|
||||
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
|
||||
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
|
||||
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
const queryMatches = (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
|
||||
})
|
||||
.filter((m: any) => {
|
||||
const comp = m.competition || m.competition_name || m.league || '';
|
||||
return (
|
||||
scoreMatch(m.home || '', q) > 0 ||
|
||||
scoreMatch(m.away || '', q) > 0 ||
|
||||
scoreMatch(m.venue || '', q) > 0 ||
|
||||
scoreMatch(comp, q) > 0
|
||||
);
|
||||
})
|
||||
.map((m: any, idx: number) => ({
|
||||
type: 'match_past' as const,
|
||||
id: `past-${m.id || idx}`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import api from './api';
|
||||
|
||||
export interface CreateShortLinkPayload {
|
||||
target_url: string;
|
||||
title?: string;
|
||||
source_type?: 'article' | 'event' | 'other' | string;
|
||||
source_id?: number;
|
||||
expires_at?: string | null;
|
||||
code?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface ShortLinkResponse {
|
||||
id: number;
|
||||
code: string;
|
||||
short_url: string;
|
||||
link: any;
|
||||
}
|
||||
|
||||
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
|
||||
try {
|
||||
// Prefer editor-accessible endpoint
|
||||
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
|
||||
return res.data;
|
||||
} catch (e: any) {
|
||||
// Fallback to admin endpoint (for admin-only contexts)
|
||||
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
|
||||
return res2.data;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listShortLinks(): Promise<{ items: any[] }> {
|
||||
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getShortLinkStats(id: number | string): Promise<any> {
|
||||
const res = await api.get(`/admin/shortlinks/${id}/stats`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
|
||||
function resolveBackendOrigin() {
|
||||
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://127.0.0.1:8080/api/v1';
|
||||
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
|
||||
const fallback = 'http://127.0.0.1:8080';
|
||||
try {
|
||||
if (!raw || raw.startsWith('/')) {
|
||||
return fallback;
|
||||
}
|
||||
const u = new URL(raw);
|
||||
u.pathname = '/';
|
||||
return u.toString();
|
||||
} catch (e) {
|
||||
return 'http://127.0.0.1:8080';
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +54,19 @@ module.exports = function(app) {
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy /dist requests to backend (assets served by Go under /dist)
|
||||
app.use(
|
||||
'/dist',
|
||||
createProxyMiddleware({
|
||||
target: resolveBackendOrigin(),
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for /dist:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy /cache requests to backend (for FACR cache files, etc.)
|
||||
app.use(
|
||||
'/cache',
|
||||
@@ -62,4 +79,50 @@ module.exports = function(app) {
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Additional common static/image paths that may be referenced directly
|
||||
app.use(
|
||||
[
|
||||
'/images',
|
||||
'/img',
|
||||
'/media',
|
||||
'/files',
|
||||
'/logos',
|
||||
'/avatars',
|
||||
'/downloads',
|
||||
'/public',
|
||||
'/favicon.ico',
|
||||
],
|
||||
createProxyMiddleware({
|
||||
target: resolveBackendOrigin(),
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for image/static path:', err);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Fallback: proxy any direct image file requests to the backend
|
||||
// This will not affect Webpack Dev Server assets since they are handled earlier in the middleware chain
|
||||
app.use(
|
||||
createProxyMiddleware(
|
||||
(pathname, req) => {
|
||||
try {
|
||||
if (pathname.startsWith('/sockjs-node') || pathname.startsWith('/ws')) return false;
|
||||
return /\.(?:png|jpe?g|gif|svg|webp|ico|avif)$/i.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
target: resolveBackendOrigin(),
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
onError: (err, req, res) => {
|
||||
console.error('Proxy error for image extension fallback:', err);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Club Style Pack
|
||||
* Generic CSS utilities and components leveraging ClubThemeContext CSS variables
|
||||
* - Full-bleed utility
|
||||
* - Hero Topbar (above hero) with variants
|
||||
*/
|
||||
|
||||
/* Utility: full-bleed edge-to-edge content */
|
||||
.full-bleed {
|
||||
margin-left: calc(50% - 50vw);
|
||||
margin-right: calc(50% - 50vw);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Hero Topbar (above hero) */
|
||||
.club-hero-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Brand variant: uses club colors and a subtle gradient */
|
||||
.club-hero-topbar--brand {
|
||||
color: var(--club-text-on-primary, #fff);
|
||||
background: linear-gradient(90deg, var(--club-primary, #0b5cff), var(--club-accent, #141414));
|
||||
border-bottom-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
/* Minimal variant: transparent with subtle text */
|
||||
.club-hero-topbar--minimal {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border-bottom-color: rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Badge variant: pill-like container around the content */
|
||||
.club-hero-topbar--badge {
|
||||
color: var(--club-text-on-primary, #fff);
|
||||
background: var(--club-primary, #0b5cff);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.club-hero-topbar__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.club-hero-topbar--minimal .club-hero-topbar__logo {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.club-hero-topbar__title {
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.club-hero-topbar--brand .club-hero-topbar__title {
|
||||
text-shadow: 0 1px 10px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.club-hero-topbar__tagline {
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.club-hero-topbar__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.club-hero-topbar__actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Responsive tweaks */
|
||||
@media (min-width: 768px) {
|
||||
.club-hero-topbar { padding: 12px 16px; }
|
||||
.club-hero-topbar__title { font-size: 1.125rem; }
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
.ql-toolbar.ql-snow {
|
||||
min-height: 42px !important;
|
||||
height: auto !important;
|
||||
position: relative !important;
|
||||
z-index: 5000 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
@@ -148,6 +151,8 @@
|
||||
background: white;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
position: absolute !important;
|
||||
z-index: 6000 !important;
|
||||
}
|
||||
|
||||
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
|
||||
@@ -452,10 +457,21 @@
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 4px;
|
||||
z-index: 1000;
|
||||
z-index: 6000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Ensure Quill built-in tooltip (e.g., link editor) is above modals and containers */
|
||||
.ql-tooltip,
|
||||
.ql-snow .ql-tooltip {
|
||||
z-index: 6000 !important;
|
||||
}
|
||||
|
||||
/* Ensure custom image resize overlay sits above content */
|
||||
.custom-image-resize-container {
|
||||
z-index: 6000 !important;
|
||||
}
|
||||
|
||||
/* Loading State for Images */
|
||||
.ql-editor img[src=""] {
|
||||
opacity: 0.3;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/* base vars and helpers */
|
||||
:root {
|
||||
--pack-gap-sm: 12px;
|
||||
--pack-gap-md: 16px;
|
||||
--pack-gap-lg: 24px;
|
||||
}
|
||||
|
||||
/* Global style-pack modifiers (toggle via body.class) */
|
||||
body.style-pack-default {
|
||||
--pack-radius: 12px;
|
||||
--pack-shadow: 0 8px 20px rgba(0,0,0,0.06), 0 2px 6px rgba(0,0,0,0.04);
|
||||
}
|
||||
body.style-pack-modern {
|
||||
--pack-radius: 16px;
|
||||
--pack-shadow: 0 14px 32px rgba(0,0,0,0.10), 0 4px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
body.style-pack-minimal {
|
||||
--pack-radius: 8px;
|
||||
--pack-shadow: none;
|
||||
}
|
||||
body.style-pack-sparta {
|
||||
--pack-radius: 12px;
|
||||
--pack-shadow: 0 10px 28px rgba(0,0,0,0.12), 0 4px 10px rgba(0,0,0,0.08);
|
||||
}
|
||||
body.style-pack-sparta .section-head h3 { text-transform: uppercase; font-weight: 700; letter-spacing: 0.5px; }
|
||||
body.style-pack-sparta .see-all { color: var(--club-primary, #0b5cff); font-weight: 700; }
|
||||
body.style-pack-sparta .btn { border-radius: 10px; letter-spacing: 0.3px; }
|
||||
body.style-pack-sparta .sponsor-tile:hover { transform: translateY(-8px) scale(1.1); }
|
||||
|
||||
/* Apply pack variables to common cards */
|
||||
.card,
|
||||
[data-element="news"] .blog-list .card,
|
||||
[data-element="table"] .table-card,
|
||||
.player-card,
|
||||
.match-card,
|
||||
.sponsor-tile,
|
||||
.newsletter-cta .card {
|
||||
border-radius: var(--pack-radius, 12px);
|
||||
box-shadow: var(--pack-shadow, none);
|
||||
}
|
||||
|
||||
/* Header & Footer tweaks */
|
||||
[data-element="header"][data-variant="fullwidth"] { box-shadow: none; }
|
||||
[data-element="footer"] { border-top: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
|
||||
|
||||
/* Header variants */
|
||||
[data-element="header"][data-variant="transparent"] {
|
||||
background: transparent !important;
|
||||
}
|
||||
[data-element="header"][data-variant="minimal"] {
|
||||
box-shadow: none !important;
|
||||
border-bottom: 1px solid var(--card-border, rgba(0,0,0,0.08));
|
||||
}
|
||||
[data-element="header"][data-variant="modern"] {
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
[data-element="header"][data-variant="sparta_navbar"] {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Section heads under style packs */
|
||||
body.style-pack-minimal .section-head h3::after { display: none; }
|
||||
body.style-pack-minimal .section-head { margin-top: 16px; }
|
||||
body.style-pack-modern .section-head h3::after { width: 64px; }
|
||||
|
||||
/* Banner placements */
|
||||
[data-element="banner"][data-variant="top"],
|
||||
[data-element="banner"][data-variant="bottom"] { text-align: center; }
|
||||
[data-element="sidebar"] img { border-radius: 8px; }
|
||||
|
||||
/* two-column news + table layout */
|
||||
.standings {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--pack-gap-lg);
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.standings:not([data-variant="standard"]) {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* News list */
|
||||
[data-element="news"] .blog-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--pack-gap-md);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
[data-element="news"] .blog-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
[data-element="news"] .blog-list .card {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: var(--pack-gap-md);
|
||||
align-items: center;
|
||||
border: 1px solid var(--card-border, rgba(0,0,0,0.08));
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg, #fff);
|
||||
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
||||
}
|
||||
[data-element="news"] .blog-list .card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--club-primary, #0b5cff);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.08);
|
||||
}
|
||||
[data-element="news"] .blog-list .card .thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-top-left-radius: 12px;
|
||||
border-bottom-left-radius: 12px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Table card */
|
||||
[data-element="table"] .table-card {
|
||||
border: 1px solid var(--card-border, rgba(0,0,0,0.08));
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg, #fff);
|
||||
padding: var(--pack-gap-md);
|
||||
}
|
||||
[data-element="table"] .standings-table-compact tbody tr:hover {
|
||||
background: color-mix(in oklab, var(--club-primary, #0b5cff) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Next match */
|
||||
[data-element="matches"] .next-match .team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
[data-element="matches"] .next-match .logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Matches slider */
|
||||
[data-element="matches-slider"] .section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
[data-element="matches-slider"] .see-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--club-primary, #0b5cff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Activities */
|
||||
[data-element="activities"] .events-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--pack-gap-md);
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
[data-element="activities"] .events-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
[data-element="activities"] .blog-list .card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
|
||||
[data-element="activities"] .blog-list .card .thumb { border-radius: 10px; }
|
||||
|
||||
/* Team */
|
||||
[data-element="team"] .player-card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
|
||||
[data-element="team"] .player-card .photo { border-radius: 10px; }
|
||||
|
||||
/* Gallery */
|
||||
[data-element="gallery"] .section-head h3 { letter-spacing: 0.2px; }
|
||||
|
||||
/* Videos */
|
||||
[data-element="videos"] .video-card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
|
||||
[data-element="videos"] iframe { border-radius: var(--pack-radius, 12px); }
|
||||
|
||||
/* Merch */
|
||||
[data-element="merch"] .card, [data-element="merch"] .grid .item { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
|
||||
|
||||
/* Poll */
|
||||
[data-element="poll"] .card { border-radius: var(--pack-radius, 12px); box-shadow: var(--pack-shadow, none); }
|
||||
|
||||
/* Minimal pack adjustments */
|
||||
body.style-pack-minimal [data-element="news"] .blog-list .card,
|
||||
body.style-pack-minimal [data-element="activities"] .blog-list .card,
|
||||
body.style-pack-minimal [data-element="table"] .table-card,
|
||||
body.style-pack-minimal [data-element="team"] .player-card,
|
||||
body.style-pack-minimal [data-element="videos"] .video-card,
|
||||
body.style-pack-minimal [data-element="merch"] .card,
|
||||
body.style-pack-minimal [data-element="poll"] .card {
|
||||
box-shadow: none; border: 1px solid var(--card-border, rgba(0,0,0,0.08));
|
||||
}
|
||||
|
||||
/* Modern pack adjustments */
|
||||
body.style-pack-modern [data-element="news"] .blog-list .card,
|
||||
body.style-pack-modern [data-element="activities"] .blog-list .card,
|
||||
body.style-pack-modern [data-element="team"] .player-card {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Players scroller */
|
||||
[data-element="team"].players-scroller .section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* General sections */
|
||||
.section-head h3 { margin: 0; }
|
||||
[data-element="gallery"],
|
||||
[data-element="videos"],
|
||||
[data-element="merch"],
|
||||
[data-element="poll"],
|
||||
[data-element="newsletter"] {
|
||||
scroll-margin-top: 72px;
|
||||
}
|
||||
|
||||
/* Banners */
|
||||
[data-element="banner"] { text-align: center; }
|
||||
|
||||
/* Sponsors */
|
||||
[data-element="sponsors"] .section-head { margin-top: 0; }
|
||||
|
||||
/* Hero variants */
|
||||
[data-element="hero"][data-variant="grid"] .hero-card {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-element="hero"][data-variant="grid"] .hero-card .overlay {
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.65) 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
[data-element="hero"][data-variant="scroller"] { scroll-margin-top: 72px; }
|
||||
[data-element="hero"][data-variant="swiper"] { scroll-margin-top: 72px; }
|
||||
[data-element="hero"][data-variant="swiper_full"] {
|
||||
margin-left: calc(50% - 50vw);
|
||||
margin-right: calc(50% - 50vw);
|
||||
}
|
||||
|
||||
/* Gallery */
|
||||
[data-element="gallery"] .section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
[data-element="gallery"] .see-all { color: var(--club-primary, #0b5cff); text-decoration: none; font-weight: 600; }
|
||||
|
||||
/* Videos */
|
||||
[data-element="videos"] .section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
[data-element="videos"] iframe { width: 100%; border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
|
||||
|
||||
/* Merch */
|
||||
[data-element="merch"] .section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
[data-element="merch"] .grid { gap: var(--pack-gap-md); }
|
||||
|
||||
/* Poll */
|
||||
[data-element="poll"] .section-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
[data-element="poll"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); }
|
||||
|
||||
/* Newsletter */
|
||||
[data-element="newsletter"] .card { border-radius: 12px; border: 1px solid var(--card-border, rgba(0,0,0,0.08)); background: var(--card-bg, #fff); }
|
||||
@@ -10,14 +10,18 @@ export function assetUrl(pathOrUrl?: string | null): string | undefined {
|
||||
}
|
||||
// Known backend-served asset paths (/uploads, optionally /dist)
|
||||
if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/dist')) {
|
||||
// 1) Explicit override wins
|
||||
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
|
||||
if (explicit) {
|
||||
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || '';
|
||||
if (explicit && !explicit.startsWith('/')) {
|
||||
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||
baseUrl.pathname = '/';
|
||||
return new URL(pathOrUrl, baseUrl).toString();
|
||||
}
|
||||
// 2) Keep relative so frontend dev proxy or edge proxy forwards to backend
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
const devOrigin = 'http://127.0.0.1:8080';
|
||||
return new URL(pathOrUrl, devOrigin).toString();
|
||||
} catch {}
|
||||
}
|
||||
return pathOrUrl;
|
||||
}
|
||||
// Otherwise return as-is (relative or other paths)
|
||||
|
||||
@@ -129,6 +129,9 @@ func (ac *AuthController) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-subscribe newly registered fans to the newsletter
|
||||
_ = models.SubscribeToNewsletter(ac.DB, user.Email)
|
||||
|
||||
// For first user, ensure setup info exists
|
||||
if isFirstUser {
|
||||
_, err := ac.setupService.GetSetupStatus()
|
||||
|
||||
+1341
-2006
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gopkg.in/mail.v2"
|
||||
)
|
||||
|
||||
type ContactController struct {
|
||||
@@ -27,7 +28,61 @@ type ContactController struct {
|
||||
emailService email.EmailService
|
||||
}
|
||||
|
||||
// GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email
|
||||
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
UseTLS bool `json:"use_tls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(input.Host) == "" || input.Port <= 0 || strings.TrimSpace(input.From) == "" || strings.TrimSpace(input.To) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host, port, from and to are required"})
|
||||
return
|
||||
}
|
||||
|
||||
d := mail.NewDialer(strings.TrimSpace(input.Host), input.Port, strings.TrimSpace(input.Username), input.Password)
|
||||
d.SSL = input.UseTLS
|
||||
d.Timeout = 30 * time.Second
|
||||
|
||||
subj := strings.TrimSpace(input.Subject)
|
||||
if subj == "" {
|
||||
subj = "SMTP Test"
|
||||
}
|
||||
body := strings.TrimSpace(input.Body)
|
||||
if body == "" {
|
||||
body = "<p>Toto je testovací e‑mail SMTP z administrace.</p>"
|
||||
}
|
||||
|
||||
m := mail.NewMessage()
|
||||
from := strings.TrimSpace(input.From)
|
||||
to := strings.TrimSpace(input.To)
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subj)
|
||||
m.SetDateHeader("Date", time.Now())
|
||||
m.SetBody("text/plain", "SMTP test email")
|
||||
m.AddAlternative("text/html", body)
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Test email sent"})
|
||||
}
|
||||
|
||||
// GET /api/v1/newsletter/token/me (auth required)
|
||||
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
||||
u, ok := c.Get("user")
|
||||
@@ -43,24 +98,27 @@ func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a 24h token for managing newsletter preferences
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", email).First(&sub).Error; err != nil {
|
||||
_ = cc.DB.Create(&models.NewsletterSubscription{Email: email, IsActive: true}).Error
|
||||
} else if !sub.IsActive {
|
||||
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", true).Error
|
||||
}
|
||||
|
||||
token, err := utils.GenerateSubscriberToken(email, 60*24)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
|
||||
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
|
||||
// POST /api/v1/admin/newsletter/send-digest
|
||||
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
Competitions string `json:"competitions"`
|
||||
@@ -69,7 +127,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
||||
return
|
||||
}
|
||||
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type))
|
||||
allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true}
|
||||
if !allowed[t] {
|
||||
@@ -77,7 +134,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch active subscribers
|
||||
var subscribers []models.NewsletterSubscription
|
||||
if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
|
||||
@@ -88,13 +144,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build digest content once based on selected type
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: "digest@local",
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: "digest@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if t == "weekly" {
|
||||
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
||||
prefs.Frequency = "weekly"
|
||||
@@ -103,9 +153,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
}
|
||||
if strings.TrimSpace(input.Competitions) != "" {
|
||||
for _, p := range strings.Split(input.Competitions, ",") {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
prefs.Competitions = append(prefs.Competitions, v)
|
||||
}
|
||||
if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) }
|
||||
}
|
||||
}
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
@@ -113,227 +161,103 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"})
|
||||
return
|
||||
}
|
||||
|
||||
// Recipients list
|
||||
recipients := make([]string, 0, len(subscribers))
|
||||
for _, s := range subscribers {
|
||||
if s.Email != "" {
|
||||
recipients = append(recipients, s.Email)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"})
|
||||
return
|
||||
}
|
||||
|
||||
if subj == "" {
|
||||
subj = strings.Title(t) + " digest"
|
||||
}
|
||||
|
||||
for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } }
|
||||
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}); return }
|
||||
if subj == "" { subj = strings.Title(t) + " digest" }
|
||||
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send digest newsletter: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": len(recipients), "type": t})
|
||||
}
|
||||
|
||||
// UpdateNewsletterAutomation toggles the automated newsletter scheduler at runtime (non-persistent)
|
||||
// PATCH /api/v1/admin/newsletter/enable { enabled: boolean }
|
||||
// PATCH /api/v1/admin/newsletter/enable
|
||||
func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
var input struct { Enabled bool `json:"enabled"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
// Persist to Settings (singleton row)
|
||||
var s models.Settings
|
||||
_ = cc.DB.First(&s).Error // ignore not found
|
||||
if s.ID == 0 {
|
||||
s = models.Settings{}
|
||||
}
|
||||
_ = cc.DB.First(&s).Error
|
||||
if s.ID == 0 { s = models.Settings{} }
|
||||
s.NewsletterEnabled = input.Enabled
|
||||
if s.ID == 0 {
|
||||
if err := cc.DB.Create(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
||||
return
|
||||
}
|
||||
} else if err := cc.DB.Save(&s).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
||||
return
|
||||
}
|
||||
// Flip the in-memory config flag; effective immediately for next tick
|
||||
if config.AppConfig != nil {
|
||||
config.AppConfig.NewsletterEnabled = input.Enabled
|
||||
}
|
||||
if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||
} else if err := cc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||
if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = input.Enabled }
|
||||
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
|
||||
}
|
||||
|
||||
// GetNewsletterStatus returns basic scheduling/status info for newsletters (admin only)
|
||||
// @Summary Newsletter status
|
||||
// @Description Returns subscriber stats and next approximate run time based on interval
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/status [get]
|
||||
// GET /api/v1/admin/newsletter/status
|
||||
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var total int64
|
||||
var active int64
|
||||
var total, active int64
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
||||
|
||||
var subs []models.NewsletterSubscription
|
||||
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
||||
sample := make([]string, 0, len(subs))
|
||||
for _, s := range subs {
|
||||
if s.Email != "" {
|
||||
sample = append(sample, s.Email)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range subs { if s.Email != "" { sample = append(sample, s.Email) } }
|
||||
interval := 24 * time.Hour
|
||||
if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" {
|
||||
if d, err := time.ParseDuration(v + "h"); err == nil {
|
||||
interval = d
|
||||
}
|
||||
if d, err := time.ParseDuration(v + "h"); err == nil { interval = d }
|
||||
}
|
||||
next := time.Now().Add(interval)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_subscribers": total,
|
||||
"active_subscribers": active,
|
||||
"sample_recipients": sample,
|
||||
"interval_minutes": int(interval.Minutes()),
|
||||
"next_approximate": next,
|
||||
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"total_subscribers": total, "active_subscribers": active, "sample_recipients": sample, "interval_minutes": int(interval.Minutes()), "next_approximate": next, "newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled})
|
||||
}
|
||||
|
||||
// PreviewNewsletter builds a digest preview (subject + html) for admin without sending
|
||||
// @Summary Preview newsletter digest (admin)
|
||||
// @Description Returns subject and HTML for a digest newsletter using current cache and optional preferences
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param prefs body map[string]interface{} false "Optional { preferences: { blogs, matches, events, scores, competitions } }"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/preview [post]
|
||||
// POST /api/v1/admin/newsletter/preview
|
||||
func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Preferences map[string]interface{} `json:"preferences"`
|
||||
}
|
||||
var input struct { Preferences map[string]interface{} `json:"preferences"` }
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
|
||||
// Normalize preferences to NewsletterPrefs
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: "preview@local",
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if m := input.Preferences; m != nil {
|
||||
if b, ok := m["blogs"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
|
||||
}
|
||||
if b, ok := m["events"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "events")
|
||||
}
|
||||
if b, ok := m["matches"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
|
||||
}
|
||||
if b, ok := m["scores"].(bool); ok && b {
|
||||
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
|
||||
}
|
||||
if b, ok := m["blogs"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") }
|
||||
if b, ok := m["events"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "events") }
|
||||
if b, ok := m["matches"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "matches") }
|
||||
if b, ok := m["scores"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "scores") }
|
||||
if cs, ok := m["competitions"].(string); ok && strings.TrimSpace(cs) != "" {
|
||||
parts := strings.Split(cs, ",")
|
||||
for _, p := range parts {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
prefs.Competitions = append(prefs.Competitions, v)
|
||||
}
|
||||
}
|
||||
for _, p := range strings.Split(cs, ",") { if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) } }
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "cache/prefetch"
|
||||
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
|
||||
}
|
||||
|
||||
// GetNewsletterPreferencesByToken returns subscriber preferences using a token (no auth required)
|
||||
// GET /api/v1/newsletter/preferences?token=...
|
||||
// GET /api/v1/newsletter/preferences
|
||||
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
|
||||
token := strings.TrimSpace(c.Query("token"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"email": sub.Email,
|
||||
"is_active": sub.IsActive,
|
||||
"preferences": sub.Preferences,
|
||||
})
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"email": sub.Email, "is_active": sub.IsActive, "preferences": sub.Preferences})
|
||||
}
|
||||
|
||||
// SaveNewsletterPreferencesByToken saves subscriber preferences using a token (no auth required)
|
||||
// POST /api/v1/newsletter/preferences { token, preferences }
|
||||
// POST /api/v1/newsletter/preferences
|
||||
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Preferences map[string]interface{} `json:"preferences" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct { Token string `json:"token" binding:"required"`; Preferences map[string]interface{} `json:"preferences" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
|
||||
jm := datatypes.JSONMap{}
|
||||
for key, raw := range input.Preferences {
|
||||
switch v := raw.(type) {
|
||||
@@ -343,13 +267,7 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
jm[key] = strings.TrimSpace(v)
|
||||
case []interface{}:
|
||||
compiled := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
||||
compiled = append(compiled, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, item := range v { if s, ok := item.(string); ok { if trimmed := strings.TrimSpace(s); trimmed != "" { compiled = append(compiled, trimmed) } } }
|
||||
jm[key] = strings.Join(compiled, ", ")
|
||||
case float64, int, int64:
|
||||
jm[key] = v
|
||||
@@ -360,7 +278,6 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if compVal, ok := jm["competitions"]; ok {
|
||||
if compStr, ok := compVal.(string); ok {
|
||||
comp := strings.TrimSpace(compStr)
|
||||
@@ -374,32 +291,18 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub.Preferences = jm
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
||||
}
|
||||
|
||||
// UnsubscribeByToken disables newsletter using a token (no auth required)
|
||||
// POST /api/v1/newsletter/unsubscribe-token { token }
|
||||
// POST /api/v1/newsletter/unsubscribe-token
|
||||
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
var input struct { Token string `json:"token" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
||||
return
|
||||
}
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||
if err := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", emailStr).Update("is_active", false).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
|
||||
return
|
||||
@@ -407,213 +310,67 @@ func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
|
||||
}
|
||||
|
||||
// DeleteNewsletterSubscriber deletes a newsletter subscriber (admin only)
|
||||
// @Summary Delete newsletter subscriber
|
||||
// @Description Deletes a newsletter subscriber by ID (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id} [delete]
|
||||
// DELETE /api/v1/admin/newsletter/subscribers/:id
|
||||
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
return
|
||||
}
|
||||
if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"}); return }
|
||||
if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}); return }
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Subscriber deleted successfully"})
|
||||
}
|
||||
|
||||
// UpdateNewsletterSubscriberStatus toggles a subscriber's active status (admin only)
|
||||
// @Summary Update newsletter subscriber status
|
||||
// @Description Updates the is_active status of a newsletter subscriber (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Param input body map[string]bool true "{ is_active: boolean }"
|
||||
// @Success 200 {object} models.NewsletterSubscription
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id}/status [patch]
|
||||
// PATCH /api/v1/admin/newsletter/subscribers/:id/status
|
||||
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
IsActive bool `json:"is_active" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
var input struct { IsActive bool `json:"is_active" binding:"required"` }
|
||||
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
|
||||
return
|
||||
}
|
||||
|
||||
sub.IsActive = input.IsActive
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"}); return }
|
||||
c.JSON(http.StatusOK, sub)
|
||||
}
|
||||
|
||||
// UpdateNewsletterSubscriberPreferences updates subscriber preferences (admin only)
|
||||
// @Summary Update newsletter subscriber preferences
|
||||
// @Description Updates the preferences JSON for a subscriber (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Subscriber ID"
|
||||
// @Param input body map[string]bool true "Preferences map"
|
||||
// @Success 200 {object} models.NewsletterSubscription
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/subscribers/{id}/preferences [patch]
|
||||
// PATCH /api/v1/admin/newsletter/subscribers/:id/preferences
|
||||
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||
var prefs map[string]bool
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"}); return }
|
||||
var sub models.NewsletterSubscription
|
||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
||||
}
|
||||
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
|
||||
return
|
||||
}
|
||||
|
||||
// convert map[string]bool to datatypes.JSONMap
|
||||
jm := datatypes.JSONMap{}
|
||||
for k, v := range prefs {
|
||||
jm[k] = v
|
||||
}
|
||||
for k, v := range prefs { jm[k] = v }
|
||||
sub.Preferences = jm
|
||||
sub.UpdatedAt = time.Now()
|
||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}); return }
|
||||
c.JSON(http.StatusOK, sub)
|
||||
}
|
||||
|
||||
// SendNewsletterTest sends a test newsletter email to a single recipient (admin only)
|
||||
// @Summary Send test newsletter email
|
||||
// @Description Sends a test newsletter email to a single recipient (admin only)
|
||||
// @Tags admin
|
||||
// @Security Bearer
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param input body map[string]string false "Optional {email} to send test to"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Router /api/v1/admin/newsletter/test [post]
|
||||
// POST /api/v1/admin/newsletter/test
|
||||
func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
||||
if c.GetString("userRole") != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Email string `json:"email"`
|
||||
Emails []string `json:"emails"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||
var input struct { Email string `json:"email"`; Emails []string `json:"emails"`; Type string `json:"type"` }
|
||||
_ = c.ShouldBindJSON(&input)
|
||||
|
||||
// Resolve recipients (emails > email > admin)
|
||||
recipients := make([]string, 0)
|
||||
if len(input.Emails) > 0 {
|
||||
for _, e := range input.Emails {
|
||||
if v := strings.TrimSpace(e); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
if v := strings.TrimSpace(input.Email); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" {
|
||||
recipients = append(recipients, v)
|
||||
}
|
||||
}
|
||||
if len(recipients) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
|
||||
return
|
||||
}
|
||||
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type))
|
||||
if t == "" {
|
||||
t = "newsletter"
|
||||
}
|
||||
|
||||
if len(input.Emails) > 0 { for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } } }
|
||||
if len(recipients) == 0 { if v := strings.TrimSpace(input.Email); v != "" { recipients = append(recipients, v) } }
|
||||
if len(recipients) == 0 { if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" { recipients = append(recipients, v) } }
|
||||
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"}); return }
|
||||
t := strings.ToLower(strings.TrimSpace(input.Type)); if t == "" { t = "newsletter" }
|
||||
logger.Info("[SendNewsletterTest] type=%s recipients=%v", t, recipients)
|
||||
|
||||
switch t {
|
||||
case "newsletter":
|
||||
testHTML := `<p>Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.</p>`
|
||||
@@ -621,155 +378,34 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
||||
logger.Debug("[SendNewsletterTest] invoking emailService.SendNewsletter for %d recipient(s)", len(recipients))
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send test newsletter: %v", err)
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"})
|
||||
}
|
||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"}) }
|
||||
return
|
||||
}
|
||||
case "welcome":
|
||||
for _, r := range recipients {
|
||||
w := &email.NewsletterWelcomeData{Email: r, UnsubscribeLink: ""}
|
||||
if err := cc.emailService.SendNewsletterWelcome(w); err != nil {
|
||||
logger.Error("Failed to send welcome test to %s: %v", r, err)
|
||||
}
|
||||
}
|
||||
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: r}) }
|
||||
case "welcome_back":
|
||||
for _, r := range recipients {
|
||||
w := &email.NewsletterWelcomeBackData{Email: r}
|
||||
if err := cc.emailService.SendNewsletterWelcomeBack(w); err != nil {
|
||||
logger.Error("Failed to send welcome back test to %s: %v", r, err)
|
||||
}
|
||||
}
|
||||
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
|
||||
case "setup":
|
||||
// Test subscription setup email with token
|
||||
for _, r := range recipients {
|
||||
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
|
||||
if tErr != nil {
|
||||
logger.Error("Failed to generate token for setup test: %v", tErr)
|
||||
continue
|
||||
}
|
||||
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
||||
setupEmail := &email.EmailData{
|
||||
Subject: "Test: Nastavte svůj newsletter",
|
||||
To: []string{r},
|
||||
Template: "newsletter_setup",
|
||||
Data: struct{ SetupURL string }{SetupURL: setupURL},
|
||||
}
|
||||
if err := cc.emailService.SendEmail(setupEmail); err != nil {
|
||||
logger.Error("Failed to send setup test to %s: %v", r, err)
|
||||
}
|
||||
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
|
||||
_ = cc.emailService.SendEmail(setupEmail)
|
||||
}
|
||||
case "match_reminder_48h":
|
||||
// Test 48h match reminder
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Připomínáme nadcházející zápas:</h2>
|
||||
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> 2025-10-02</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
||||
</div>
|
||||
<p style="color: #4a5568; margin-top: 20px;">Zápas začíná za 48 hodin. Nezapomeňte!</p>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Nadcházející zápas za 48 hodin", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match reminder test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "match_reminder_today":
|
||||
// Test day-of match reminder
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Zápas je dnes!</h2>
|
||||
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> Dnes</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
|
||||
</div>
|
||||
<p style="color: #4a5568; margin-top: 20px;">Přijďte fandit!</p>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Zápas dnes", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match today test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "blog_notification":
|
||||
// Test blog release notification
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
|
||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">Testovací článek: Zajímavosti ze sezóny</h3>
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">Toto je ukázkový výňatek z nového článku na našem webu. Přečtěte si celý příběh a dozvíte se více zajímavostí ze sezóny.</p>
|
||||
<a href="https://example.com/news/test" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Nový článek - Zajímavosti ze sezóny", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send blog notification test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
case "match_result":
|
||||
// Test match result notification
|
||||
testHTML := `
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Výsledek zápasu</h2>
|
||||
<div style="border-left: 4px solid #d69e2e; padding: 20px; background: #fffbeb; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test <span style="color: #d69e2e;">3:2</span> SK Example</h3>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> 2025-09-30</p>
|
||||
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
|
||||
<p style="color: #975a16; margin: 10px 0 0 0;">Gratulujeme týmu k vítězství!</p>
|
||||
</div>
|
||||
</div>`
|
||||
data := &email.NewsletterData{Subject: "Test: Výsledek - FC Test 3:2 SK Example", Content: testHTML, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send match result test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
|
||||
return
|
||||
}
|
||||
// Predefined digest test types mapped to content sections
|
||||
case "blogs", "events", "matches", "scores", "weekly":
|
||||
prefs := services.NewsletterPrefs{
|
||||
Email: recipients[0],
|
||||
ContentTypes: []string{},
|
||||
Competitions: []string{},
|
||||
Frequency: "daily",
|
||||
}
|
||||
if t == "weekly" {
|
||||
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
||||
prefs.Frequency = "weekly"
|
||||
} else {
|
||||
prefs.ContentTypes = []string{t}
|
||||
}
|
||||
cacheDir := "cache/prefetch"
|
||||
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
||||
if subj == "" {
|
||||
subj = "Test digest"
|
||||
}
|
||||
if html == "" {
|
||||
html = "<p>Momentálně žádný obsah pro zvolený typ.</p>"
|
||||
}
|
||||
prefs := services.NewsletterPrefs{Email: recipients[0], ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||
if t == "weekly" { prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}; prefs.Frequency = "weekly" } else { prefs.ContentTypes = []string{t} }
|
||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||
if subj == "" { subj = "Test digest" }
|
||||
if html == "" { html = "<p>Momentálně žádný obsah pro zvolený typ.</p>" }
|
||||
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||
logger.Error("Failed to send digest test: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"})
|
||||
return
|
||||
}
|
||||
if err := cc.emailService.SendNewsletter(data); err != nil { logger.Error("Failed to send digest test: %v", err); c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"}); return }
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
|
||||
}
|
||||
|
||||
@@ -928,7 +564,7 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
|
||||
// Create new subscription. Default: enable everything if preferences omitted
|
||||
prefs := input.Preferences
|
||||
if prefs == nil {
|
||||
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true}
|
||||
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true, "scores": true}
|
||||
}
|
||||
// convert to datatypes.JSONMap
|
||||
jm := datatypes.JSONMap{}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/logger"
|
||||
@@ -233,7 +234,11 @@ func (fc *FilesController) GetFileUsages(c *gin.Context) {
|
||||
|
||||
// ScanAndSyncFiles scans the uploads directory and syncs with database
|
||||
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
||||
uploadsDir := "uploads"
|
||||
// Use configured uploads directory (normalized to absolute in main startup)
|
||||
uploadsDir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(uploadsDir) == "" {
|
||||
uploadsDir = "./uploads"
|
||||
}
|
||||
|
||||
var filesInDB []models.UploadedFile
|
||||
if err := fc.DB.Find(&filesInDB).Error; err != nil {
|
||||
@@ -287,7 +292,13 @@ func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
||||
if !existsOriginal && !existsNormalized {
|
||||
// File exists on disk but not in database - add it
|
||||
mimeType := detectMimeType(path)
|
||||
fileURL := "/" + filepath.ToSlash(path)
|
||||
// Compute public URL under /uploads by making path relative to uploadsDir
|
||||
rel, rerr := filepath.Rel(uploadsDir, path)
|
||||
if rerr != nil {
|
||||
// If for some reason file is outside base (symlink), skip
|
||||
return nil
|
||||
}
|
||||
fileURL := "/uploads/" + filepath.ToSlash(rel)
|
||||
|
||||
newFile := models.UploadedFile{
|
||||
Filename: filename,
|
||||
|
||||
@@ -12,8 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type ImageProcessingController struct{}
|
||||
@@ -143,9 +146,20 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Return the new URL
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
xfl := strings.ToLower(xf)
|
||||
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
|
||||
host = xf
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + outputPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
"url": absolute,
|
||||
"format": format,
|
||||
})
|
||||
}
|
||||
@@ -154,8 +168,14 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
||||
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
|
||||
// Check if it's a local file path
|
||||
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
|
||||
// Local file
|
||||
localPath := filepath.Join(".", imageURL)
|
||||
// Local file under configured uploads dir
|
||||
base := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base = "./uploads"
|
||||
}
|
||||
rel := strings.TrimPrefix(imageURL, "/")
|
||||
rel = strings.TrimPrefix(rel, "uploads/")
|
||||
localPath := filepath.Join(base, filepath.FromSlash(rel))
|
||||
file, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to open local file: %w", err)
|
||||
@@ -169,14 +189,27 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// HTTP URL
|
||||
resp, err := http.Get(imageURL)
|
||||
// HTTP URL - use custom client and headers, some CDNs block default Go UA
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
req, err := http.NewRequest("GET", imageURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
// Set a recognizable UA; align with other proxy endpoints
|
||||
req.Header.Set("User-Agent", "fotbal-club/1.0")
|
||||
req.Header.Set("Accept", "image/*")
|
||||
// Some providers (e.g. Zonerama) may require a referer
|
||||
if strings.Contains(strings.ToLower(imageURL), "zonerama.com") {
|
||||
req.Header.Set("Referer", "https://zonerama.com/")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -190,8 +223,11 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
|
||||
|
||||
// saveProcessedImage saves the processed image and returns the path
|
||||
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
|
||||
// Create uploads directory if it doesn't exist
|
||||
uploadsDir := "./uploads"
|
||||
// Create uploads directory if it doesn't exist (use configured UploadDir)
|
||||
uploadsDir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(uploadsDir) == "" {
|
||||
uploadsDir = "./uploads"
|
||||
}
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create uploads directory: %w", err)
|
||||
}
|
||||
@@ -297,8 +333,20 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
xfl := strings.ToLower(xf)
|
||||
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
|
||||
host = xf
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + outputPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": outputPath,
|
||||
"url": absolute,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -513,10 +513,11 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
{Label: "Prefetch & Cache", Type: models.NavTypeInternal, PageType: "prefetch", DisplayOrder: 22, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Uživatelé", Type: models.NavTypeInternal, PageType: "users", DisplayOrder: 23, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Nastavení", Type: models.NavTypeInternal, PageType: "settings", DisplayOrder: 24, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Zkrácené odkazy", Type: models.NavTypeInternal, PageType: "shortlinks", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
|
||||
|
||||
// Help section
|
||||
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
|
||||
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 27, Visible: true, RequiresAdmin: true},
|
||||
}
|
||||
|
||||
// Combine all items
|
||||
|
||||
@@ -15,9 +15,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func uploadsBaseDir() string {
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
||||
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
@@ -102,7 +112,8 @@ func ensureUniqueFilename(dir, name string) string {
|
||||
|
||||
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
||||
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
||||
entries, err := os.ReadDir(filepath.Join("uploads", "sponsors"))
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
entries, err := os.ReadDir(sponsorDir)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusOK, []string{})
|
||||
return
|
||||
@@ -125,7 +136,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755)
|
||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||
|
||||
saved := 0
|
||||
if ctx.Request.MultipartForm != nil {
|
||||
@@ -145,8 +157,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
||||
base := name
|
||||
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
||||
outName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), base+".png")
|
||||
outPath := filepath.Join("uploads", "sponsors", outName)
|
||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||
outPath := filepath.Join(sponsorDir, outName)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, src); err == nil {
|
||||
@@ -154,8 +166,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||
saved++
|
||||
} else {
|
||||
// Fallback: write original bytes with original extension
|
||||
rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name)
|
||||
rawPath := filepath.Join("uploads", "sponsors", rawName)
|
||||
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||
rawPath := filepath.Join(sponsorDir, rawName)
|
||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||
saved++
|
||||
}
|
||||
@@ -173,7 +185,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||
return
|
||||
}
|
||||
p := filepath.Join("uploads", "sponsors", name)
|
||||
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -187,7 +199,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||
|
||||
// GetQR returns the current QR image URL if present
|
||||
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
||||
path := filepath.Join("uploads", "qr.png")
|
||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||
return
|
||||
@@ -203,8 +215,9 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
_ = os.MkdirAll("uploads", 0o755)
|
||||
out, err := os.Create(filepath.Join("uploads", "qr.png"))
|
||||
dir := uploadsBaseDir()
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ShortLinkController struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
|
||||
return &ShortLinkController{DB: db}
|
||||
}
|
||||
|
||||
func randCode(n int) (string, error) {
|
||||
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func clientIP(c *gin.Context) string {
|
||||
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if xr := c.GetHeader("X-Real-IP"); xr != "" {
|
||||
return xr
|
||||
}
|
||||
return c.ClientIP()
|
||||
}
|
||||
|
||||
func hashIPShort(ip string) string {
|
||||
salted := ip + "_fotbal_club_2025"
|
||||
h := sha256.Sum256([]byte(salted))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func getScheme(c *gin.Context) string {
|
||||
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
|
||||
return p
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func parseTarget(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", errors.New("empty url")
|
||||
}
|
||||
// allow base64-encoded form as well
|
||||
if !(strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://")) {
|
||||
if dec, err := base64.URLEncoding.DecodeString(raw); err == nil {
|
||||
raw = string(dec)
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", errors.New("invalid url")
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) RedirectShort(c *gin.Context) {
|
||||
code := strings.TrimSpace(c.Param("code"))
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code"})
|
||||
return
|
||||
}
|
||||
var link models.ShortLink
|
||||
err := s.DB.Where("code = ? AND active = ?", code, true).First(&link).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "expired"})
|
||||
return
|
||||
}
|
||||
_ = s.DB.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")).Error
|
||||
|
||||
// Record click
|
||||
u, _ := url.Parse(link.TargetURL)
|
||||
click := models.LinkClick{
|
||||
ShortLinkID: &link.ID,
|
||||
TargetURL: link.TargetURL,
|
||||
IPHash: hashIPShort(clientIP(c)),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Referrer: c.GetHeader("Referer"),
|
||||
UTMSource: u.Query().Get("utm_source"),
|
||||
UTMMedium: u.Query().Get("utm_medium"),
|
||||
UTMCampaign: u.Query().Get("utm_campaign"),
|
||||
UTMContent: u.Query().Get("utm_content"),
|
||||
UTMTerm: u.Query().Get("utm_term"),
|
||||
}
|
||||
_ = s.DB.Create(&click).Error
|
||||
|
||||
// Umami event (best-effort)
|
||||
cfg := config.AppConfig
|
||||
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
|
||||
svc := services.NewUmamiService()
|
||||
_ = svc.SendEvent(cfg.UmamiWebsiteID, "ShortLink Click", "/s/"+code, link.Title, map[string]any{"code": code, "target": link.TargetURL}, "web")
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, link.TargetURL)
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) RedirectAndTrack(c *gin.Context) {
|
||||
raw := strings.TrimSpace(c.Query("u"))
|
||||
target, err := parseTarget(raw)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
||||
return
|
||||
}
|
||||
u, _ := url.Parse(target)
|
||||
click := models.LinkClick{
|
||||
TargetURL: target,
|
||||
IPHash: hashIPShort(clientIP(c)),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Referrer: c.GetHeader("Referer"),
|
||||
UTMSource: u.Query().Get("utm_source"),
|
||||
UTMMedium: u.Query().Get("utm_medium"),
|
||||
UTMCampaign: u.Query().Get("utm_campaign"),
|
||||
UTMContent: u.Query().Get("utm_content"),
|
||||
UTMTerm: u.Query().Get("utm_term"),
|
||||
}
|
||||
_ = s.DB.Create(&click).Error
|
||||
|
||||
cfg := config.AppConfig
|
||||
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
|
||||
svc := services.NewUmamiService()
|
||||
_ = svc.SendEvent(cfg.UmamiWebsiteID, "Link Redirect", "/r", "Link Redirect", map[string]any{"target": target}, "web")
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
|
||||
var body struct {
|
||||
TargetURL string `json:"target_url"`
|
||||
Title string `json:"title"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID *uint `json:"source_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Code string `json:"code"`
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
target, err := parseTarget(body.TargetURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||
return
|
||||
}
|
||||
code := strings.TrimSpace(body.Code)
|
||||
if code == "" {
|
||||
for i := 0; i < 5; i++ {
|
||||
cnd, _ := randCode(7)
|
||||
var cnt int64
|
||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
code = cnd
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if body.Active != nil { active = *body.Active }
|
||||
link := models.ShortLink{
|
||||
Code: code,
|
||||
TargetURL: target,
|
||||
Title: strings.TrimSpace(body.Title),
|
||||
SourceType: strings.TrimSpace(body.SourceType),
|
||||
SourceID: body.SourceID,
|
||||
Active: active,
|
||||
ExpiresAt: body.ExpiresAt,
|
||||
}
|
||||
if err := s.DB.Create(&link).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||
return
|
||||
}
|
||||
scheme := getScheme(c)
|
||||
host := c.Request.Host
|
||||
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code)
|
||||
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
|
||||
var items []models.ShortLink
|
||||
_ = s.DB.Order("created_at DESC").Limit(200).Find(&items).Error
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return }
|
||||
var link models.ShortLink
|
||||
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return }
|
||||
start := time.Now().AddDate(0,0,-30)
|
||||
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` }
|
||||
var rows []Row
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("DATE(created_at) as date, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("DATE(created_at)").Order("date ASC").Scan(&rows)
|
||||
var refRows []struct{ Referrer string; Count int64 }
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("referrer, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
|
||||
var utmRows []struct{ Source, Medium, Campaign string; Count int64 }
|
||||
s.DB.Model(&models.LinkClick{}).
|
||||
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
|
||||
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
|
||||
Group("utm_source, utm_medium, utm_campaign").Order("count DESC").Limit(50).Scan(&utmRows)
|
||||
c.JSON(http.StatusOK, gin.H{"timeseries": rows, "referrers": refRows, "utms": utmRows})
|
||||
}
|
||||
@@ -84,6 +84,13 @@ func SubscribeToNewsletter(db *gorm.DB, email string) error {
|
||||
subscription := NewsletterSubscription{
|
||||
Email: email,
|
||||
IsActive: true,
|
||||
Preferences: datatypes.JSONMap{
|
||||
"blogs": true,
|
||||
"matches": true,
|
||||
"events": true,
|
||||
"scores": true,
|
||||
"weekly": true,
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type LinkClick struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
ShortLinkID *uint `gorm:"index" json:"short_link_id"`
|
||||
TargetURL string `gorm:"size:2048" json:"target_url"`
|
||||
IPHash string `gorm:"size:64;index" json:"ip_hash"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
Referrer string `gorm:"size:512" json:"referrer"`
|
||||
|
||||
UTMSource string `gorm:"size:128" json:"utm_source"`
|
||||
UTMMedium string `gorm:"size:128" json:"utm_medium"`
|
||||
UTMCampaign string `gorm:"size:128" json:"utm_campaign"`
|
||||
UTMContent string `gorm:"size:128" json:"utm_content"`
|
||||
UTMTerm string `gorm:"size:128" json:"utm_term"`
|
||||
}
|
||||
|
||||
func (LinkClick) TableName() string { return "link_clicks" }
|
||||
@@ -95,6 +95,7 @@ func (n *NavigationItem) GetURL() string {
|
||||
"prefetch": "/admin/prefetch",
|
||||
"users": "/admin/uzivatele",
|
||||
"settings": "/admin/nastaveni",
|
||||
"shortlinks": "/admin/shortlinks",
|
||||
"files": "/admin/soubory",
|
||||
"docs": "/admin/docs",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
type ShortLink struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Code string `gorm:"size:16;uniqueIndex" json:"code"`
|
||||
TargetURL string `gorm:"size:2048" json:"target_url"`
|
||||
Title string `gorm:"size:512" json:"title"`
|
||||
SourceType string `gorm:"size:32;index" json:"source_type"`
|
||||
SourceID *uint `gorm:"index" json:"source_id"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at"`
|
||||
ClickCount int64 `gorm:"default:0" json:"click_count"`
|
||||
CreatedByID *uint `gorm:"index" json:"created_by_id"`
|
||||
Metadata datatypes.JSONMap `gorm:"type:jsonb" json:"metadata"`
|
||||
}
|
||||
|
||||
func (ShortLink) TableName() string { return "short_links" }
|
||||
@@ -53,6 +53,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
imageProcessingController := &controllers.ImageProcessingController{}
|
||||
articleController := controllers.NewArticleController(db)
|
||||
myuibrixController := &controllers.MyUIbrixController{DB: db}
|
||||
shortLinkController := controllers.NewShortLinkController(db)
|
||||
|
||||
// API v1 group
|
||||
{
|
||||
@@ -151,6 +152,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
|
||||
}
|
||||
|
||||
// Shortlinks (protected for editors) - create/list
|
||||
protectedShortlinks := protected.Group("/shortlinks")
|
||||
protectedShortlinks.Use(middleware.RoleAuth("editor"))
|
||||
{
|
||||
protectedShortlinks.POST("", shortLinkController.CreateShortLink)
|
||||
protectedShortlinks.GET("", shortLinkController.ListShortLinks)
|
||||
}
|
||||
|
||||
// Articles (protected - accessible by editors and admins)
|
||||
articles := protected.Group("/articles")
|
||||
articles.Use(middleware.RoleAuth("editor"))
|
||||
@@ -284,6 +293,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
|
||||
// New: send prebuilt digest by type and toggle automation
|
||||
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
|
||||
admin.POST("/newsletter/smtp-test", contactController.AdminSmtpTest)
|
||||
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
|
||||
// Removed deprecated SMTP test route (use /newsletter/test instead)
|
||||
admin.GET("/newsletter/status", contactController.GetNewsletterStatus)
|
||||
@@ -405,6 +415,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
myuibrix.GET("/preview", myuibrixController.GetElementPreview)
|
||||
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
|
||||
}
|
||||
|
||||
// Short links (admin)
|
||||
shortlinks := admin.Group("/shortlinks")
|
||||
{
|
||||
shortlinks.POST("", shortLinkController.CreateShortLink)
|
||||
shortlinks.GET("", shortLinkController.ListShortLinks)
|
||||
shortlinks.GET("/:id/stats", shortLinkController.GetShortLinkStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,6 +535,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
||||
// SetupRootRoutes registers endpoints at the root (no /api prefix)
|
||||
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
|
||||
seoController := controllers.NewSEOController(db)
|
||||
shortLinkController := controllers.NewShortLinkController(db)
|
||||
r.GET("/robots.txt", seoController.GetRobotsTXT)
|
||||
r.GET("/sitemap.xml", seoController.GetSitemapXML)
|
||||
// Public short-link redirects and generic tracked redirect
|
||||
r.GET("/s/:code", shortLinkController.RedirectShort)
|
||||
r.GET("/r", shortLinkController.RedirectAndTrack)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type logoAPIResponse struct {
|
||||
LogoURLSVG string `json:"logo_url_svg"`
|
||||
LogoURLPNG string `json:"logo_url_png"`
|
||||
LogoURL string `json:"logo_url"`
|
||||
}
|
||||
|
||||
func CacheClubLogo(db *gorm.DB, clubID string) (string, error) {
|
||||
cid := strings.TrimSpace(clubID)
|
||||
if cid == "" {
|
||||
return "", fmt.Errorf("empty club id")
|
||||
}
|
||||
baseUpload := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(baseUpload) == "" {
|
||||
baseUpload = "./uploads"
|
||||
}
|
||||
destDir := filepath.Join(baseUpload, "logos", "club", cid)
|
||||
_ = os.MkdirAll(destDir, 0o755)
|
||||
|
||||
checkExisting := func() (string, bool) {
|
||||
exts := []string{".svg", ".png", ".jpg", ".jpeg", ".webp"}
|
||||
for _, ext := range exts {
|
||||
p := filepath.Join(destDir, "club-logo"+ext)
|
||||
if fi, err := os.Stat(p); err == nil && fi.Size() > 0 {
|
||||
pub := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
|
||||
return pub, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if url, ok := checkExisting(); ok {
|
||||
return url, nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 12 * time.Second}
|
||||
req, err := http.NewRequest("GET", "https://logoapi.sportcreative.eu/logos/"+cid+"/json", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("logoapi status %d", resp.StatusCode)
|
||||
}
|
||||
var api logoAPIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&api); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logoURL := strings.TrimSpace(api.LogoURLSVG)
|
||||
if logoURL == "" {
|
||||
logoURL = strings.TrimSpace(api.LogoURLPNG)
|
||||
}
|
||||
if logoURL == "" {
|
||||
logoURL = strings.TrimSpace(api.LogoURL)
|
||||
}
|
||||
if logoURL == "" {
|
||||
return "", fmt.Errorf("no logo url in api response")
|
||||
}
|
||||
|
||||
req2, err := http.NewRequest("GET", logoURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req2.Header.Set("User-Agent", "fotbal-club/logo-cache")
|
||||
req2.Header.Set("Accept", "*/*")
|
||||
resp2, err := client.Do(req2)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode < 200 || resp2.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("logo download status %d", resp2.StatusCode)
|
||||
}
|
||||
ct := strings.ToLower(strings.TrimSpace(resp2.Header.Get("Content-Type")))
|
||||
ext := ".png"
|
||||
if strings.Contains(ct, "svg") || strings.HasSuffix(strings.ToLower(logoURL), ".svg") {
|
||||
ext = ".svg"
|
||||
} else if strings.Contains(ct, "webp") || strings.HasSuffix(strings.ToLower(logoURL), ".webp") {
|
||||
ext = ".webp"
|
||||
} else if strings.Contains(ct, "jpeg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpg") || strings.HasSuffix(strings.ToLower(logoURL), ".jpeg") {
|
||||
ext = ".jpg"
|
||||
}
|
||||
|
||||
destTmp := filepath.Join(destDir, "club-logo"+ext+".tmp")
|
||||
dest := filepath.Join(destDir, "club-logo"+ext)
|
||||
f, err := os.Create(destTmp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, copyErr := io.Copy(f, resp2.Body)
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(destTmp)
|
||||
return "", copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(destTmp)
|
||||
return "", closeErr
|
||||
}
|
||||
_ = os.Rename(destTmp, dest)
|
||||
|
||||
fi, _ := os.Stat(dest)
|
||||
mime := "image/png"
|
||||
if ext == ".svg" {
|
||||
mime = "image/svg+xml"
|
||||
} else if ext == ".jpg" || ext == ".jpeg" {
|
||||
mime = "image/jpeg"
|
||||
} else if ext == ".webp" {
|
||||
mime = "image/webp"
|
||||
}
|
||||
|
||||
publicURL := "/uploads/" + filepath.ToSlash(filepath.Join("logos", "club", cid, "club-logo"+ext))
|
||||
|
||||
var existing models.UploadedFile
|
||||
if db != nil {
|
||||
if err := db.Where("file_path = ?", dest).First(&existing).Error; err != nil {
|
||||
uf := models.UploadedFile{
|
||||
Filename: "club-logo" + ext,
|
||||
FilePath: dest,
|
||||
FileURL: publicURL,
|
||||
MimeType: mime,
|
||||
FileSize: 0,
|
||||
UploadedByID: nil,
|
||||
}
|
||||
if fi != nil {
|
||||
uf.FileSize = fi.Size()
|
||||
}
|
||||
_ = db.Create(&uf).Error
|
||||
}
|
||||
}
|
||||
|
||||
return publicURL, nil
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -475,46 +474,62 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// HTML builders
|
||||
|
||||
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
|
||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||
// Short description: prefer excerpt; otherwise derive from content
|
||||
desc := strings.TrimSpace(article.Excerpt)
|
||||
if desc == "" {
|
||||
plain := utils.SanitizeString(article.Content)
|
||||
if len(plain) > 260 {
|
||||
cut := 240
|
||||
if cut < len(plain) {
|
||||
for cut < len(plain) && plain[cut] != ' ' {
|
||||
cut++
|
||||
}
|
||||
}
|
||||
if cut > len(plain) { cut = len(plain) }
|
||||
plain = strings.TrimSpace(plain[:cut]) + "…"
|
||||
}
|
||||
desc = plain
|
||||
}
|
||||
|
||||
// Build tracked link
|
||||
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
|
||||
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
|
||||
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
|
||||
url.QueryEscape(articleURL),
|
||||
url.QueryEscape(token))
|
||||
// Category badge (if available)
|
||||
cat := strings.TrimSpace(article.CategoryName)
|
||||
var catHTML string
|
||||
if cat != "" {
|
||||
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
// Cover image (optional)
|
||||
var imgHTML string
|
||||
if strings.TrimSpace(article.ImageURL) != "" {
|
||||
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
|
||||
|
||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
|
||||
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
|
||||
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
|
||||
</p>
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
|
||||
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
|
||||
%s
|
||||
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size:22px;">%s</h3>
|
||||
%s
|
||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 12px 0;">%s</p>
|
||||
<a href="%s" style="display: inline-block; padding: 12px 20px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
|
||||
</div>
|
||||
</div>
|
||||
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
|
||||
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
|
||||
|
||||
return html
|
||||
return html
|
||||
}
|
||||
|
||||
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
|
||||
var intro string
|
||||
if notifType == "reminder_48h" {
|
||||
intro = "Připomínáme nadcházející zápas:"
|
||||
} else {
|
||||
intro = "Zápas je dnes!"
|
||||
}
|
||||
var intro string
|
||||
if notifType == "reminder_48h" {
|
||||
intro = "Připomínáme nadcházející zápas:"
|
||||
} else {
|
||||
intro = "Zápas je dnes!"
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
html := fmt.Sprintf(`
|
||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
||||
art := readJSON(filepath.Join(cacheDir, "articles.json"))
|
||||
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
|
||||
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json"))
|
||||
// Club name for subject personalization (fallback to default)
|
||||
set := readJSON(filepath.Join(cacheDir, "settings.json"))
|
||||
sm := asMap(set)
|
||||
clubName := strings.TrimSpace(str(sm["club_name"], ""))
|
||||
if clubName == "" { clubName = "Fotbal Club" }
|
||||
|
||||
sections := make([]string, 0, 4)
|
||||
|
||||
@@ -73,10 +78,10 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
||||
}
|
||||
|
||||
if len(sections) == 0 {
|
||||
return "Fotbal Club – přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
|
||||
return fmt.Sprintf("%s – přehled", clubName), "<p>Pro vybrané preference nyní nemáme novinky.</p>"
|
||||
}
|
||||
|
||||
subject = "Fotbal Club – novinky a zápasy"
|
||||
subject = fmt.Sprintf("%s – novinky a zápasy", clubName)
|
||||
html = strings.Join(sections, "\n\n")
|
||||
return subject, html
|
||||
}
|
||||
@@ -125,12 +130,22 @@ func pickUpcomingEvents(v any, n int) []Event {
|
||||
Time: str(m["time"], ""),
|
||||
Url: str(m["url"], ""),
|
||||
}
|
||||
if e.Date == "" {
|
||||
st := str(m["start_time"], "")
|
||||
if st != "" {
|
||||
if tm, err := time.Parse(time.RFC3339, st); err == nil {
|
||||
lt := tm.In(time.Local)
|
||||
e.Date = lt.Format("2006-01-02")
|
||||
e.Time = lt.Format("15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Match struct { Home, Away, Date, Time, Competition, Link, Score string }
|
||||
type Match struct { Home, Away, Date, Time, Competition, CompCode, Link, Score string }
|
||||
|
||||
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||
compSet := make(map[string]bool)
|
||||
@@ -142,7 +157,9 @@ func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||
ts := parseDateTimeISO(m.Date, m.Time)
|
||||
if ts.IsZero() || ts.Before(now) { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
|
||||
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
|
||||
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
if len(out) >= n { break }
|
||||
@@ -163,7 +180,9 @@ func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.
|
||||
// treat as result if score like "2:1" exists
|
||||
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
|
||||
if len(compSet) > 0 {
|
||||
if !compSet[strings.ToLower(m.Competition)] { continue }
|
||||
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
|
||||
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
|
||||
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
@@ -188,19 +207,20 @@ func facrAllMatches(v any) []Match {
|
||||
for _, c := range asList(comps) {
|
||||
cm := asMap(c)
|
||||
compName := str(cm["name"], str(cm["code"], ""))
|
||||
compCode := str(cm["code"], "")
|
||||
for _, mm := range asList(cm["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), compName))
|
||||
out = append(out, toMatch(asMap(mm), compName, compCode))
|
||||
}
|
||||
}
|
||||
}
|
||||
// flat matches fallback
|
||||
for _, mm := range asList(m["matches"]) {
|
||||
out = append(out, toMatch(asMap(mm), ""))
|
||||
out = append(out, toMatch(asMap(mm), "", ""))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toMatch(m map[string]any, comp string) Match {
|
||||
func toMatch(m map[string]any, compName, compCode string) Match {
|
||||
dt := str(m["date_time"], "")
|
||||
var date, tm string
|
||||
if dt != "" && strings.Contains(dt, " ") {
|
||||
@@ -215,7 +235,8 @@ func toMatch(m map[string]any, comp string) Match {
|
||||
Away: str(m["away"], ""),
|
||||
Date: date,
|
||||
Time: tm,
|
||||
Competition: str(m["competition"], str(m["competition_name"], comp)),
|
||||
Competition: str(m["competition"], str(m["competition_name"], compName)),
|
||||
CompCode: str(m["competition_code"], compCode),
|
||||
Link: str(m["facr_link"], str(m["report_url"], "#")),
|
||||
Score: str(m["score"], ""),
|
||||
}
|
||||
@@ -224,10 +245,19 @@ func toMatch(m map[string]any, comp string) Match {
|
||||
func parseDateTimeISO(d, t string) time.Time {
|
||||
if d == "" { return time.Time{} }
|
||||
if t == "" { t = "00:00" }
|
||||
layout := "2006-01-02T15:04:05"
|
||||
// try shorter HH:MM format
|
||||
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
|
||||
return parseTime(layout, d+"T"+t)
|
||||
if strings.Contains(d, ".") {
|
||||
if len(t) == 5 {
|
||||
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
|
||||
}
|
||||
if tm := parseTime("02.01.2006 15:04:05", d+" "+t); !tm.IsZero() { return tm }
|
||||
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
|
||||
}
|
||||
if len(t) == 5 {
|
||||
if tm := parseTime("2006-01-02T15:04", d+"T"+t); !tm.IsZero() { return tm }
|
||||
return parseTime("2006-01-02 15:04", d+" "+t)
|
||||
}
|
||||
if tm := parseTime("2006-01-02T15:04:05", d+"T"+t); !tm.IsZero() { return tm }
|
||||
return parseTime("2006-01-02 15:04:05", d+" "+t)
|
||||
}
|
||||
|
||||
func parseTime(layout, s string) time.Time {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user