mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
de day #74
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
REACT_APP_API_URL=/api/v1
|
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_NAME=Fotbal Club Manager
|
||||||
REACT_APP_ENV=development
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
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
|
# Enable gzip compression for text-based assets
|
||||||
gzip on;
|
gzip on;
|
||||||
@@ -78,6 +80,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Accept-Encoding gzip;
|
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
|
# Enable buffering for better performance
|
||||||
proxy_buffering on;
|
proxy_buffering on;
|
||||||
@@ -86,6 +90,24 @@ server {
|
|||||||
proxy_busy_buffers_size 8k;
|
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
|
# Proxy backend-served assets so the frontend can use relative URLs
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
|
|||||||
+13
-2
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
|
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 './styles/custom-scrollbar.css';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import AuthPage from './pages/AuthPage';
|
import AuthPage from './pages/AuthPage';
|
||||||
@@ -51,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
|
|||||||
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
import FilesAdminPage from './pages/admin/FilesAdminPage';
|
||||||
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
|
||||||
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
|
||||||
|
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
|
||||||
import SemiAdminPage from './pages/SemiAdminPage';
|
import SemiAdminPage from './pages/SemiAdminPage';
|
||||||
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
import PollsAdminPage from './pages/admin/PollsAdminPage';
|
||||||
// Admin pages render their own AdminLayout internally
|
// Admin pages render their own AdminLayout internally
|
||||||
@@ -75,6 +76,7 @@ import ForbiddenPage from './pages/ForbiddenPage';
|
|||||||
import NotFoundPage from './pages/NotFoundPage';
|
import NotFoundPage from './pages/NotFoundPage';
|
||||||
import VideosPage from './pages/VideosPage';
|
import VideosPage from './pages/VideosPage';
|
||||||
import SearchPage from './pages/SearchPage';
|
import SearchPage from './pages/SearchPage';
|
||||||
|
import ShortRedirectPage from './pages/ShortRedirectPage';
|
||||||
import ClothingPage from './pages/ClothingPage';
|
import ClothingPage from './pages/ClothingPage';
|
||||||
import PollsPage from './pages/PollsPage';
|
import PollsPage from './pages/PollsPage';
|
||||||
import { useUmami } from './hooks/useUmami';
|
import { useUmami } from './hooks/useUmami';
|
||||||
@@ -258,6 +260,12 @@ const FontLoader: React.FC = () => {
|
|||||||
return null;
|
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 = () => {
|
const App: React.FC = () => {
|
||||||
// Uses shared ProtectedRoute component for auth guard
|
// Uses shared ProtectedRoute component for auth guard
|
||||||
|
|
||||||
@@ -352,7 +360,9 @@ const App: React.FC = () => {
|
|||||||
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
|
||||||
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
<Route path="/obchodni-podminky" element={<TermsPage />} />
|
||||||
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
|
<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 */}
|
{/* Slug routes must precede id route to avoid conflicts */}
|
||||||
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
<Route path="/news/:slug" element={<ArticleDetailPage />} />
|
||||||
<Route path="/articles/slug/: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" element={<ScoreboardAdminPage />} />
|
||||||
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
|
||||||
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
|
||||||
|
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
|
||||||
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
<Route path="/admin/soubory" element={<FilesAdminPage />} />
|
||||||
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
|
||||||
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
|
<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';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const STORAGE_KEY = 'cookie_consent';
|
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 saveAndClose = (c: Consent) => {
|
||||||
const payload = { ...c, timestamp: new Date().toISOString() };
|
const payload = { ...c, timestamp: new Date().toISOString() };
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||||
@@ -62,46 +74,59 @@ const CookieBanner: React.FC = () => {
|
|||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
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">
|
<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}>
|
<Text fontSize="sm" mb={2}>
|
||||||
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
|
<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
|
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v
|
||||||
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
|
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
|
||||||
</Text>
|
</Text>
|
||||||
{managing && (
|
{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>
|
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
|
||||||
<Flex direction="column" gap={2}>
|
<Flex direction="column" gap={2}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Checkbox isChecked isDisabled>
|
||||||
<input type="checkbox" checked readOnly />
|
|
||||||
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
|
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
|
||||||
</label>
|
</Checkbox>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Checkbox
|
||||||
<input
|
isChecked={!!consent.preferences}
|
||||||
type="checkbox"
|
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
|
||||||
checked={!!consent.preferences}
|
>
|
||||||
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
|
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
|
||||||
</label>
|
</Checkbox>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Checkbox
|
||||||
<input
|
isChecked={!!consent.analytics}
|
||||||
type="checkbox"
|
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
|
||||||
checked={!!consent.analytics}
|
>
|
||||||
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
|
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
|
||||||
</label>
|
</Checkbox>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Checkbox
|
||||||
<input
|
isChecked={!!consent.marketing}
|
||||||
type="checkbox"
|
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
|
||||||
checked={!!consent.marketing}
|
>
|
||||||
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
|
|
||||||
/>
|
|
||||||
<Text fontSize="sm">Marketingové cookies</Text>
|
<Text fontSize="sm">Marketingové cookies</Text>
|
||||||
</label>
|
</Checkbox>
|
||||||
<Flex gap={2} mt={2} wrap="wrap">
|
<Flex gap={2} mt={2} wrap="wrap">
|
||||||
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
|
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
|
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
|
||||||
@@ -111,8 +136,8 @@ const CookieBanner: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Flex gap={2} align="center" wrap="wrap">
|
<Flex gap={2} align="center" wrap="wrap">
|
||||||
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
|
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="ghost">Nastavit</Button>
|
||||||
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</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>
|
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { getCachedYouTube } from '../services/youtube';
|
|||||||
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
|
||||||
import { getMyNewsletterToken } from '../services/public/newsletter';
|
import { getMyNewsletterToken } from '../services/public/newsletter';
|
||||||
import { API_URL } from '../services/api';
|
import { API_URL } from '../services/api';
|
||||||
|
import { assetUrl } from '../utils/url';
|
||||||
|
|
||||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
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="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
|
||||||
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</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>
|
<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>
|
<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>
|
<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) => {
|
{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 customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
|
||||||
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
|
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
|
||||||
@@ -191,7 +192,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{hasArticles !== false && (
|
{hasArticles === true && (
|
||||||
<>
|
<>
|
||||||
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
|
||||||
{Array.isArray(categories) && categories.length > 0 && (
|
{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="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
|
||||||
)}
|
)}
|
||||||
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</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>
|
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
|
||||||
)}
|
)}
|
||||||
{settings?.shop_url && (
|
{settings?.shop_url && (
|
||||||
@@ -258,6 +259,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
const accountPath = isAdmin ? '/admin/nastaveni' : '/semiadmin';
|
||||||
const { data: settings } = usePublicSettings();
|
const { data: settings } = usePublicSettings();
|
||||||
const theme = useClubTheme();
|
const theme = useClubTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -324,16 +326,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
// Set favicon/logo in head for fan pages (SPA)
|
// Set favicon/logo in head for fan pages (SPA)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
const raw = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
|
||||||
if (!url) return;
|
if (!raw) return;
|
||||||
// Normalize relative upload paths to API origin so favicon resolves on all pages
|
const url = assetUrl(raw) || raw;
|
||||||
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 setIcon = (rel: string) => {
|
const setIcon = (rel: string) => {
|
||||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||||
@@ -544,11 +539,29 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
}));
|
}));
|
||||||
}, [navCategories]);
|
}, [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
|
// Use dynamic navigation if available, otherwise fallback to hardcoded
|
||||||
let NAV_LINKS: NavLink[] = useMemo(() => {
|
let NAV_LINKS: NavLink[] = useMemo(() => {
|
||||||
if (!navLoading && dynamicNavItems.length > 0) {
|
if (!navLoading && filteredDynamicNavItems.length > 0) {
|
||||||
// Use dynamic navigation from API
|
// 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
|
// Inject categories into "Články" or "Blog" navigation item if it exists
|
||||||
if (categoryItems.length > 0) {
|
if (categoryItems.length > 0) {
|
||||||
@@ -566,8 +579,19 @@ 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
|
// Fallback to hardcoded navigation
|
||||||
@@ -607,40 +631,40 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
links = links.filter((n) => n.label !== 'Tabulky');
|
links = links.filter((n) => n.label !== 'Tabulky');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Aktivity when there are no activities
|
// Hide Aktivity unless there are activities
|
||||||
if (hasActivities === false) {
|
if (hasActivities !== true) {
|
||||||
links = links.filter((n) => n.label !== 'Aktivity');
|
links = links.filter((n) => n.label !== 'Aktivity');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Hráči when there are no players
|
// Hide Hráči unless there are players
|
||||||
if (hasPlayers === false) {
|
if (hasPlayers !== true) {
|
||||||
links = links.filter((n) => n.label !== 'Hráči');
|
links = links.filter((n) => n.label !== 'Hráči');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Články when there are no articles
|
// Hide Články unless there are articles
|
||||||
if (hasArticles === false) {
|
if (hasArticles !== true) {
|
||||||
links = links.filter((n) => n.label !== 'Články');
|
links = links.filter((n) => n.label !== 'Články');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Videa when there are no videos
|
// Hide Videa unless there are videos
|
||||||
if (hasVideos === false) {
|
if (hasVideos !== true) {
|
||||||
links = links.filter((n) => n.label !== 'Videa');
|
links = links.filter((n) => n.label !== 'Videa');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide Fotogalerie when there is no gallery content
|
// Hide Fotogalerie unless there is gallery content
|
||||||
if (hasGallery === false) {
|
if (hasGallery !== true) {
|
||||||
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
|
links = links.filter((n) => n.to !== '/galerie');
|
||||||
}
|
}
|
||||||
|
|
||||||
return links;
|
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 (
|
return (
|
||||||
<Box position="sticky" top={0} zIndex={1000}>
|
<Box position="sticky" top={0} zIndex={1000}>
|
||||||
{/* Top bar with socials and quick external links */}
|
{/* Top bar with socials and quick external links */}
|
||||||
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
|
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
|
||||||
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
|
<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}>
|
<Flex align="center" justify="space-between" gap={2}>
|
||||||
<HStack spacing={2}>
|
<HStack spacing={2}>
|
||||||
{settings?.shop_url && (
|
{settings?.shop_url && (
|
||||||
@@ -674,15 +698,15 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
boxShadow={scrolled ? 'sm' : 'none'}
|
boxShadow={scrolled ? 'sm' : 'none'}
|
||||||
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
|
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} />
|
<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}>
|
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
|
||||||
<Flex h={16} alignItems="center" justifyContent="space-between">
|
<Flex h={16} alignItems="center" justifyContent="space-between">
|
||||||
<HStack spacing={4} alignItems="center">
|
<HStack spacing={4} alignItems="center">
|
||||||
{/* Club Logo only */}
|
{/* Club Logo only */}
|
||||||
<HStack as={RouterLink} to="/" spacing={3} align="center">
|
<HStack as={RouterLink} to="/" spacing={3} align="center">
|
||||||
{(settings?.club_logo_url || theme.logoUrl) && (
|
{(settings?.club_logo_url || theme.logoUrl) && (
|
||||||
<Image
|
<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'}
|
alt={settings?.club_name || theme.name || 'Logo'}
|
||||||
boxSize={{ base: '36px', md: '40px' }}
|
boxSize={{ base: '36px', md: '40px' }}
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
@@ -826,9 +850,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
|
|||||||
<Avatar size="sm" name={user?.name || 'Uživatel'} />
|
<Avatar size="sm" name={user?.name || 'Uživatel'} />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList>
|
<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 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>}
|
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
|
||||||
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
|
||||||
</MenuList>
|
</MenuList>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
|
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 { Link as RouterLink, useLocation } from 'react-router-dom';
|
||||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
FaTachometerAlt,
|
FaTachometerAlt,
|
||||||
FaUsers,
|
FaUsers,
|
||||||
@@ -30,13 +30,15 @@ import {
|
|||||||
FaTshirt,
|
FaTshirt,
|
||||||
FaBullhorn,
|
FaBullhorn,
|
||||||
FaUserShield,
|
FaUserShield,
|
||||||
FaFileAlt
|
FaFileAlt,
|
||||||
|
FaLink
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getUpcomingEvents } from '../../services/eventService';
|
import { getUpcomingEvents } from '../../services/eventService';
|
||||||
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
icon: any;
|
icon: any;
|
||||||
@@ -146,6 +148,7 @@ const getIconForPageType = (pageType?: string): any => {
|
|||||||
settings: FaPalette,
|
settings: FaPalette,
|
||||||
files: FaFolder,
|
files: FaFolder,
|
||||||
docs: FaBook,
|
docs: FaBook,
|
||||||
|
shortlinks: FaLink,
|
||||||
};
|
};
|
||||||
return iconMap[pageType || ''] || FaFileAlt;
|
return iconMap[pageType || ''] || FaFileAlt;
|
||||||
};
|
};
|
||||||
@@ -175,6 +178,9 @@ const AdminSidebar = ({
|
|||||||
// Dynamic navigation state
|
// Dynamic navigation state
|
||||||
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
|
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
|
||||||
const [navLoading, setNavLoading] = useState(true);
|
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
|
// Restore scroll on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -287,7 +293,7 @@ const AdminSidebar = ({
|
|||||||
<Box px={3} mb={8}>
|
<Box px={3} mb={8}>
|
||||||
<Flex align="center" gap={3} mb={2}>
|
<Flex align="center" gap={3} mb={2}>
|
||||||
<Image
|
<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"
|
alt="Club Logo"
|
||||||
boxSize="48px"
|
boxSize="48px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
@@ -365,6 +371,16 @@ const AdminSidebar = ({
|
|||||||
>
|
>
|
||||||
MyUIbrix Editor
|
MyUIbrix Editor
|
||||||
</NavItem>
|
</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
|
// Fallback to hardcoded navigation
|
||||||
@@ -592,6 +608,13 @@ const AdminSidebar = ({
|
|||||||
>
|
>
|
||||||
Nastavení
|
Nastavení
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<NavItem
|
||||||
|
icon={FaLink}
|
||||||
|
to="/admin/shortlinks"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Zkrácené odkazy
|
||||||
|
</NavItem>
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={FaFolder}
|
icon={FaFolder}
|
||||||
to="/admin/soubory"
|
to="/admin/soubory"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface PollLinkerProps {
|
|||||||
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
|
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(true);
|
||||||
const [selectedPollId, setSelectedPollId] = useState<string>('');
|
const [selectedPollId, setSelectedPollId] = useState<string>('');
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
@@ -363,7 +363,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
|
|||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Tabs size="sm" variant="enclosed">
|
<Tabs size="sm" variant="enclosed" defaultIndex={1}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>Propojit existující</Tab>
|
<Tab>Propojit existující</Tab>
|
||||||
<Tab>Vytvořit novou</Tab>
|
<Tab>Vytvořit novou</Tab>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const quillRef = useRef<ReactQuill | null>(null);
|
const quillRef = useRef<ReactQuill | null>(null);
|
||||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
|
const selectedImageIdRef = useRef<string | null>(null);
|
||||||
|
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
// Ensure component is mounted before rendering Quill
|
// Ensure component is mounted before rendering Quill
|
||||||
@@ -192,6 +194,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
},
|
},
|
||||||
}), [toolbarConfig, onImageUpload, handleImageUpload]);
|
}), [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
|
// Get cropped blob
|
||||||
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -368,18 +418,40 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const editorRect = editor.root.getBoundingClientRect();
|
const editorRect = editor.root.getBoundingClientRect();
|
||||||
const scrollTop = editor.root.scrollTop;
|
const scrollTop = editor.root.scrollTop;
|
||||||
const scrollLeft = editor.root.scrollLeft;
|
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)
|
// Create edge handles (right, bottom, left, top)
|
||||||
const handles = [
|
const handles = [
|
||||||
{ position: 'right', cursor: 'ew-resize', width: '8px', height: '60%' },
|
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
|
||||||
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '8px' },
|
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
|
||||||
{ position: 'left', cursor: 'ew-resize', width: '8px', height: '60%' },
|
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
|
||||||
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '8px' },
|
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
|
||||||
// Corner handles
|
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'bottom-right', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
|
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'bottom-left', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
|
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'top-right', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
|
{ position: 'top-left', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'top-left', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const updateHandlePositions = () => {
|
const updateHandlePositions = () => {
|
||||||
@@ -467,7 +539,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handle.addEventListener('mousedown', (e) => {
|
handle.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
@@ -477,26 +549,19 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
startWidth = img.offsetWidth;
|
startWidth = img.offsetWidth;
|
||||||
const startHeight = img.offsetHeight;
|
const startHeight = img.offsetHeight;
|
||||||
const aspectRatio = startWidth / startHeight;
|
const aspectRatio = startWidth / startHeight;
|
||||||
|
const onPointerMove = (ev: PointerEvent) => {
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
const deltaX = e.clientX - startX;
|
const deltaX = ev.clientX - startX;
|
||||||
const deltaY = e.clientY - startY;
|
const deltaY = ev.clientY - startY;
|
||||||
let newWidth = startWidth;
|
let newWidth = startWidth;
|
||||||
|
|
||||||
// Calculate new width based on handle position
|
|
||||||
if (position.includes('right')) {
|
if (position.includes('right')) {
|
||||||
newWidth = startWidth + deltaX;
|
newWidth = startWidth + deltaX;
|
||||||
} else if (position.includes('left')) {
|
} else if (position.includes('left')) {
|
||||||
newWidth = startWidth - deltaX;
|
newWidth = startWidth - deltaX;
|
||||||
} else if (position.includes('bottom') || position.includes('top')) {
|
} else if (position.includes('bottom') || position.includes('top')) {
|
||||||
// For vertical handles, maintain aspect ratio
|
|
||||||
newWidth = startWidth + (deltaY * aspectRatio);
|
newWidth = startWidth + (deltaY * aspectRatio);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constrain width
|
|
||||||
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
|
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
|
||||||
|
|
||||||
img.style.width = `${newWidth}px`;
|
img.style.width = `${newWidth}px`;
|
||||||
img.style.maxWidth = '100%';
|
img.style.maxWidth = '100%';
|
||||||
img.style.height = 'auto';
|
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))));
|
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
|
||||||
} catch {}
|
} catch {}
|
||||||
updateHandlePositions();
|
updateHandlePositions();
|
||||||
|
updateSizeLabel(newWidth);
|
||||||
};
|
};
|
||||||
|
const onPointerUp = () => {
|
||||||
const onMouseUp: (ev: MouseEvent) => void = () => {
|
|
||||||
isResizing = false;
|
isResizing = false;
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('pointerup', onPointerUp);
|
||||||
onChangeRef.current(editor.root.innerHTML);
|
onChangeRef.current(editor.root.innerHTML);
|
||||||
|
const id = selectedImageIdRef.current;
|
||||||
|
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
|
||||||
};
|
};
|
||||||
|
document.addEventListener('pointermove', onPointerMove);
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
document.addEventListener('pointerup', onPointerUp);
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(handle);
|
container.appendChild(handle);
|
||||||
});
|
});
|
||||||
|
|
||||||
updateHandlePositions();
|
updateHandlePositions();
|
||||||
|
updateSizeLabel(img.offsetWidth || img.width || 0);
|
||||||
editor.root.style.position = 'relative';
|
editor.root.style.position = 'relative';
|
||||||
editor.root.appendChild(container);
|
editor.root.appendChild(container);
|
||||||
|
container.appendChild(sizeLabel);
|
||||||
resizeHandle = container;
|
resizeHandle = container;
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
@@ -547,6 +615,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedImage = img;
|
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.outline = '3px solid #3182ce';
|
||||||
img.style.cursor = 'move';
|
img.style.cursor = 'move';
|
||||||
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
||||||
@@ -622,6 +697,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
setShowImageToolbar(true);
|
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 = () => {
|
const deselectImage = () => {
|
||||||
if (selectedImage) {
|
if (selectedImage) {
|
||||||
selectedImage.style.outline = '';
|
selectedImage.style.outline = '';
|
||||||
@@ -965,6 +1050,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Force overlay reposition
|
// Force overlay reposition
|
||||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||||
}
|
}
|
||||||
|
reselectAfterContentUpdate();
|
||||||
|
|
||||||
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
|
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -998,10 +1084,20 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Force overlay reposition
|
// Force overlay reposition
|
||||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
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 });
|
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
|
||||||
}
|
}
|
||||||
}, [selectedImageElement, toast]);
|
}, [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 }) => {
|
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
|
||||||
if (!selectedImageElement) return;
|
if (!selectedImageElement) return;
|
||||||
const editor = quillRef.current?.getEditor();
|
const editor = quillRef.current?.getEditor();
|
||||||
@@ -1017,6 +1113,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
onChangeRef.current(editor.root.innerHTML);
|
onChangeRef.current(editor.root.innerHTML);
|
||||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||||
}
|
}
|
||||||
|
// Keep selection active for subsequent operations (e.g., 50% → 75%)
|
||||||
|
reselectAfterContentUpdate();
|
||||||
if (!opts?.silent) {
|
if (!opts?.silent) {
|
||||||
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
|
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);
|
onChangeRef.current(editor.root.innerHTML);
|
||||||
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||||
}
|
}
|
||||||
|
reselectAfterContentUpdate();
|
||||||
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
|
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
|
||||||
}, [selectedImageElement, toast]);
|
}, [selectedImageElement, toast]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,34 @@ import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
|
|||||||
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
import { getTeamLogo } from '../../utils/sportLogosAPI';
|
||||||
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
|
||||||
import '../../styles/logos.css';
|
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'> {
|
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
@@ -32,6 +60,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
|||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const { data: publicSettings } = usePublicSettings();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -40,11 +69,30 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
// Load admin overrides (cached)
|
||||||
const url = await getTeamLogo(teamId, teamName, facrLogo);
|
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }> } = {};
|
||||||
|
try { overrides = await loadTeamOverrides(); } catch {}
|
||||||
if (mounted) {
|
// Prefer local club logo for own team when IDs match
|
||||||
setLogoUrl(url);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch logo:', e);
|
console.error('Failed to fetch logo:', e);
|
||||||
@@ -65,7 +113,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [teamId, teamName, facrLogo]);
|
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url]);
|
||||||
|
|
||||||
// Size mapping
|
// Size mapping
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
@@ -101,7 +149,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl || '/logo192.png'}
|
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
|
||||||
alt={alt || teamName || 'Team logo'}
|
alt={alt || teamName || 'Team logo'}
|
||||||
{...sizeProps}
|
{...sizeProps}
|
||||||
{...imageProps}
|
{...imageProps}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
|
|||||||
borderRadius={borderRadius}
|
borderRadius={borderRadius}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
|
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -70,6 +71,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
|
|||||||
maxH="400px"
|
maxH="400px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
|
fallbackSrc="/dist/img/logo-club-empty.svg"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</PopoverBody>
|
</PopoverBody>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
|
|||||||
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
import { useClubTheme } from '../../contexts/ClubThemeContext';
|
||||||
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
|
||||||
import { getCategories, Category } from '../../services/public';
|
import { getCategories, Category } from '../../services/public';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
// Minimal NavLink type used to render items
|
// Minimal NavLink type used to render items
|
||||||
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
|
||||||
@@ -121,7 +122,7 @@ const SpartaNavbar: React.FC = () => {
|
|||||||
return links;
|
return links;
|
||||||
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
|
}, [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';
|
const clubName = settings?.club_name || theme.name || 'Klub';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -133,7 +133,17 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
|
<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 />
|
<AlertIcon />
|
||||||
<Box>
|
<Box>
|
||||||
<Text mb={1}>{error}</Text>
|
<Text mb={1}>{error}</Text>
|
||||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
<Text>
|
||||||
Otevřít v OpenStreetMap
|
<Link href={openStreetMapUrl} isExternal color="blue.400">Otevřít v OpenStreetMap</Link>
|
||||||
</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>
|
</Box>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -172,10 +186,12 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text fontSize="sm" color="gray.500">
|
<Text fontSize="sm" color="gray.500">
|
||||||
Přesnost určena pomocí otevřených mapových dat.{' '}
|
Přesnost určena pomocí otevřených mapových dat. Zobrazit v{' '}
|
||||||
<Link href={openStreetMapUrl} isExternal color="blue.400">
|
<Link href={openStreetMapUrl} isExternal color="blue.400">OpenStreetMap</Link>
|
||||||
Zobrazit v OpenStreetMap
|
{' · '}
|
||||||
</Link>
|
<Link href={googleMapsUrl} isExternal color="blue.400">Google Maps</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link href={mapyCzUrl} isExternal color="blue.400">Mapy.cz</Link>
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</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 { useQuery } from '@tanstack/react-query';
|
||||||
import { facrApi } from '../../services/facr/facrApi';
|
import { facrApi } from '../../services/facr/facrApi';
|
||||||
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
|
import { TeamLogo } from '../common/TeamLogo';
|
||||||
|
|
||||||
const ClubHeader: React.FC = () => {
|
const ClubHeader: React.FC = () => {
|
||||||
const { data: settings } = usePublicSettings();
|
const { data: settings } = usePublicSettings();
|
||||||
@@ -18,7 +19,15 @@ const ClubHeader: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
|
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
|
||||||
<HStack spacing={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>
|
<Box>
|
||||||
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
|
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
|
||||||
<Text color="gray.600" fontSize="sm">
|
<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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
|
<Tabs variant="soft-rounded" colorScheme="blue" size="sm">
|
||||||
<TabList>
|
<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) => {
|
{sortedCompetitions.map((c) => {
|
||||||
const label = c.alias || c.name;
|
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>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
|
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface HeaderVariantsProps {
|
interface HeaderVariantsProps {
|
||||||
variant: 'unified' | 'edge' | 'minimal' | 'modern';
|
variant: 'unified' | 'edge' | 'minimal' | 'modern';
|
||||||
@@ -15,9 +16,9 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
|
|||||||
clubLogo,
|
clubLogo,
|
||||||
clubId,
|
clubId,
|
||||||
}) => {
|
}) => {
|
||||||
const displayLogo = clubId
|
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
|
||||||
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
|
||||||
: clubLogo || '/images/club-logo.png';
|
: '/images/club-logo.png');
|
||||||
|
|
||||||
// Unified variant - classic header
|
// Unified variant - classic header
|
||||||
if (variant === 'unified') {
|
if (variant === 'unified') {
|
||||||
|
|||||||
@@ -99,10 +99,16 @@ const MatchesSection: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{isLoading && <Skeleton height="200px" />}
|
{isLoading && <Skeleton height="200px" />}
|
||||||
{!isLoading && data && (
|
{!isLoading && data && (
|
||||||
<Tabs variant="enclosed-colored" isFitted>
|
<Tabs variant="enclosed-colored" size="sm">
|
||||||
<TabList>
|
<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) => (
|
{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>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ const TableSection: React.FC = () => {
|
|||||||
const rankTopText = useColorModeValue('green.800', 'white');
|
const rankTopText = useColorModeValue('green.800', 'white');
|
||||||
const pointsBg = useColorModeValue('blue.600', 'blue.400');
|
const pointsBg = useColorModeValue('blue.600', 'blue.400');
|
||||||
const pointsText = 'white';
|
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({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ['facr-table', clubId, clubType],
|
queryKey: ['facr-table', clubId, clubType],
|
||||||
queryFn: () => facrApi.getClubTable(clubId, clubType),
|
queryFn: () => facrApi.getClubTable(clubId, clubType),
|
||||||
@@ -135,15 +142,33 @@ const TableSection: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
{!isLoading && !isError && data && data.competitions?.length > 0 && (
|
{!isLoading && !isError && data && data.competitions?.length > 0 && (
|
||||||
<Tabs variant="enclosed" colorScheme="blue" isFitted>
|
<Tabs variant="enclosed" colorScheme="blue" size="sm">
|
||||||
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
|
<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) => (
|
{data.competitions?.map((c) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={c.id}
|
key={c.id}
|
||||||
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
|
_selected={{ bg: tabSelectedBg, color: tabSelectedColor, borderColor: tabSelectedBorderColor }}
|
||||||
color={useColorModeValue('gray.800', 'gray.200')}
|
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>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
|
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPlayers, Player } from '../../services/players';
|
import { getPlayers, Player } from '../../services/players';
|
||||||
|
import { assetUrl } from '../../utils/url';
|
||||||
|
|
||||||
const TeamScroller: React.FC = () => {
|
const TeamScroller: React.FC = () => {
|
||||||
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
|
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">
|
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
|
||||||
{players.map((p: Player) => (
|
{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')}>
|
<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 fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
|
||||||
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
|||||||
const [showTop, setShowTop] = useState(false);
|
const [showTop, setShowTop] = useState(false);
|
||||||
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
|
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
|
||||||
const headerVariant = getVariant('header', 'unified');
|
const headerVariant = getVariant('header', 'unified');
|
||||||
|
const sponsorsVariant = getVariant('sponsors', 'grid');
|
||||||
|
const footerVariant = getVariant('footer', 'standard');
|
||||||
|
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -39,10 +42,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
|||||||
return (
|
return (
|
||||||
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
<Box minH="100vh" bg="bg.app" overflowX="hidden">
|
||||||
<Box id="top" position="absolute" top={0} left={0} />
|
<Box id="top" position="absolute" top={0} left={0} />
|
||||||
{headerInsideContainer ? (
|
{headerIsInside ? (
|
||||||
<>
|
<>
|
||||||
<Container maxW="container.xl" py={8}>
|
<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' ? (
|
{headerVariant === 'sparta_navbar' ? (
|
||||||
<SpartaNavbar />
|
<SpartaNavbar />
|
||||||
) : (
|
) : (
|
||||||
@@ -51,14 +54,16 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
|||||||
</Box>
|
</Box>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
<SponsorsSection />
|
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
|
||||||
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
|
<SponsorsSection />
|
||||||
|
</Box>
|
||||||
|
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Box>
|
</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' ? (
|
{headerVariant === 'sparta_navbar' ? (
|
||||||
<SpartaNavbar />
|
<SpartaNavbar />
|
||||||
) : (
|
) : (
|
||||||
@@ -69,8 +74,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
|
|||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
{/* Global sponsors section across front-facing pages */}
|
{/* Global sponsors section across front-facing pages */}
|
||||||
<SponsorsSection />
|
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
|
||||||
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
|
<SponsorsSection />
|
||||||
|
</Box>
|
||||||
|
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Box>
|
</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,
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
|
SimpleGrid,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPolls, getPoll } from '../../services/polls';
|
import { getPolls, getPoll } from '../../services/polls';
|
||||||
@@ -17,6 +18,7 @@ interface EmbeddedPollProps {
|
|||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
|
maxPolls?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +31,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
videoUrl,
|
videoUrl,
|
||||||
title = 'Hlasování',
|
title = 'Hlasování',
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
|
maxPolls,
|
||||||
}) => {
|
}) => {
|
||||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||||
|
|
||||||
@@ -46,16 +49,31 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
staleTime: 2 * 60 * 1000,
|
staleTime: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get full poll data for each
|
// Get full poll data for each (all linked polls)
|
||||||
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
|
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({
|
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 () => {
|
queryFn: async () => {
|
||||||
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
|
const promises = preSortedLimited.map((poll) => getPoll(poll.id));
|
||||||
return await Promise.all(promises);
|
return await Promise.all(promises);
|
||||||
},
|
},
|
||||||
enabled: pollsToDisplay.length > 0,
|
enabled: preSortedLimited.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Don't render anything if no content identifier provided
|
// Don't render anything if no content identifier provided
|
||||||
@@ -84,35 +102,83 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
|
<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 && (
|
{showTitle && (
|
||||||
<Heading size="md" textAlign="center">
|
<Heading size="md" textAlign="center">
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<VStack spacing={4} w="full">
|
{isLoadingPolls ? (
|
||||||
{isLoadingPolls ? (
|
<VStack py={8}>
|
||||||
<VStack py={8}>
|
<Spinner />
|
||||||
<Spinner />
|
<Text>Načítání...</Text>
|
||||||
<Text>Načítání...</Text>
|
</VStack>
|
||||||
</VStack>
|
) : (
|
||||||
) : (
|
(() => {
|
||||||
pollsData.map((pollResponse) => (
|
// Sort: rating first, then featured, then newest
|
||||||
<Box key={pollResponse.poll.id} w="full">
|
const sorted = [...(pollsData || [])].sort((a, b) => {
|
||||||
<PollCard
|
const aRating = a.poll.type === 'rating' ? 1 : 0;
|
||||||
poll={pollResponse.poll}
|
const bRating = b.poll.type === 'rating' ? 1 : 0;
|
||||||
hasVoted={pollResponse.has_voted}
|
if (aRating !== bRating) return bRating - aRating;
|
||||||
isActive={pollResponse.is_active}
|
const aFeat = a.poll.featured ? 1 : 0;
|
||||||
canShowResults={pollResponse.can_show_results}
|
const bFeat = b.poll.featured ? 1 : 0;
|
||||||
/>
|
if (aFeat !== bFeat) return bFeat - aFeat;
|
||||||
</Box>
|
const aDate = new Date(a.poll.created_at).getTime();
|
||||||
))
|
const bDate = new Date(b.poll.created_at).getTime();
|
||||||
)}
|
return bDate - aDate;
|
||||||
</VStack>
|
});
|
||||||
|
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>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmbeddedPoll;
|
export default EmbeddedPoll;
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,12 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
Link,
|
Link,
|
||||||
} from '@chakra-ui/react';
|
} 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 { CheckIcon, StarIcon } from '@chakra-ui/icons';
|
||||||
import {
|
import {
|
||||||
Poll,
|
Poll,
|
||||||
PollOption,
|
PollOption,
|
||||||
|
PollResultsResponse,
|
||||||
votePoll,
|
votePoll,
|
||||||
getPollResults,
|
getPollResults,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
@@ -87,6 +88,16 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
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
|
// Vote mutation
|
||||||
const voteMutation = useMutation({
|
const voteMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -113,6 +124,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['polls'] });
|
queryClient.invalidateQueries({ queryKey: ['polls'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
|
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['poll-results', poll.id] });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Hlas zaznamenán!',
|
title: 'Hlas zaznamenán!',
|
||||||
@@ -227,13 +239,14 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
|
|
||||||
// Show results if available
|
// Show results if available
|
||||||
if (showingResults && canShowResults) {
|
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,
|
option_id: opt.id,
|
||||||
text: opt.text,
|
text: opt.text,
|
||||||
vote_count: opt.vote_count,
|
vote_count: opt.vote_count,
|
||||||
percentage: calculatePercentage(opt.vote_count),
|
percentage: totalVotesToShow ? (opt.vote_count / totalVotesToShow) * 100 : 0,
|
||||||
image_url: opt.image_url,
|
image_url: opt.image_url,
|
||||||
}));
|
})));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -275,7 +288,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
|
|
||||||
<VStack spacing={3} align="stretch">
|
<VStack spacing={3} align="stretch">
|
||||||
<Text fontWeight="bold" fontSize="sm" color="gray.500">
|
<Text fontWeight="bold" fontSize="sm" color="gray.500">
|
||||||
Výsledky ({poll.total_votes} hlasů)
|
Výsledky ({totalVotesToShow} hlasů)
|
||||||
</Text>
|
</Text>
|
||||||
{displayResults.map((result) => (
|
{displayResults.map((result) => (
|
||||||
<Box key={result.option_id}>
|
<Box key={result.option_id}>
|
||||||
@@ -569,7 +582,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text fontSize="xs" color="gray.500" textAlign="center">
|
<Text fontSize="xs" color="gray.500" textAlign="center">
|
||||||
Celkem hlasů: {poll.total_votes}
|
Celkem hlasů: {liveResultsData?.total_votes ?? poll.total_votes}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const MatchesWidget = () => {
|
|||||||
queryFn: fetchTeamLogoOverrides,
|
queryFn: fetchTeamLogoOverrides,
|
||||||
staleTime: 5 * 60 * 1000,
|
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 getLogo = (teamName?: string, original?: string) => {
|
||||||
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
||||||
const norm = (s: string) => String(s || '')
|
const norm = (s: string) => String(s || '')
|
||||||
@@ -147,12 +148,14 @@ export const MatchesWidget = () => {
|
|||||||
id: m.match_id,
|
id: m.match_id,
|
||||||
date_time: m.date_time || m.date,
|
date_time: m.date_time || m.date,
|
||||||
competitionName: m.competitionName,
|
competitionName: m.competitionName,
|
||||||
home: m.home || m.home_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 || m.away_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,
|
score: m.score,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
home_logo_url: getLogo(m.home || m.home_team, m.home_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: getLogo(m.away || m.away_team, m.away_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[];
|
})) as Match[];
|
||||||
|
|
||||||
return upcoming;
|
return upcoming;
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ chil
|
|||||||
textOnAccent = pickTextColor(accent!);
|
textOnAccent = pickTextColor(accent!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer logo from logoapi.sportcreative.eu when club ID is known
|
// Prefer local logo from settings; only fetch from logoapi when no explicit local logo is configured
|
||||||
if (clubId) {
|
if (!explicitLogo && clubId) {
|
||||||
try {
|
try {
|
||||||
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
|
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
|
||||||
if (apiLogo) {
|
if (apiLogo) {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { PageElementConfig } from '../services/pageElements';
|
|||||||
// Elements that are actually implemented on HomePage
|
// Elements that are actually implemented on HomePage
|
||||||
// Only these should be available in the editor
|
// Only these should be available in the editor
|
||||||
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
||||||
|
'style-pack', // Global style pack selector
|
||||||
'header', // Site navigation/header
|
'header', // Site navigation/header
|
||||||
|
'hero-topbar', // Club bar above hero
|
||||||
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
'hero', // Hero section with news cards (grid/scroller/swiper variants)
|
||||||
'news', // Featured news articles
|
'news', // Featured news articles
|
||||||
'matches', // Upcoming/recent matches
|
'matches', // Upcoming/recent matches
|
||||||
@@ -23,6 +25,14 @@ export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
||||||
|
{
|
||||||
|
page_type: 'homepage',
|
||||||
|
element_name: 'style-pack',
|
||||||
|
variant: 'default',
|
||||||
|
visible: true,
|
||||||
|
display_order: -1,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page_type: 'homepage',
|
page_type: 'homepage',
|
||||||
element_name: 'header',
|
element_name: 'header',
|
||||||
@@ -31,6 +41,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
|
|||||||
display_order: 0,
|
display_order: 0,
|
||||||
settings: {},
|
settings: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page_type: 'homepage',
|
||||||
|
element_name: 'hero-topbar',
|
||||||
|
variant: 'brand',
|
||||||
|
visible: true,
|
||||||
|
display_order: 1,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page_type: 'homepage',
|
page_type: 'homepage',
|
||||||
element_name: 'hero',
|
element_name: 'hero',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { getCategories, CategoryItem } from '../services/categories';
|
|||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import SponsorsSection from '../components/common/SponsorsSection';
|
import SponsorsSection from '../components/common/SponsorsSection';
|
||||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||||
|
import { assetUrl } from '../utils/url';
|
||||||
|
|
||||||
type AboutPageData = {
|
type AboutPageData = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -155,7 +156,7 @@ const AboutPage: React.FC = () => {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
|
<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." />
|
<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>
|
</Helmet>
|
||||||
<Container maxW="container.lg" py={8}>
|
<Container maxW="container.lg" py={8}>
|
||||||
<Box textAlign="center" py={6}>
|
<Box textAlign="center" py={6}>
|
||||||
@@ -177,7 +178,7 @@ const AboutPage: React.FC = () => {
|
|||||||
const seoTitle = data.seo_title || data.title;
|
const seoTitle = data.seo_title || data.title;
|
||||||
const seoDesc = data.seo_description || data.subtitle;
|
const seoDesc = data.seo_description || data.subtitle;
|
||||||
const clubName = settings?.club_name || data.title;
|
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 cleanContent = DOMPurify.sanitize(data.content);
|
||||||
|
|
||||||
const renderContent = () => {
|
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 { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
|
import { trackEvent as umamiTrackEvent } from '../utils/umami';
|
||||||
import EventLocationMap from '../components/events/EventLocationMap';
|
import EventLocationMap from '../components/events/EventLocationMap';
|
||||||
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
|
||||||
import FilePreview from '../components/common/FilePreview';
|
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 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';
|
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
|
// Extract YouTube video ID from various URL formats
|
||||||
const getYouTubeEmbedUrl = (url: string): string | null => {
|
const getYouTubeEmbedUrl = (url: string): string | null => {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
@@ -92,7 +111,14 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
<VStack align="stretch" spacing={5}>
|
<VStack align="stretch" spacing={5}>
|
||||||
{data.image_url && (
|
{data.image_url && (
|
||||||
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
|
<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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -132,6 +158,7 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
|
||||||
' img': { maxWidth: '100%', borderRadius: 'md' },
|
' img': { maxWidth: '100%', borderRadius: 'md' },
|
||||||
}}
|
}}
|
||||||
|
ref={contentRef}
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -161,7 +188,7 @@ const ActivityDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.id && (
|
{data?.id && (
|
||||||
<EmbeddedPoll eventId={data.id} />
|
<EmbeddedPoll eventId={data.id} maxPolls={2} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
|
{(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 { useQuery } from '@tanstack/react-query';
|
||||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||||
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
|
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 { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
import { API_URL } from '../services/api';
|
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) => {
|
const toText = (html?: string) => {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
@@ -29,6 +34,22 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
enabled: Boolean(slug || id),
|
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
|
// Placeholders; moved tracking effects below to avoid using variables before declaration
|
||||||
|
|
||||||
// Track article view when data is loaded
|
// Track article view when data is loaded
|
||||||
@@ -40,6 +61,27 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [data]);
|
}, [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)
|
// Fetch linked match (public)
|
||||||
const matchLinkQuery = useQuery({
|
const matchLinkQuery = useQuery({
|
||||||
queryKey: ['article-match-link', (data as any)?.id],
|
queryKey: ['article-match-link', (data as any)?.id],
|
||||||
@@ -81,10 +123,10 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
staleTime: 60_000,
|
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({
|
const galleryAlbumQuery = useQuery({
|
||||||
queryKey: ['article-gallery-album', (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),
|
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const albumId = (data as any)?.gallery_album_id;
|
const albumId = (data as any)?.gallery_album_id;
|
||||||
let photoIds: string[] = [];
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
@@ -152,15 +212,6 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
|
}, [(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
|
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
|
||||||
// Memoize the transformation function to prevent infinite loops
|
// Memoize the transformation function to prevent infinite loops
|
||||||
const toAbsoluteUploads = React.useCallback((html?: string) => {
|
const toAbsoluteUploads = React.useCallback((html?: string) => {
|
||||||
@@ -177,13 +228,27 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const safeContentHTML = React.useMemo(() => {
|
const safeContentHTML = React.useMemo(() => {
|
||||||
const transformed = toAbsoluteUploads(data.content);
|
const transformed = toAbsoluteUploads((data as any)?.content);
|
||||||
return DOMPurify.sanitize(transformed || '', {
|
return DOMPurify.sanitize(transformed || '', {
|
||||||
USE_PROFILES: { html: true },
|
USE_PROFILES: { html: true },
|
||||||
ADD_TAGS: ['iframe'],
|
ADD_TAGS: ['iframe'],
|
||||||
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
|
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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
@@ -243,201 +308,238 @@ const ArticleDetailPage: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</script>
|
</script>
|
||||||
</Helmet>
|
</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">
|
<Container maxW="7xl">
|
||||||
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
|
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
|
||||||
<HStack spacing={4} fontSize="sm" color="gray.600">
|
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
|
||||||
{((data as any).read_time || (data as any).estimated_read_minutes) && (
|
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<Clock size={16} />
|
<Clock size={16} />
|
||||||
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
|
||||||
</HStack>
|
</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 && (
|
{(data as any)?.category?.id && (
|
||||||
<HStack spacing={1}>
|
<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} />
|
<Eye size={16} />
|
||||||
<Text>{(data as any).view_count} zobrazení</Text>
|
<Text>{(data as any).view_count} zobrazení</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
) : null}
|
||||||
</HStack>
|
</HStack>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
<Container maxW="7xl">
|
<Container maxW="7xl">
|
||||||
<Stack spacing={6}>
|
<Stack spacing={6}>
|
||||||
{/* Featured Image - Top */}
|
{/* Featured Image - smaller with subtle overlay */}
|
||||||
{data.image_url && (
|
{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 - smaller and rounded */}
|
||||||
{/* YouTube Video Section - If attached to article */}
|
|
||||||
{(data as any)?.youtube_video_id && (
|
{(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>
|
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
|
||||||
<AspectRatio ratio={16 / 9}>
|
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
|
||||||
<Box
|
<AspectRatio ratio={16 / 9}>
|
||||||
as="iframe"
|
<Box
|
||||||
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
as="iframe"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
|
||||||
allowFullScreen
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
title={(data as any).youtube_video_title || 'YouTube video'}
|
allowFullScreen
|
||||||
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
|
title={(data as any).youtube_video_title || 'YouTube video'}
|
||||||
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
|
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 ? (
|
</AspectRatio>
|
||||||
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
|
</Box>
|
||||||
) : null }
|
{(data as any).youtube_video_title ? (
|
||||||
|
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Match Section - After Image */}
|
{/* Match Section - Card with logos, score/countdown, venue/date */}
|
||||||
{(matchLinkQuery.data as any)?.external_match_id && (
|
{(matchLinkQuery.data as any)?.external_match_id && (
|
||||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
|
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
|
||||||
<HStack justify="space-between" align="start">
|
{/* Edge fades */}
|
||||||
<Box>
|
<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" />
|
||||||
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
|
{opponentColor && (
|
||||||
{facrMatchQuery.isLoading ? (
|
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
|
||||||
<Text color="gray.600">Načítám údaje o zápasu…</Text>
|
)}
|
||||||
) : facrMatchQuery.data ? (
|
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
|
||||||
<>
|
{facrMatchQuery.isLoading ? (
|
||||||
<HStack spacing={2} wrap="wrap">
|
<Text color={textMuted}>Načítám údaje o zápasu…</Text>
|
||||||
{facrMatchQuery.data.competitionName && (
|
) : facrMatchQuery.data ? (
|
||||||
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
<>
|
||||||
)}
|
<HStack spacing={2} wrap="wrap" mb={3}>
|
||||||
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
|
{facrMatchQuery.data.competitionName && (
|
||||||
</HStack>
|
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
|
||||||
<Text mt={2} fontWeight="600">
|
)}
|
||||||
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
|
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
|
||||||
{' '}
|
</HStack>
|
||||||
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
|
<Flex align="center" justify="space-between" gap={4}>
|
||||||
{' '}
|
<VStack flex={1} spacing={2} minW="0">
|
||||||
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
|
<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>
|
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
|
||||||
{facrMatchQuery.data.venue && (
|
</VStack>
|
||||||
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
|
<VStack minW={{ base: '100px', md: '140px' }}>
|
||||||
)}
|
{(() => {
|
||||||
{facrMatchQuery.data.report_url && (
|
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
|
||||||
<Box mt={2}>
|
const d = new Date(dRaw);
|
||||||
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
|
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');
|
||||||
</Box>
|
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>);
|
||||||
) : (
|
}
|
||||||
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
|
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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Article Content - Main Section */}
|
{/* Article Content - Main Section with editor-like lists */}
|
||||||
<Box
|
<Box
|
||||||
className="article-content"
|
className="article-content"
|
||||||
bg="white"
|
bg={useColorModeValue('white','gray.900')}
|
||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
p={{ base: 4, md: 6 }}
|
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 }}
|
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gallery Section - At the End */}
|
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
|
||||||
{(data as any)?.gallery_album_id && (
|
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
|
||||||
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
|
||||||
<Box mb={3}>
|
<Box mb={3}>
|
||||||
<HStack justify="space-between" align="center" mb={2}>
|
<HStack justify="space-between" align="center" mb={2}>
|
||||||
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
|
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
|
||||||
<Button
|
<Button
|
||||||
as={RouterLink}
|
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"
|
size="sm"
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
rightIcon={<ArrowRight size={16} />}
|
rightIcon={<ArrowRight size={16} />}
|
||||||
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
|
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
|
||||||
>
|
>
|
||||||
Zobrazit celé album
|
Zobrazit galerii
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
{galleryAlbumQuery.isLoading ? (
|
{/* Custom 5-image mosaic */}
|
||||||
<Text color="gray.600">Načítám fotografie…</Text>
|
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
|
||||||
) : galleryAlbumQuery.data ? (
|
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
|
||||||
<>
|
if (photos.length < 5) {
|
||||||
<HStack spacing={2} mb={3}>
|
return (
|
||||||
<Badge colorScheme="purple">
|
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
|
||||||
{galleryAlbumQuery.data.title}
|
{photos.map((p: any) => (
|
||||||
</Badge>
|
<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%)' }} />
|
||||||
{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>
|
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
{/* Zonerama Attribution */}
|
return (
|
||||||
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
|
<Box position="relative" sx={{
|
||||||
<Text>📸 Fotografie z</Text>
|
display: 'grid',
|
||||||
<Link
|
gridTemplateColumns: '1fr 1.2fr 1fr',
|
||||||
href={(data as any).gallery_album_url || `https://zonerama.com`}
|
gridTemplateRows: 'repeat(2, 140px)',
|
||||||
isExternal
|
gap: '8px'
|
||||||
fontWeight="600"
|
}}>
|
||||||
color="blue.600"
|
<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" />
|
||||||
display="inline-flex"
|
<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" />
|
||||||
alignItems="center"
|
<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" />
|
||||||
gap={1}
|
<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" />
|
||||||
Zonerama
|
<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>
|
||||||
<ExternalLink size={12} />
|
</Box>
|
||||||
</Link>
|
);
|
||||||
</HStack>
|
})()}
|
||||||
</>
|
|
||||||
) : (
|
{/* Zonerama Attribution */}
|
||||||
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Embedded Poll - directly under content/gallery */}
|
||||||
|
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Embedded Poll - shows polls related to this article */}
|
{/* Attachments - bottom above CTA */}
|
||||||
{data?.id && <EmbeddedPoll articleId={data.id} />}
|
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
|
||||||
|
<Container maxW="7xl" mt={4}>
|
||||||
{/* Newsletter CTA */}
|
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
|
||||||
<NewsletterCTA />
|
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
|
||||||
|
<Stack spacing={2}>
|
||||||
{/* Sponsors Section */}
|
{(data as any).attachments.map((f: any, idx: number) => (
|
||||||
<SponsorsSection />
|
<HStack key={idx} justify="space-between">
|
||||||
</MainLayout>
|
<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 />
|
||||||
|
|
||||||
|
{/* Sponsors Section */}
|
||||||
|
<SponsorsSection />
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ArticleDetailPage;
|
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 { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { getArticles, Article, Paginated } from '../services/articles';
|
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 { assetUrl } from '../utils/url';
|
||||||
import MainLayout from '../components/layout/MainLayout';
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
import { getCategories, CategoryItem } from '../services/categories';
|
import { getCategories, CategoryItem } from '../services/categories';
|
||||||
@@ -92,7 +92,14 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
|
|||||||
const BlogPage: React.FC = () => {
|
const BlogPage: React.FC = () => {
|
||||||
const pageSize = 18;
|
const pageSize = 18;
|
||||||
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
|
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 borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const textColor = useColorModeValue('gray.500', 'gray.400');
|
const textColor = useColorModeValue('gray.500', 'gray.400');
|
||||||
|
|
||||||
@@ -111,13 +118,15 @@ const BlogPage: React.FC = () => {
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useInfiniteQuery<Paginated<Article>>(
|
} = 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 }) =>
|
({ pageParam = 1 }) =>
|
||||||
getArticles({
|
getArticles({
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
published: true,
|
published: true,
|
||||||
...(categoryId ? { category_id: Number(categoryId) } : {}),
|
...(categoryId ? { category_id: Number(categoryId) } : {}),
|
||||||
|
...(month ? { month } : {}),
|
||||||
|
...(matchId ? { match_id: matchId } : {}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
@@ -159,7 +168,15 @@ const BlogPage: React.FC = () => {
|
|||||||
maxW={{ base: '52%', md: '320px' }}
|
maxW={{ base: '52%', md: '320px' }}
|
||||||
placeholder="Všechny kategorie"
|
placeholder="Všechny kategorie"
|
||||||
value={categoryId}
|
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) => (
|
{categories.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<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 ClubModal from '../components/home/ClubModal';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
import { API_URL } from '../services/api';
|
import { API_URL } from '../services/api';
|
||||||
|
import { TeamLogo } from '../components/common/TeamLogo';
|
||||||
|
|
||||||
// Weekday headers (Czech, starting Monday)
|
// Weekday headers (Czech, starting Monday)
|
||||||
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
|
||||||
@@ -226,9 +227,16 @@ const CalendarPage: React.FC = () => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
|
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 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 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;
|
if (!teamName) return original;
|
||||||
const exact = (byName || {})[teamName];
|
const exact = (byName || {})[teamName];
|
||||||
const normName = normalize(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 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 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 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 {
|
return {
|
||||||
id: m.match_id || `${cIdx}-${idx}`,
|
id: m.match_id || `${cIdx}-${idx}`,
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
time,
|
time,
|
||||||
home: m.home,
|
home: homeName,
|
||||||
away: m.away,
|
away: awayName,
|
||||||
home_id: m.home_id,
|
home_id: m.home_id,
|
||||||
away_id: m.away_id,
|
away_id: m.away_id,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
|
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
|
||||||
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
|
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
|
||||||
report_url: m.report_url,
|
report_url: m.report_url,
|
||||||
facr_link: m.facr_link,
|
facr_link: m.facr_link,
|
||||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
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}`,
|
id: m.match_id || `${cIdx}-${idx}`,
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
time,
|
time,
|
||||||
home: m.home,
|
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
|
||||||
away: m.away,
|
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,
|
home_id: m.home_id,
|
||||||
away_id: m.away_id,
|
away_id: m.away_id,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_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),
|
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
|
||||||
report_url: m.report_url,
|
report_url: m.report_url,
|
||||||
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
|
||||||
} as MatchItem;
|
} as MatchItem;
|
||||||
@@ -381,7 +391,7 @@ const CalendarPage: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
return {
|
return {
|
||||||
position: Number(r.rank || idx + 1),
|
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,
|
team_id: teamId,
|
||||||
points: Number(r.points || r.pts || 0),
|
points: Number(r.points || r.pts || 0),
|
||||||
played: Number(r.played || r.matches || 0),
|
played: Number(r.played || r.matches || 0),
|
||||||
@@ -390,7 +400,7 @@ const CalendarPage: React.FC = () => {
|
|||||||
losses: Number(r.losses || r.loss || 0),
|
losses: Number(r.losses || r.loss || 0),
|
||||||
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 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),
|
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>
|
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex align="center" gap={2} justify="center">
|
<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>
|
<Text fontSize="sm">{m.home}</Text>
|
||||||
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
|
<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>
|
<Text fontSize="sm">{m.away}</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
|
{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 { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||||
import '../styles/theme.css';
|
import '../styles/theme.css';
|
||||||
import '../styles/sparta-styles.css';
|
import '../styles/sparta-styles.css';
|
||||||
|
import '../styles/club-styles.css';
|
||||||
|
import '../styles/home-style-pack.css';
|
||||||
import './styles/UnifiedHome.css';
|
import './styles/UnifiedHome.css';
|
||||||
import { getPublicSettings } from '../services/settings';
|
import { getPublicSettings } from '../services/settings';
|
||||||
import { assetUrl, sanitizeClubName } from '../utils/url';
|
import { assetUrl, sanitizeClubName } from '../utils/url';
|
||||||
@@ -25,6 +27,12 @@ import MatchModal from '../components/home/MatchModal';
|
|||||||
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
|
||||||
import { API_URL } from '../services/api';
|
import { API_URL } from '../services/api';
|
||||||
import { TeamLogo } from '../components/common/TeamLogo';
|
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
|
// Types for real API-driven data
|
||||||
type NewsItem = {
|
type NewsItem = {
|
||||||
@@ -87,9 +95,7 @@ const HomePage: React.FC = () => {
|
|||||||
// Index for the NEXT MATCH competition carousel
|
// Index for the NEXT MATCH competition carousel
|
||||||
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
|
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
|
||||||
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
|
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
|
||||||
// Ref to the draggable matches track and per-competition closest index
|
// Matches slider auto-centering handled internally by MatchesSlider component
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [closestIndexByComp, setClosestIndexByComp] = useState<number[]>([]);
|
|
||||||
|
|
||||||
// API-driven players and sponsors
|
// API-driven players and sponsors
|
||||||
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string };
|
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
|
// MyUIbrix element configuration hook for live preview
|
||||||
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
|
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) => ({
|
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
|
||||||
id: typeof item.id === 'number' ? item.id : index,
|
id: typeof item.id === 'number' ? item.id : index,
|
||||||
@@ -360,22 +376,6 @@ const HomePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
setFacrCompetitions(comps);
|
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
|
// Next match FACR link
|
||||||
const first = filteredMatches?.[0];
|
const first = filteredMatches?.[0];
|
||||||
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
|
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
|
// Removed: legacy auto-scroll. Handled by MatchesSlider.
|
||||||
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]);
|
|
||||||
|
|
||||||
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
// MyUIbrix events are handled by useAllPageElementConfigs hook
|
||||||
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
|
||||||
@@ -1340,26 +1323,38 @@ const HomePage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<MainLayout headerInsideContainer>
|
<MainLayout headerInsideContainer>
|
||||||
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
|
||||||
{/* Header: logo + club name */}
|
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
|
||||||
<div className="home-header">
|
{/* Above-hero club bar (MyUIbrix managed) */}
|
||||||
<TeamLogo
|
{isVisible('hero-topbar', true) && (
|
||||||
teamId={settings?.club_id}
|
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
|
||||||
teamName={clubName}
|
<ClubHeroTopbar
|
||||||
facrLogo={assetUrl(clubLogo) || undefined}
|
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
|
||||||
size="custom"
|
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
|
||||||
alt="Klub"
|
/>
|
||||||
borderRadius="full"
|
</section>
|
||||||
style={{ width: 56, height: 56 }}
|
)}
|
||||||
/>
|
{/* Header: logo + club name (legacy). Hidden when hero-topbar is visible */}
|
||||||
<div>
|
{!isVisible('hero-topbar', true) && (
|
||||||
<h1 style={{ margin: 0 }}>{clubName}</h1>
|
<div className="home-header">
|
||||||
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
|
<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>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
|
||||||
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
|
{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] ? (
|
{featured[0] ? (
|
||||||
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
|
<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'})` }} />
|
<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 */}
|
{/* Banner: homepage_middle */}
|
||||||
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
|
{(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) => (
|
{(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 }}>
|
<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 */}
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
@@ -1415,7 +1410,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Sidebar banners (homepage_sidebar) */}
|
{/* Sidebar banners (homepage_sidebar) */}
|
||||||
{(banners || []).some(b => b.placement === '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 */}
|
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
|
||||||
@@ -1430,12 +1425,12 @@ const HomePage: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
|
{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 />
|
<BlogCardsScroller />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
|
{(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}
|
<BlogSwiper fallbackArticles={heroFallbackArticles}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -1457,155 +1452,54 @@ const HomePage: React.FC = () => {
|
|||||||
setSelectedMatch({
|
setSelectedMatch({
|
||||||
...show,
|
...show,
|
||||||
competition: comp?.name,
|
competition: comp?.name,
|
||||||
competitionName: comp?.name,
|
|
||||||
});
|
});
|
||||||
setIsMatchModalOpen(true);
|
setIsMatchModalOpen(true);
|
||||||
|
} else if (link) {
|
||||||
|
window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
|
<NextMatch
|
||||||
<button
|
data={show}
|
||||||
aria-label="Předchozí soutěž"
|
competitionName={comp?.name}
|
||||||
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
|
countdown={countdown}
|
||||||
className="nav prev"
|
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
|
||||||
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
|
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
|
||||||
>
|
onOpen={handleNextMatchClick}
|
||||||
<FiChevronLeft size={24} />
|
elementProps={{
|
||||||
</button>
|
'data-element': 'matches' as any,
|
||||||
<div className="team">
|
'data-variant': getVariant('matches', 'compact') as any,
|
||||||
<TeamLogo
|
style: { ...getStyles('matches') },
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : isVisible('matches', true) ? (
|
) : isVisible('matches', true) ? (
|
||||||
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
|
<NextMatch
|
||||||
<div className="team">
|
data={{
|
||||||
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
|
home: matches[0]?.homeTeam || clubName,
|
||||||
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
|
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
||||||
</div>
|
away: matches[0]?.awayTeam || 'Soupeř',
|
||||||
<div className="countdown">
|
away_logo_url: matches[0]?.awayLogoURL,
|
||||||
{countdown || '—'}
|
}}
|
||||||
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
|
countdown={countdown}
|
||||||
{nextMatchLink && (
|
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
|
||||||
<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>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Matches slider with scores by competition (moved after news+tables) */}
|
{/* Matches slider with scores by competition (moved after news+tables) */}
|
||||||
{facrCompetitions.length > 0 && (
|
{facrCompetitions.length > 0 && (
|
||||||
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
|
<MatchesSlider
|
||||||
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
|
comps={facrCompetitions as any}
|
||||||
<h3>Zápasy</h3>
|
activeIndex={matchesTab}
|
||||||
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
|
onActiveChange={setMatchesTab}
|
||||||
</div>
|
onMatchClick={(m: any, compName?: string) => {
|
||||||
<div className="matches-grid">
|
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
|
||||||
<div className="matches-track" ref={trackRef}>
|
setIsMatchModalOpen(true);
|
||||||
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
|
}}
|
||||||
const handleMatchClick = (e: React.MouseEvent) => {
|
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* News + Tables: split into two independent sections */}
|
{/* News + Tables: split into two independent sections */}
|
||||||
@@ -1631,121 +1525,40 @@ const HomePage: React.FC = () => {
|
|||||||
style={{ marginTop: 32 }}
|
style={{ marginTop: 32 }}
|
||||||
>
|
>
|
||||||
{showNews && (
|
{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 }}>
|
<div className="section-head" style={{ marginTop: 0 }}>
|
||||||
<h3>Další aktuality</h3>
|
<h3>Další aktuality</h3>
|
||||||
|
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-list">
|
<NewsList items={news as any} />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTable && (
|
{showTable && (
|
||||||
<div data-element="table" style={{ ...getStyles('table') }}>
|
<div data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
|
||||||
<div className="table-card">
|
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
||||||
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
|
<h3>Tabulky</h3>
|
||||||
<h3>Tabulky</h3>
|
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1755,32 +1568,20 @@ const HomePage: React.FC = () => {
|
|||||||
{/* Competition tables moved into right column below */}
|
{/* Competition tables moved into right column below */}
|
||||||
|
|
||||||
{upcomingEvents.length > 0 && isVisible('activities', true) && (
|
{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 style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<div className="section-head" style={{ marginTop: 0 }}>
|
<div className="section-head" style={{ marginTop: 0 }}>
|
||||||
<h3>Aktivity</h3>
|
<h3>Aktivity</h3>
|
||||||
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-list">
|
<ActivitiesList items={upcomingEvents as any} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Players scroller */}
|
{/* Players scroller */}
|
||||||
{players.length > 0 && isVisible('team', false) && (
|
{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">
|
<div className="section-head">
|
||||||
<h3>Hráči</h3>
|
<h3>Hráči</h3>
|
||||||
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
|
||||||
@@ -1799,7 +1600,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Gallery */}
|
{/* Gallery */}
|
||||||
{isVisible('gallery', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<GallerySection zoneramaUrl={galleryUrl} />
|
<GallerySection zoneramaUrl={galleryUrl} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1808,7 +1609,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Videos */}
|
{/* Videos */}
|
||||||
{isVisible('videos', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<VideosSection />
|
<VideosSection />
|
||||||
</div>
|
</div>
|
||||||
@@ -1816,7 +1617,7 @@ const HomePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isVisible('merch', true) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<MerchSection />
|
<MerchSection />
|
||||||
</div>
|
</div>
|
||||||
@@ -1825,7 +1626,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Polls / Voting */}
|
{/* Polls / Voting */}
|
||||||
{isVisible('poll', false) && (
|
{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' }}>
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
|
||||||
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1834,7 +1635,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Banner: homepage_footer */}
|
{/* Banner: homepage_footer */}
|
||||||
{(banners || []).some(b => b.placement === '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) => (
|
{(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 }}>
|
<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 */}
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
@@ -1846,7 +1647,7 @@ const HomePage: React.FC = () => {
|
|||||||
|
|
||||||
{/* CTA (Newsletter) moved up */}
|
{/* CTA (Newsletter) moved up */}
|
||||||
{isVisible('newsletter', false) && (
|
{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' }}>
|
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||||
<NewsletterSubscribe />
|
<NewsletterSubscribe />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,8 +277,9 @@ const MatchesPage: React.FC = () => {
|
|||||||
setAliasMap(amap);
|
setAliasMap(amap);
|
||||||
|
|
||||||
// Build override helpers
|
// 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 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)
|
const normalize = (s: string) => String(s)
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
@@ -321,9 +322,27 @@ const MatchesPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
|
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) {
|
if (teamId) {
|
||||||
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
|
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
|
||||||
}
|
}
|
||||||
|
// Fallback to by-name overrides or original
|
||||||
return getFallbackLogo(teamName, original);
|
return getFallbackLogo(teamName, original);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,6 +370,8 @@ const MatchesPage: React.FC = () => {
|
|||||||
const [day, month, year] = (d || '').split('.');
|
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 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 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
|
// Check if match is in the future - if so, ignore score
|
||||||
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
||||||
@@ -361,12 +382,12 @@ const MatchesPage: React.FC = () => {
|
|||||||
id: m.match_id || idx + 1,
|
id: m.match_id || idx + 1,
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
time,
|
time,
|
||||||
home: m.home,
|
home: homeName,
|
||||||
away: m.away,
|
away: awayName,
|
||||||
home_id: m.home_id,
|
home_id: m.home_id,
|
||||||
away_id: m.away_id,
|
away_id: m.away_id,
|
||||||
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
|
home_logo_url: getOverrideLogo(homeName, m.home_id, m.home_logo_url),
|
||||||
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
|
away_logo_url: getOverrideLogo(awayName, m.away_id, m.away_logo_url),
|
||||||
score: actualScore,
|
score: actualScore,
|
||||||
facr_link: m.facr_link,
|
facr_link: m.facr_link,
|
||||||
report_url: m.report_url,
|
report_url: m.report_url,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Container,
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -15,6 +16,11 @@ import {
|
|||||||
AlertIcon,
|
AlertIcon,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
|
SimpleGrid,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
Spinner,
|
||||||
|
Spacer,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -54,8 +60,9 @@ const NewsletterPreferencesPage: React.FC = () => {
|
|||||||
blogs: true,
|
blogs: true,
|
||||||
matches: true,
|
matches: true,
|
||||||
events: true,
|
events: true,
|
||||||
scores: false,
|
scores: true,
|
||||||
...(data?.preferences || {} as SubscriberPreferences),
|
weekly: true,
|
||||||
|
...(data?.preferences || ({} as SubscriberPreferences)),
|
||||||
}), [data]);
|
}), [data]);
|
||||||
|
|
||||||
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
|
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
|
||||||
@@ -117,7 +124,14 @@ const NewsletterPreferencesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
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) {
|
if (isError || !data) {
|
||||||
@@ -129,73 +143,103 @@ 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 (
|
return (
|
||||||
<Box maxW="720px" mx="auto" p={6}>
|
<Container maxW="container.md" py={8}>
|
||||||
<Heading size="lg" mb={2}>Nastavení newsletteru</Heading>
|
<HStack mb={2} align="center">
|
||||||
<Text color="gray.600" mb={6}>Spravujte, jaké e-maily chcete dostávat na adresu {data.email}.</Text>
|
<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>
|
<Card mb={6}>
|
||||||
<VStack spacing={4} align="stretch">
|
<CardBody>
|
||||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
<VStack spacing={6} align="stretch">
|
||||||
<FormLabel m={0}>Články (blog)</FormLabel>
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||||
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||||
</FormControl>
|
<FormLabel m={0}>Články (blog)</FormLabel>
|
||||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
|
||||||
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
</FormControl>
|
||||||
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||||
</FormControl>
|
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
|
||||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
|
||||||
<FormLabel m={0}>Události</FormLabel>
|
</FormControl>
|
||||||
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||||
</FormControl>
|
<FormLabel m={0}>Události</FormLabel>
|
||||||
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
|
||||||
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
</FormControl>
|
||||||
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||||
</FormControl>
|
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
|
||||||
<FormControl>
|
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
|
||||||
<FormLabel>Preferované soutěže</FormLabel>
|
</FormControl>
|
||||||
{Array.isArray(competitions) && competitions.length > 0 ? (
|
<FormControl display="flex" alignItems="center" justifyContent="space-between">
|
||||||
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
|
<FormLabel m={0}>Týdenní souhrn (digest)</FormLabel>
|
||||||
{competitions.map((c: any, idx: number) => {
|
<Switch isChecked={!!(prefs as any).weekly} onChange={(e) => setPrefs({ ...prefs, weekly: e.target.checked })} />
|
||||||
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
|
</FormControl>
|
||||||
const name = (c?.name || c?.code || code) as string;
|
</SimpleGrid>
|
||||||
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}>
|
<Divider />
|
||||||
<Button
|
|
||||||
colorScheme="blue"
|
<FormControl>
|
||||||
onClick={() => saveMut.mutate()}
|
<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()}
|
||||||
isLoading={saveMut.isLoading}
|
isLoading={saveMut.isLoading}
|
||||||
data-umami-event="Save Preferences"
|
data-umami-event="Save Preferences"
|
||||||
>
|
>
|
||||||
Uložit
|
Uložit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => qc.invalidateQueries({ queryKey: ['newsletter', 'prefs', token] })}
|
onClick={() => qc.invalidateQueries({ queryKey: ['newsletter', 'prefs', token] })}
|
||||||
data-umami-event="Refresh Preferences"
|
data-umami-event="Refresh Preferences"
|
||||||
>
|
>
|
||||||
Obnovit
|
Obnovit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Spacer />
|
||||||
colorScheme="red"
|
<Button
|
||||||
variant="outline"
|
colorScheme="red"
|
||||||
onClick={() => unsubMut.mutate()}
|
variant="outline"
|
||||||
|
onClick={() => unsubMut.mutate()}
|
||||||
isLoading={unsubMut.isLoading}
|
isLoading={unsubMut.isLoading}
|
||||||
data-umami-event="Unsubscribe"
|
data-umami-event="Unsubscribe"
|
||||||
data-umami-event-source="preferences"
|
data-umami-event-source="preferences"
|
||||||
@@ -203,7 +247,7 @@ const NewsletterPreferencesPage: React.FC = () => {
|
|||||||
Zrušit odběr
|
Zrušit odběr
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getPlayers } from '../services/public';
|
import { getPlayers } from '../services/public';
|
||||||
|
import type { Player } from '../services/public';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import MainLayout from '../components/layout/MainLayout';
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
@@ -8,7 +9,7 @@ import SponsorsSection from '../components/common/SponsorsSection';
|
|||||||
import NewsletterCTA from '../components/common/NewsletterCTA';
|
import NewsletterCTA from '../components/common/NewsletterCTA';
|
||||||
|
|
||||||
const PlayersPage: React.FC = () => {
|
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 cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const SetupPage: React.FC = () => {
|
|||||||
const [smtpUser, setSmtpUser] = useState('');
|
const [smtpUser, setSmtpUser] = useState('');
|
||||||
const [smtpPass, setSmtpPass] = useState('');
|
const [smtpPass, setSmtpPass] = useState('');
|
||||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
||||||
|
const [smtpUserEdited, setSmtpUserEdited] = useState(false);
|
||||||
// Sender display name only; actual email is derived from smtpUser
|
// Sender display name only; actual email is derived from smtpUser
|
||||||
const [smtpFromName, setSmtpFromName] = useState('');
|
const [smtpFromName, setSmtpFromName] = useState('');
|
||||||
const [smtpTLS, setSmtpTLS] = useState(true);
|
const [smtpTLS, setSmtpTLS] = useState(true);
|
||||||
@@ -192,10 +193,10 @@ const SetupPage: React.FC = () => {
|
|||||||
|
|
||||||
// Auto-fill SMTP username from contact email
|
// Auto-fill SMTP username from contact email
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
|
if (contactEmail && isValidEmail(contactEmail) && !smtpUserEdited) {
|
||||||
setSmtpUser(contactEmail);
|
setSmtpUser(contactEmail);
|
||||||
}
|
}
|
||||||
}, [contactEmail, smtpUser]);
|
}, [contactEmail, smtpUserEdited]);
|
||||||
|
|
||||||
const handleSelectClub = async (item: SearchResult) => {
|
const handleSelectClub = async (item: SearchResult) => {
|
||||||
const clubIdValue = item.club_id || '';
|
const clubIdValue = item.club_id || '';
|
||||||
@@ -791,7 +792,7 @@ const SetupPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>E-mail</FormLabel>
|
<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>
|
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
@@ -816,7 +817,7 @@ const SetupPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl mb={3}>
|
<FormControl mb={3}>
|
||||||
<FormLabel>SMTP uživatelské jméno</FormLabel>
|
<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>
|
||||||
<FormControl mb={3}>
|
<FormControl mb={3}>
|
||||||
<FormLabel>SMTP heslo</FormLabel>
|
<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 && (
|
{!!competitions.length && (
|
||||||
<Tabs variant="enclosed">
|
<Tabs variant="enclosed" size="sm">
|
||||||
<TabList>
|
<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) => (
|
{competitions.map((c) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={c.id}
|
key={c.id}
|
||||||
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
|
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
|
||||||
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
|
_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>
|
</Tab>
|
||||||
))}
|
))}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
Image as ChakraImage,
|
Image as ChakraImage,
|
||||||
} from '@chakra-ui/react';
|
} 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Event } from '../../types/event';
|
import { Event } from '../../types/event';
|
||||||
import { uploadFile } from '../../services/articles';
|
import { uploadFile } from '../../services/articles';
|
||||||
@@ -60,9 +60,10 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
|||||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
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 ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
import { assetUrl } from '../../utils/url';
|
import { assetUrl } from '../../utils/url';
|
||||||
|
import { createShortLink } from '../../services/shortlinks';
|
||||||
|
|
||||||
const types: Array<{ value: Event['type']; label: string }> = [
|
const types: Array<{ value: Event['type']; label: string }> = [
|
||||||
{ value: 'match', label: 'Zápas' },
|
{ value: 'match', label: 'Zápas' },
|
||||||
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const events = data || [];
|
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
|
// Load club YouTube videos
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
const e = editing || {};
|
const e = editing || {};
|
||||||
// Build a helpful Czech prompt including known fields
|
// Build a helpful Czech prompt including known fields
|
||||||
const lines: string[] = [];
|
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.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}`);
|
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||||
const base = lines.join('\n');
|
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 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 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', {
|
const { data } = await api.post('/ai/blog/generate', {
|
||||||
prompt,
|
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,
|
min_words: 120,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
<Td>{ev.title}</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>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||||
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
||||||
<Td>{ev.location || '-'}</Td>
|
<Td>{ev.location || '-'}</Td>
|
||||||
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
<HStack>
|
<HStack>
|
||||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
|
<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="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>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ import {
|
|||||||
FiZap,
|
FiZap,
|
||||||
FiTrendingUp,
|
FiTrendingUp,
|
||||||
FiCalendar,
|
FiCalendar,
|
||||||
FiSearch
|
FiSearch,
|
||||||
|
FiInfo
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
|
|
||||||
// Register ChartJS components
|
// Register ChartJS components
|
||||||
@@ -188,6 +189,11 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
|
|||||||
name: 'Kliknutí na externí odkaz',
|
name: 'Kliknutí na externí odkaz',
|
||||||
source: 'Různé stránky',
|
source: 'Různé stránky',
|
||||||
description: 'Uživatel klikl na odkaz vedoucí mimo web'
|
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 [timeRange, setTimeRange] = useState('0'); // Default to "today"
|
||||||
const [hasData, setHasData] = useState(false);
|
const [hasData, setHasData] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
|
||||||
const [selectedCountry, setSelectedCountry] = useState<{
|
const [selectedCountry, setSelectedCountry] = useState<{
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
|
|||||||
const fetchAnalytics = async (days: string) => {
|
const fetchAnalytics = async (days: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
setNoDataInfo(null);
|
||||||
try {
|
try {
|
||||||
const daysNum = parseInt(days);
|
const daysNum = parseInt(days);
|
||||||
|
|
||||||
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
|
|||||||
|
|
||||||
setPageviewsData(pageviewsDataArray);
|
setPageviewsData(pageviewsDataArray);
|
||||||
|
|
||||||
// Determine if we have data
|
|
||||||
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
|
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
|
||||||
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
|
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
|
||||||
setHasData(hasAnyStats || hasPageviews || hasMetrics);
|
setHasData(hasAnyStats || hasPageviews || hasMetrics);
|
||||||
|
|
||||||
// Set error message if no data
|
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
|
||||||
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
|
if (noData) {
|
||||||
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.');
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch analytics:', error);
|
console.error('Failed to fetch analytics:', error);
|
||||||
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</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 */}
|
{/* Stats Overview */}
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
|
||||||
<Card bg={bgColor} borderColor={borderColor}>
|
<Card bg={bgColor} borderColor={borderColor}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
||||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
||||||
} from '@chakra-ui/react';
|
} 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import AdminLayout from '../../layouts/AdminLayout';
|
import AdminLayout from '../../layouts/AdminLayout';
|
||||||
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
|
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 { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||||
|
|
||||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||||
|
import { createShortLink } from '../../services/shortlinks';
|
||||||
|
|
||||||
// Inline small component to show match link badge in list (with short label)
|
// Inline small component to show match link badge in list (with short label)
|
||||||
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
|
||||||
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
|
|||||||
return dateStr.includes(matchDateFilter);
|
return dateStr.includes(matchDateFilter);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by proximity to current date (recent matches first)
|
|
||||||
const now = Date.now();
|
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) => {
|
opts = opts.sort((a, b) => {
|
||||||
const dateA = new Date(a.date || 0).getTime();
|
const ta = parseTime(a.date);
|
||||||
const dateB = new Date(b.date || 0).getTime();
|
const tb = parseTime(b.date);
|
||||||
const diffA = Math.abs(now - dateA);
|
const da = ta - now;
|
||||||
const diffB = Math.abs(now - dateB);
|
const db = tb - now;
|
||||||
return diffA - diffB; // Closest to today first
|
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;
|
return opts;
|
||||||
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
|
|||||||
<HStack spacing={1}>
|
<HStack spacing={1}>
|
||||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
|
<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="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>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
|
|||||||
<Tab>Základní</Tab>
|
<Tab>Základní</Tab>
|
||||||
<Tab>Obsah</Tab>
|
<Tab>Obsah</Tab>
|
||||||
<Tab>Média</Tab>
|
<Tab>Média</Tab>
|
||||||
<Tab>SEO</Tab>
|
|
||||||
<Tab>Anketa</Tab>
|
<Tab>Anketa</Tab>
|
||||||
|
<Tab>SEO</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
{/* AI first */}
|
{/* AI first */}
|
||||||
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</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 */}
|
{/* SEO last - minimized */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<Text fontSize="sm" color="gray.600" mb={4}>
|
<Text fontSize="sm" color="gray.600" mb={4}>
|
||||||
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</TabPanel>
|
</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>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Zonerama Gallery Picker Modal */}
|
{/* Zonerama Gallery Picker Modal */}
|
||||||
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
|
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent maxH="90vh">
|
<ModalContent maxH="90vh">
|
||||||
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
|
||||||
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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 */}
|
{/* Draft Recovery Modal */}
|
||||||
<DraftRecoveryModal
|
<DraftRecoveryModal
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import AdminLayout from '../../layouts/AdminLayout';
|
import AdminLayout from '../../layouts/AdminLayout';
|
||||||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||||
import { getPublicSettings } from '../../services/settings';
|
import { getPublicSettings } from '../../services/settings';
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { parse } from 'date-fns';
|
import { parse } from 'date-fns';
|
||||||
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
|
|||||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
const [showScrollHint, setShowScrollHint] = useState(true);
|
const [showScrollHint, setShowScrollHint] = useState(true);
|
||||||
const thBg = useColorModeValue('gray.50', 'gray.700');
|
|
||||||
|
|
||||||
// Drag-to-scroll state
|
// Drag-to-scroll state
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [startX, setStartX] = useState(0);
|
const [startX, setStartX] = useState(0);
|
||||||
const [scrollLeft, setScrollLeft] = useState(0);
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
const [lastX, setLastX] = useState(0);
|
const lastXRef = useRef(0);
|
||||||
const [lastTime, setLastTime] = useState(0);
|
const lastTimeRef = useRef(0);
|
||||||
const velocityRef = useRef(0);
|
const velocityRef = useRef(0);
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
|
const scrollRaf = 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 updateScrollShadow = () => {
|
const updateScrollShadow = () => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
setCanScrollLeft(el.scrollLeft > 0);
|
const left = el.scrollLeft > 0;
|
||||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
||||||
|
if (left !== canScrollLeft) setCanScrollLeft(left);
|
||||||
|
if (right !== canScrollRight) setCanScrollRight(right);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag-to-scroll handlers
|
// Drag-to-scroll handlers
|
||||||
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
setStartX(e.pageX - scrollRef.current.offsetLeft);
|
||||||
setScrollLeft(scrollRef.current.scrollLeft);
|
setScrollLeft(scrollRef.current.scrollLeft);
|
||||||
setLastX(e.pageX);
|
lastXRef.current = e.pageX;
|
||||||
setLastTime(Date.now());
|
lastTimeRef.current = Date.now();
|
||||||
velocityRef.current = 0;
|
velocityRef.current = 0;
|
||||||
scrollRef.current.style.cursor = 'grabbing';
|
scrollRef.current.style.cursor = 'grabbing';
|
||||||
scrollRef.current.style.userSelect = 'none';
|
scrollRef.current.style.userSelect = 'none';
|
||||||
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
|
|||||||
|
|
||||||
// Calculate velocity for momentum
|
// Calculate velocity for momentum
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeDelta = now - lastTime;
|
const timeDelta = now - lastTimeRef.current;
|
||||||
if (timeDelta > 0) {
|
if (timeDelta > 0) {
|
||||||
const currentX = e.pageX;
|
const currentX = e.pageX;
|
||||||
const distance = currentX - lastX;
|
const distance = currentX - lastXRef.current;
|
||||||
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
|
||||||
setLastX(currentX);
|
lastXRef.current = currentX;
|
||||||
setLastTime(now);
|
lastTimeRef.current = now;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
|
|||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
setStartX(touch.pageX - scrollRef.current.offsetLeft);
|
||||||
setScrollLeft(scrollRef.current.scrollLeft);
|
setScrollLeft(scrollRef.current.scrollLeft);
|
||||||
setLastX(touch.pageX);
|
lastXRef.current = touch.pageX;
|
||||||
setLastTime(Date.now());
|
lastTimeRef.current = Date.now();
|
||||||
velocityRef.current = 0;
|
velocityRef.current = 0;
|
||||||
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
|
||||||
};
|
};
|
||||||
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
|
|||||||
scrollRef.current.scrollLeft = scrollLeft - walk;
|
scrollRef.current.scrollLeft = scrollLeft - walk;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const timeDelta = now - lastTime;
|
const timeDelta = now - lastTimeRef.current;
|
||||||
if (timeDelta > 0) {
|
if (timeDelta > 0) {
|
||||||
const currentX = touch.pageX;
|
const currentX = touch.pageX;
|
||||||
const distance = currentX - lastX;
|
const distance = currentX - lastXRef.current;
|
||||||
velocityRef.current = distance / timeDelta * 16;
|
velocityRef.current = distance / timeDelta * 16;
|
||||||
setLastX(currentX);
|
lastXRef.current = currentX;
|
||||||
setLastTime(now);
|
lastTimeRef.current = now;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
|
|||||||
const headerText = useColorModeValue('text.onPrimary', 'white');
|
const headerText = useColorModeValue('text.onPrimary', 'white');
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
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 (
|
return (
|
||||||
<AdminLayout requireAdmin={false}>
|
<AdminLayout requireAdmin={false}>
|
||||||
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
|
|||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
updateScrollShadow();
|
if (scrollRaf.current == null) {
|
||||||
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
scrollRaf.current = requestAnimationFrame(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) {
|
||||||
|
updateScrollShadow();
|
||||||
|
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
|
||||||
|
}
|
||||||
|
scrollRaf.current = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollBehavior: 'smooth',
|
scrollBehavior: 'smooth',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
willChange: 'transform',
|
||||||
|
overscrollBehaviorX: 'contain',
|
||||||
|
touchAction: 'pan-x',
|
||||||
'th, td': { whiteSpace: 'nowrap' },
|
'th, td': { whiteSpace: 'nowrap' },
|
||||||
'::-webkit-scrollbar': { height: '14px' },
|
'::-webkit-scrollbar': { height: '14px' },
|
||||||
'::-webkit-scrollbar-thumb': {
|
'::-webkit-scrollbar-thumb': {
|
||||||
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
|
|||||||
{/* Gradient edges to indicate horizontal scroll */}
|
{/* Gradient edges to indicate horizontal scroll */}
|
||||||
{canScrollLeft && (
|
{canScrollLeft && (
|
||||||
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
<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}
|
zIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canScrollRight && (
|
{canScrollRight && (
|
||||||
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
|
<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}
|
zIndex={1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
|
|||||||
alt={m.home || m.home_team || ''}
|
alt={m.home || m.home_team || ''}
|
||||||
boxSize="24px"
|
boxSize="24px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
<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>
|
<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 || ''}
|
alt={m.away || m.away_team || ''}
|
||||||
boxSize="24px"
|
boxSize="24px"
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
<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>
|
<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>
|
</DrawerFooter>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
|
|||||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||||
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
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 { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const testModal = useDisclosure();
|
const testModal = useDisclosure();
|
||||||
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</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>
|
||||||
<TabPanel p={0}>
|
<TabPanel p={0}>
|
||||||
{/* Scheduling controls */}
|
{/* Scheduling controls */}
|
||||||
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
|
|||||||
<FormControl maxW="220px">
|
<FormControl maxW="220px">
|
||||||
<FormLabel>Den v týdnu</FormLabel>
|
<FormLabel>Den v týdnu</FormLabel>
|
||||||
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
|
<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="mon">Pondělí</option>
|
||||||
<option value="tue">Úterý</option>
|
<option value="tue">Úterý</option>
|
||||||
<option value="wed">Středa</option>
|
<option value="wed">Středa</option>
|
||||||
<option value="thu">Čtvrtek</option>
|
<option value="thu">Čtvrtek</option>
|
||||||
<option value="fri">Pátek</option>
|
<option value="fri">Pátek</option>
|
||||||
<option value="sat">Sobota</option>
|
<option value="sat">Sobota</option>
|
||||||
|
<option value="sun">Neděle</option>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl maxW="160px">
|
<FormControl maxW="160px">
|
||||||
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</HStack>
|
</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>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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 */}
|
{/* Test Email Modal */}
|
||||||
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
|
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
const HEIGHT_MIN = 0;
|
const HEIGHT_MIN = 0;
|
||||||
const HEIGHT_MAX = 250;
|
const HEIGHT_MAX = 250;
|
||||||
const WEIGHT_MIN = 0;
|
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
|
// 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: '' });
|
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' });
|
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
|
||||||
return;
|
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
|
// Build payload by including only present values to satisfy backend validation
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
first_name: fn,
|
first_name: fn,
|
||||||
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Custom DOB picker: day / month / year (timezone-safe) */}
|
{/* Custom DOB picker: day / month / year (timezone-safe) */}
|
||||||
<FormControl>
|
<FormControl isRequired>
|
||||||
<FormLabel>Datum narození</FormLabel>
|
<FormLabel>Datum narození</FormLabel>
|
||||||
<HStack>
|
<HStack>
|
||||||
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
|
<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>)}
|
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
|
<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>)}
|
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
|
<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>; })}
|
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
|
||||||
</Select>
|
</Select>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Fotka</FormLabel>
|
<FormLabel>Fotka</FormLabel>
|
||||||
<HStack>
|
<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
|
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// Validate that all options have text
|
if (formData.type !== 'rating') {
|
||||||
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
|
const invalidOptions = formData.options.filter(
|
||||||
if (invalidOptions.length > 0) {
|
(opt) => !opt.text || opt.text.trim() === ''
|
||||||
toast({
|
);
|
||||||
title: 'Chyba',
|
if (invalidOptions.length > 0) {
|
||||||
description: 'Všechny možnosti musí mít vyplněný text',
|
toast({
|
||||||
status: 'error',
|
title: 'Chyba',
|
||||||
duration: 3000,
|
description: 'Všechny možnosti musí mít vyplněný text',
|
||||||
});
|
status: 'error',
|
||||||
return;
|
duration: 3000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingPoll) {
|
if (editingPoll) {
|
||||||
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [isOpen, clubVideos.length, toast]);
|
}, [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 getStatusBadge = (status: string) => {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
draft: 'gray',
|
draft: 'gray',
|
||||||
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>Základní</Tab>
|
<Tab>Základní</Tab>
|
||||||
<Tab>Možnosti</Tab>
|
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
|
||||||
<Tab>Nastavení</Tab>
|
<Tab>Nastavení</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
{/* Basic Info Tab */}
|
{/* Basic Info Tab */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack spacing={4}>
|
<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>
|
<FormControl isRequired>
|
||||||
<FormLabel>Název ankety</FormLabel>
|
<FormLabel>Název ankety</FormLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</SimpleGrid>
|
</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">
|
<SimpleGrid columns={2} spacing={4} w="full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Datum zahájení</FormLabel>
|
<FormLabel>Datum zahájení</FormLabel>
|
||||||
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
{/* Options Tab */}
|
{/* Options Tab */}
|
||||||
<TabPanel>
|
{formData.type !== 'rating' && (
|
||||||
<VStack spacing={4} align="stretch">
|
<TabPanel>
|
||||||
{formData.options.map((option, index) => (
|
<VStack spacing={4} align="stretch">
|
||||||
<Card key={index}>
|
{formData.options.map((option, index) => (
|
||||||
<CardBody>
|
<Card key={index}>
|
||||||
<HStack align="start">
|
<CardBody>
|
||||||
<VStack flex={1} spacing={3}>
|
<HStack align="start">
|
||||||
<FormControl isRequired>
|
<VStack flex={1} spacing={3}>
|
||||||
<FormLabel>Možnost {index + 1}</FormLabel>
|
<FormControl isRequired>
|
||||||
<Input
|
<FormLabel>Možnost {index + 1}</FormLabel>
|
||||||
value={option.text}
|
<Input
|
||||||
onChange={(e) =>
|
value={option.text}
|
||||||
updateOption(index, 'text', e.target.value)
|
onChange={(e) =>
|
||||||
}
|
updateOption(index, 'text', e.target.value)
|
||||||
placeholder="Text možnosti"
|
}
|
||||||
|
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>
|
</HStack>
|
||||||
<FormLabel>Popis (volitelné)</FormLabel>
|
</CardBody>
|
||||||
<Input
|
</Card>
|
||||||
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>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
leftIcon={<AddIcon />}
|
leftIcon={<AddIcon />}
|
||||||
onClick={addOption}
|
onClick={addOption}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
>
|
>
|
||||||
Přidat možnost
|
Přidat možnost
|
||||||
</Button>
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings Tab */}
|
{/* Settings Tab */}
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<VStack spacing={4}>
|
<VStack spacing={4}>
|
||||||
<FormControl display="flex" alignItems="center">
|
{formData.type !== 'rating' && (
|
||||||
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
<FormControl display="flex" alignItems="center">
|
||||||
<Switch
|
<FormLabel mb="0">Povolit více voleb</FormLabel>
|
||||||
isChecked={formData.allow_multiple}
|
<Switch
|
||||||
onChange={(e) =>
|
isChecked={formData.allow_multiple}
|
||||||
setFormData({ ...formData, allow_multiple: e.target.checked })
|
onChange={(e) =>
|
||||||
}
|
setFormData({ ...formData, allow_multiple: e.target.checked })
|
||||||
/>
|
}
|
||||||
</FormControl>
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
{formData.allow_multiple && (
|
{formData.type !== 'rating' && formData.allow_multiple && (
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Max. počet voleb</FormLabel>
|
<FormLabel>Max. počet voleb</FormLabel>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
|
|||||||
smtp_from: (settings as any).smtp_from,
|
smtp_from: (settings as any).smtp_from,
|
||||||
smtp_from_name: (settings as any).smtp_from_name,
|
smtp_from_name: (settings as any).smtp_from_name,
|
||||||
smtp_encryption: (settings as any).smtp_encryption as any,
|
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,
|
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
|
||||||
// videos module
|
// videos module
|
||||||
videos_module_enabled: (settings as any).videos_module_enabled as any,
|
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_latitude: (settings as any).location_latitude as any,
|
||||||
location_longitude: (settings as any).location_longitude as any,
|
location_longitude: (settings as any).location_longitude as any,
|
||||||
map_zoom_level: (settings as any).map_zoom_level 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:
|
||||||
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
|
(typeof (settings as any).location_latitude === 'number') &&
|
||||||
|
(typeof (settings as any).location_longitude === 'number'),
|
||||||
map_style: (settings as any).map_style,
|
map_style: (settings as any).map_style,
|
||||||
// homepage matches display
|
// homepage matches display
|
||||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
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 { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||||
|
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
|
||||||
import {
|
import {
|
||||||
Heading,
|
Heading,
|
||||||
Text,
|
Text,
|
||||||
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
|
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/...)
|
// 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;
|
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
|
|||||||
queryFn: fetchTeamLogoOverrides,
|
queryFn: fetchTeamLogoOverrides,
|
||||||
staleTime: 5 * 60 * 1000,
|
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
|
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||||
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
|
|||||||
const rows: TableRow[] = comp?.table?.overall || [];
|
const rows: TableRow[] = comp?.table?.overall || [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
if (r.team_id) teamIds.add(r.team_id);
|
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
|
// Unify various dash characters to a simple hyphen
|
||||||
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
|
||||||
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
|
// 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)
|
// Remove organization phrases/prefixes anywhere (keep core locality/name)
|
||||||
const orgPhrases = [
|
const orgPhrases = [
|
||||||
'fotbalovy klub',
|
'fotbalovy klub',
|
||||||
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
|
|||||||
'futsal',
|
'futsal',
|
||||||
];
|
];
|
||||||
for (const phrase of orgPhrases) {
|
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, ' ');
|
out = out.replace(re, ' ');
|
||||||
}
|
}
|
||||||
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
|
||||||
@@ -152,40 +160,61 @@ const TeamsAdminPage = () => {
|
|||||||
}
|
}
|
||||||
return idx;
|
return idx;
|
||||||
}, [byName]);
|
}, [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) => {
|
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
|
||||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||||
|
// Priority 0: Admin override by team ID
|
||||||
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
|
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
||||||
if (teamId && sportLogosMap[teamId]) {
|
const u = String(overridesById[teamId].logo_url);
|
||||||
return sportLogosMap[teamId];
|
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||||
|
return u;
|
||||||
}
|
}
|
||||||
|
// Priority 1: Local admin override (exact + normalized)
|
||||||
// Priority 2: Try exact match from local overrides
|
|
||||||
let overrideUrl = byName[teamName];
|
let overrideUrl = byName[teamName];
|
||||||
if (!overrideUrl) {
|
if (!overrideUrl) {
|
||||||
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
|
|
||||||
const norm = normalize(teamName);
|
const norm = normalize(teamName);
|
||||||
overrideUrl = byNameNormalized[norm];
|
overrideUrl = byNameNormalized[norm];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Use override if found
|
|
||||||
if (overrideUrl) {
|
if (overrideUrl) {
|
||||||
// Resolve against backend for relative assets
|
|
||||||
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
|
||||||
return assetUrl(overrideUrl) as string;
|
return assetUrl(overrideUrl) as string;
|
||||||
}
|
}
|
||||||
return overrideUrl;
|
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) {
|
if (original) {
|
||||||
return original;
|
return original;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final fallback: empty logo
|
// Final fallback: empty logo
|
||||||
return '/dist/img/logo-club-empty.svg';
|
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
|
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
|
||||||
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
||||||
// Selected competition for quick switching (only applies in table mode)
|
// Selected competition for quick switching (only applies in table mode)
|
||||||
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
|
|||||||
name: string; // representative name
|
name: string; // representative name
|
||||||
logo: string;
|
logo: string;
|
||||||
variants: string[]; // all raw names found
|
variants: string[]; // all raw names found
|
||||||
|
teamId?: string;
|
||||||
};
|
};
|
||||||
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
|
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
|
||||||
const map: Record<string, TeamAggregate> = {};
|
const map: Record<string, TeamAggregate> = {};
|
||||||
for (const comp of competitions) {
|
for (const comp of competitions) {
|
||||||
const rows: TableRow[] = comp?.table?.overall || [];
|
const rows: TableRow[] = comp?.table?.overall || [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const teamName = (r.team || '').trim();
|
const rawName = (r.team || '').trim();
|
||||||
if (!teamName) continue;
|
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
|
||||||
const key = normalize(teamName);
|
if (!teamId && mainClubId) {
|
||||||
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
|
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]) {
|
if (!map[key]) {
|
||||||
map[key] = { key, name: teamName, logo, variants: [teamName] };
|
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
|
||||||
} else {
|
} else {
|
||||||
map[key].variants.push(teamName);
|
map[key].variants.push(rawName);
|
||||||
|
map[key].variants.push(canonicalName);
|
||||||
// Update logo - prefer non-empty logos
|
// Update logo - prefer non-empty logos
|
||||||
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
|
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
|
||||||
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
|
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
|
||||||
if (currentIsEmpty && newIsNotEmpty) {
|
if (currentIsEmpty && newIsNotEmpty) {
|
||||||
map[key].logo = logo as string;
|
map[key].logo = logo as string;
|
||||||
}
|
}
|
||||||
|
if (!map[key].teamId && teamId) {
|
||||||
|
map[key].teamId = teamId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sort by representative name
|
// Sort by representative name
|
||||||
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
|
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
|
// Fast lookup from normalized name to variant list
|
||||||
const variantsByKey = useMemo(() => {
|
const variantsByKey = useMemo(() => {
|
||||||
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
|
|||||||
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
|
||||||
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
|
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
|
// Club search
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
|
|||||||
enabled: debounced.trim().length >= 2,
|
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
|
// If variants not explicitly provided (e.g., from table view), compute from normalized key
|
||||||
let v = variantNames;
|
let v = variantNames;
|
||||||
if (!v || v.length === 0) {
|
if (!v || v.length === 0) {
|
||||||
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
|
|||||||
v = variantsByKey[key] || [];
|
v = variantsByKey[key] || [];
|
||||||
}
|
}
|
||||||
setSelected({ teamName, teamLogoUrl, variantNames: v });
|
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 || '');
|
setQuery(teamName || '');
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
|
|||||||
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
|
||||||
}
|
}
|
||||||
let logoUrl = (form.logo_url || '').trim();
|
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
|
// All variants to update (deduped), always include the primary name
|
||||||
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
|
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
|
||||||
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
|
try {
|
||||||
if (logoUrl) {
|
if (!uploadedFile && form.external_team_id) {
|
||||||
setExternalUploadStatus('uploading');
|
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
|
||||||
setExternalUploadError(null);
|
if (apiLogo) {
|
||||||
try {
|
logoUrl = apiLogo;
|
||||||
let logoFileToUpload: File | Blob | null = uploadedFile;
|
|
||||||
if (!logoFileToUpload && logoUrl) {
|
|
||||||
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
|
|
||||||
}
|
}
|
||||||
if (logoFileToUpload) {
|
}
|
||||||
const logaResult = await uploadToLogaSportcreative(
|
} catch {}
|
||||||
form.external_team_id,
|
|
||||||
logoFileToUpload,
|
if (logoUrl) {
|
||||||
{
|
let shouldUpload = Boolean(uploadedFile);
|
||||||
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
|
try {
|
||||||
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
|
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
|
||||||
}
|
const u = new URL(abs);
|
||||||
);
|
const host = u.hostname.toLowerCase();
|
||||||
if (logaResult.success) {
|
const path = u.pathname;
|
||||||
setExternalUploadStatus('success');
|
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
|
||||||
if (logaResult.url) {
|
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
|
||||||
logoUrl = logaResult.url;
|
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 {
|
} else {
|
||||||
setExternalUploadStatus('error');
|
setExternalUploadStatus('error');
|
||||||
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
|
setExternalUploadError('Could not fetch logo file');
|
||||||
}
|
}
|
||||||
} else {
|
} catch (error: any) {
|
||||||
setExternalUploadStatus('error');
|
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 putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
|
||||||
await Promise.all(
|
|
||||||
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
|
|||||||
<Td py={1.5}>
|
<Td py={1.5}>
|
||||||
<HStack spacing={2} align="center">
|
<HStack spacing={2} align="center">
|
||||||
<Image
|
<Image
|
||||||
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
|
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
|
||||||
alt={r.team}
|
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
|
||||||
boxSize="24px"
|
boxSize="24px"
|
||||||
objectFit="contain"
|
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>
|
</HStack>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isNumeric py={1.5} fontSize="xs">{r.played}</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">{r.score}</Td>
|
||||||
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
|
||||||
<Td py={1.5}>
|
<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>
|
</Td>
|
||||||
</Tr>
|
</Tr>
|
||||||
))}
|
))}
|
||||||
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</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>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
|
|||||||
px={3}
|
px={3}
|
||||||
py={2}
|
py={2}
|
||||||
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
|
_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 }));
|
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
|
||||||
setQuery(r.name);
|
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}>
|
<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(', ')}
|
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{form.logo_url && (
|
{showExternalUploadInfo && (
|
||||||
<Alert status="info" variant="left-accent">
|
<Alert status="info" variant="left-accent">
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<VStack align="start" spacing={1}>
|
<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.
|
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>
|
</Text>
|
||||||
|
|
||||||
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
|
<Heading size="md" mb={2}>Admini a editoři</Heading>
|
||||||
<Table variant="simple">
|
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
|
||||||
<Thead>
|
<Table variant="simple">
|
||||||
<Tr>
|
<Thead>
|
||||||
<Th>Name</Th>
|
<Tr>
|
||||||
<Th>Email</Th>
|
<Th>Name</Th>
|
||||||
<Th>Role</Th>
|
<Th>Email</Th>
|
||||||
<Th>Status</Th>
|
<Th>Role</Th>
|
||||||
<Th>Created</Th>
|
<Th>Status</Th>
|
||||||
<Th>Actions</Th>
|
<Th>Created</Th>
|
||||||
</Tr>
|
<Th>Actions</Th>
|
||||||
</Thead>
|
</Tr>
|
||||||
<Tbody>
|
</Thead>
|
||||||
{users.map((user) => (
|
<Tbody>
|
||||||
<Tr key={user.id}>
|
{users.filter(u => u.role !== 'fan').map((user) => (
|
||||||
<Td>{user.name}</Td>
|
<Tr key={user.id}>
|
||||||
<Td>{user.email}</Td>
|
<Td>{user.name}</Td>
|
||||||
<Td>
|
<Td>{user.email}</Td>
|
||||||
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
|
<Td>
|
||||||
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
|
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
|
||||||
</Badge>
|
{user.role === 'admin' ? 'Admin' : 'Editor'}
|
||||||
</Td>
|
</Badge>
|
||||||
<Td>
|
</Td>
|
||||||
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
<Td>
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
|
||||||
</Badge>
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</Td>
|
</Badge>
|
||||||
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
</Td>
|
||||||
<Td>
|
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
|
||||||
<Menu>
|
<Td>
|
||||||
<MenuButton
|
<Menu>
|
||||||
as={IconButton}
|
<MenuButton
|
||||||
aria-label="Options"
|
as={IconButton}
|
||||||
icon={<HamburgerIcon />}
|
aria-label="Options"
|
||||||
size="sm"
|
icon={<HamburgerIcon />}
|
||||||
variant="ghost"
|
size="sm"
|
||||||
/>
|
variant="ghost"
|
||||||
<MenuList>
|
/>
|
||||||
<MenuItem
|
<MenuList>
|
||||||
icon={<EditIcon />}
|
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
|
||||||
onClick={() => openEditModal(user)}
|
Edit
|
||||||
>
|
</MenuItem>
|
||||||
Edit
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={async () => {
|
<MenuItem onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/admin/users/${user.id}/reset-password`);
|
await api.post(`/admin/users/${user.id}/reset-password`);
|
||||||
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
|
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
|
||||||
const errorDetails = e?.response?.data?.details;
|
const errorDetails = e?.response?.data?.details;
|
||||||
toast({
|
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
|
||||||
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
|
Odeslat reset hesla
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
|
||||||
<MenuItem
|
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
|
||||||
icon={<DeleteIcon />}
|
Delete
|
||||||
color="red.500"
|
</MenuItem>
|
||||||
onClick={() => handleDelete(user.id)}
|
)}
|
||||||
>
|
</MenuList>
|
||||||
Delete
|
</Menu>
|
||||||
</MenuItem>
|
</Td>
|
||||||
)}
|
</Tr>
|
||||||
</MenuList>
|
))}
|
||||||
</Menu>
|
</Tbody>
|
||||||
</Td>
|
</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>
|
</Tr>
|
||||||
))}
|
</Thead>
|
||||||
</Tbody>
|
<Tbody>
|
||||||
</Table>
|
{users.filter(u => u.role === 'fan').map((user) => (
|
||||||
</Box>
|
<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 */}
|
{/* Add/Edit User Modal */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="lg">
|
<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';
|
import MainLayout from '../../components/layout/MainLayout';
|
||||||
|
|
||||||
const CookiePolicyPage: React.FC = () => {
|
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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container maxW="3xl">
|
<Container maxW="3xl" py={8}>
|
||||||
<Heading as="h1" size="lg" mb={4}>Pravidla používání souborů Cookies</Heading>
|
<VStack align="stretch" spacing={6}>
|
||||||
<Text mb={4}>
|
<Heading as="h1" size="xl" mb={2} color={headingColor}>Pravidla používání souborů cookies</Heading>
|
||||||
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 fontSize="sm" color={textColor}>
|
||||||
</Text>
|
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>Co jsou cookies?</Heading>
|
</Text>
|
||||||
<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.
|
<Box bg={boxBg} p={4} borderRadius="md">
|
||||||
</Text>
|
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>Jaké typy cookies používáme</Heading>
|
<Text fontSize="sm" color={boxText}>
|
||||||
<List spacing={2} mb={4} styleType="disc" pl={6}>
|
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.
|
||||||
<ListItem>Nezbytné cookies – zajišťují základní funkce webu.</ListItem>
|
</Text>
|
||||||
<ListItem>Preferenční cookies – pamatují si vaše volby (např. jazyk).</ListItem>
|
</Box>
|
||||||
<ListItem>Analytické cookies – pomáhají nám porozumět, jak web používáte (anonymně).</ListItem>
|
|
||||||
</List>
|
<Divider />
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>Správa cookies</Heading>
|
|
||||||
<Text mb={4}>
|
<Box>
|
||||||
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.
|
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Co jsou cookies?</Heading>
|
||||||
</Text>
|
<Text color={textColor} mb={4}>
|
||||||
<Text fontSize="sm" color="gray.600">
|
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.
|
||||||
V případě dotazů nás kontaktujte na adrese <Link href="/kontakt">/kontakt</Link>.
|
</Text>
|
||||||
</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>
|
</Container>
|
||||||
</MainLayout>
|
</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>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>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>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>
|
<ListItem><strong>Analytické údaje:</strong> anonymizovaná data o chování na webu prostřednictvím analytických nástrojů</ListItem>
|
||||||
</List>
|
</List>
|
||||||
</Box>
|
</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';
|
import MainLayout from '../../components/layout/MainLayout';
|
||||||
|
|
||||||
const TermsPage: React.FC = () => {
|
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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Container maxW="3xl">
|
<Container maxW="3xl" py={8}>
|
||||||
<Heading as="h1" size="lg" mb={4}>Obchodní podmínky</Heading>
|
<VStack align="stretch" spacing={6}>
|
||||||
<Text mb={4}>
|
<Heading as="h1" size="xl" mb={2} color={headingColor}>Obchodní podmínky</Heading>
|
||||||
Tyto obchodní podmínky upravují používání webových stránek a poskytované služby.
|
<Text fontSize="sm" color={textColor}>
|
||||||
</Text>
|
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>1. Obecná ustanovení</Heading>
|
</Text>
|
||||||
<Text mb={4}>
|
|
||||||
Provozovatelem webu je subjekt uvedený v kontaktech. Používáním webu vyjadřujete souhlas s těmito podmínkami.
|
<Box bg={boxBg} p={4} borderRadius="md">
|
||||||
</Text>
|
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>2. Obsah webu</Heading>
|
<Text fontSize="sm" color={boxText}>
|
||||||
<Text mb={4}>
|
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.
|
||||||
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>
|
||||||
</Text>
|
</Box>
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>3. Odpovědnost</Heading>
|
|
||||||
<Text mb={4}>
|
<Divider />
|
||||||
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>
|
<Box>
|
||||||
<Heading as="h2" size="md" mt={6} mb={2}>4. Kontakt</Heading>
|
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Provozovatel a kontakt</Heading>
|
||||||
<Text mb={4}>
|
<Text color={textColor} mb={4}>
|
||||||
V případě dotazů nás kontaktujte na stránce Kontakt.
|
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>
|
</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>
|
</Container>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
|
|||||||
fullUrl = `${apiOrigin}${logoUrl}`;
|
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;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
return await response.blob();
|
return await response.blob();
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export async function getArticles(params: {
|
|||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
q?: string;
|
q?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
match_id?: string | number;
|
||||||
|
month?: string; // YYYY-MM
|
||||||
} = {}) {
|
} = {}) {
|
||||||
// Backend returns shape: { items, total, page, page_size }
|
// Backend returns shape: { items, total, page, page_size }
|
||||||
// Normalize to { data, total, page, page_size } expected by the frontend.
|
// 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
|
// Lazy-load public overrides with lightweight cache
|
||||||
let overridesCache: { data: any; ts: number } | null = null;
|
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();
|
const now = Date.now();
|
||||||
if (overridesCache && now - overridesCache.ts < 60_000) {
|
if (overridesCache && now - overridesCache.ts < 60_000) {
|
||||||
return (overridesCache.data?.by_name || {}) as Record<string, string>;
|
return (overridesCache.data || {}) as OverridesPayload;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
|
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
|
// Invalidate internal FACR GET cache so consumers refetch with new logos
|
||||||
cache.clear();
|
cache.clear();
|
||||||
}
|
}
|
||||||
return (json?.by_name || {}) as Record<string, string>;
|
return (json || {}) as OverridesPayload;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
// Fallback to cached file if API failed
|
// Fallback to cached file if API failed
|
||||||
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
|
|||||||
if (prev !== next) {
|
if (prev !== next) {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
}
|
}
|
||||||
return (json?.by_name || {}) as Record<string, string>;
|
return (json || {}) as OverridesPayload;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
overridesCache = { data: { by_name: {} }, ts: now };
|
overridesCache = { data: { by_name: {} }, ts: now };
|
||||||
return {};
|
return { by_name: {} };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Name normalization helpers
|
// 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, '');
|
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();
|
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;
|
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) => {
|
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
|
||||||
acc[norm(k)] = byName[k];
|
acc[norm(k)] = byName[k];
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
|
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;
|
if (!teamName) return original;
|
||||||
const exact = (byName || {})[teamName];
|
const exact = (byName || {})[teamName];
|
||||||
const n = norm(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);
|
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
|
||||||
return 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) => ({
|
club.competitions = (club.competitions || []).map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
matches: (c.matches || []).map((m: any) => ({
|
matches: (c.matches || []).map((m: any) => ({
|
||||||
...m,
|
...m,
|
||||||
home_logo_url: pick(m.home, m.home_logo_url),
|
home: pickName(m.home_id, m.home),
|
||||||
away_logo_url: pick(m.away, m.away_logo_url),
|
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;
|
return club;
|
||||||
@@ -232,8 +249,8 @@ export const facrApi = {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
|
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
|
||||||
// Load overrides and apply before returning/caching consumers
|
// Load overrides and apply before returning/caching consumers
|
||||||
const byName = await loadOverrides();
|
const overrides = await loadOverrides();
|
||||||
const patched = applyOverridesToClub(response.data, byName);
|
const patched = applyOverridesToClub(response.data, overrides);
|
||||||
return patched;
|
return patched;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
@@ -244,7 +261,47 @@ export const facrApi = {
|
|||||||
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
|
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
|
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) {
|
} catch (error) {
|
||||||
return handleApiError(error);
|
return handleApiError(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ export interface PredefinedElement {
|
|||||||
|
|
||||||
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
|
||||||
// Layout - Rozvržení
|
// 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: '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: '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: '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' },
|
{ 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[]> = {
|
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: [
|
header: [
|
||||||
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
|
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
|
||||||
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
|
{ 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: 'current', label: 'Současný', description: 'Stávající navigace' },
|
||||||
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
|
{ 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: [
|
hero: [
|
||||||
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
|
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
|
||||||
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
|
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
|
||||||
|
|||||||
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
|
|||||||
event_id?: number;
|
event_id?: number;
|
||||||
video_url?: string;
|
video_url?: string;
|
||||||
}): Promise<Poll[]> => {
|
}): Promise<Poll[]> => {
|
||||||
const response = await api.get('/polls', { params });
|
const response = await api.get('/polls', {
|
||||||
|
params,
|
||||||
|
headers: { 'X-Session-Token': generateSessionToken() },
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPoll = async (id: number): Promise<PollResponse> => {
|
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;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,12 +189,20 @@ export const votePoll = async (
|
|||||||
id: number,
|
id: number,
|
||||||
data: PollVoteRequest
|
data: PollVoteRequest
|
||||||
): Promise<{ message: string; poll: Poll }> => {
|
): 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;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
|
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;
|
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 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[] };
|
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() {
|
export async function getMatches() {
|
||||||
const res = await api.get<Match[] | { data: Match[] }>('/matches');
|
const res = await api.get<Match[] | { data: Match[] }>('/matches');
|
||||||
return Array.isArray(res.data) ? res.data : res.data.data;
|
return Array.isArray(res.data) ? res.data : res.data.data;
|
||||||
@@ -33,15 +54,16 @@ export async function getStandings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayers() {
|
export async function getPlayers() {
|
||||||
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
|
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
|
||||||
if (Array.isArray(res.data)) return res.data as Player[];
|
const raw = Array.isArray(res.data)
|
||||||
const d = res.data as any;
|
? res.data
|
||||||
return (d?.data || d?.items || []) as Player[];
|
: ((res.data as any).data || (res.data as any).items);
|
||||||
|
return (raw || []).map(normalizePlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPlayer(id: number | string) {
|
export async function getPlayer(id: number | string) {
|
||||||
const res = await api.get<Player>(`/players/${id}`);
|
const res = await api.get<any>(`/players/${id}`);
|
||||||
return res.data;
|
return normalizePlayer(res.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSponsors() {
|
export async function getSponsors() {
|
||||||
|
|||||||
+146
-86
@@ -35,60 +35,50 @@ export interface SearchResults {
|
|||||||
total: number;
|
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 scoreMatch = (text: string, query: string): number => {
|
||||||
const t = (text || '').toLowerCase();
|
const base = (t: string, q: string): number => {
|
||||||
const q = (query || '').toLowerCase();
|
if (!t || !q) return 0;
|
||||||
if (!t || !q) return 0;
|
if (t === q) return 100;
|
||||||
|
if (t.startsWith(q)) return 80;
|
||||||
// Exact match - highest score
|
const idx = t.indexOf(q);
|
||||||
if (t === q) return 100;
|
if (idx >= 0) return 60 - Math.min(idx, 30);
|
||||||
|
const keywords = q.split(/\s+/).filter(k => k.length > 1);
|
||||||
// Starts with query - very high score
|
if (keywords.length > 1) {
|
||||||
if (t.startsWith(q)) return 80;
|
let matchedKeywords = 0;
|
||||||
|
let totalScore = 0;
|
||||||
// Contains query as whole substring
|
for (const keyword of keywords) {
|
||||||
const idx = t.indexOf(q);
|
if (t.includes(keyword)) {
|
||||||
if (idx >= 0) return 60 - Math.min(idx, 30);
|
matchedKeywords++;
|
||||||
|
const keywordIdx = t.indexOf(keyword);
|
||||||
// Keyword matching - split query into words and check each
|
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
|
||||||
const keywords = q.split(/\s+/).filter(k => k.length > 1);
|
}
|
||||||
if (keywords.length > 1) {
|
}
|
||||||
let matchedKeywords = 0;
|
if (matchedKeywords >= keywords.length / 2) {
|
||||||
let totalScore = 0;
|
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
||||||
|
|
||||||
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 chars = q.split('');
|
||||||
// If at least half the keywords match, return proportional score
|
let lastIdx = -1;
|
||||||
if (matchedKeywords >= keywords.length / 2) {
|
let matched = 0;
|
||||||
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
|
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));
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
return 0;
|
||||||
|
};
|
||||||
if (matched >= chars.length * 0.8) {
|
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
return Math.min(25, Math.floor((matched / chars.length) * 25));
|
const t0 = (text || '').toLowerCase();
|
||||||
}
|
const q0 = (query || '').toLowerCase();
|
||||||
|
const t1 = strip(t0);
|
||||||
return 0;
|
const q1 = strip(q0);
|
||||||
|
return Math.max(base(t0, q0), base(t1, q1));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve backend URLs for assets
|
// 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) =>
|
const normalizeName = (value: string) =>
|
||||||
String(value || '')
|
String(value || '')
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
|||||||
galleryRes,
|
galleryRes,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
relatedClubsPromise,
|
relatedClubsPromise,
|
||||||
// Clubs from FACR
|
|
||||||
facrApi.searchClubs(query).catch(() => ({ results: [] })),
|
facrApi.searchClubs(query).catch(() => ({ results: [] })),
|
||||||
|
|
||||||
// Matches (upcoming)
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
|
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
|
||||||
const res = await fetch(url, { cache: 'no-cache' });
|
try {
|
||||||
if (!res.ok) return [];
|
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||||
return await res.json();
|
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 () => {
|
(async () => {
|
||||||
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
|
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
|
||||||
const res = await fetch(url, { cache: 'no-cache' });
|
try {
|
||||||
if (!res.ok) return [];
|
const r = await fetch(apiUrl, { cache: 'no-cache' });
|
||||||
return await res.json();
|
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 }),
|
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
|
||||||
|
|
||||||
// Players
|
|
||||||
getPlayers(),
|
getPlayers(),
|
||||||
|
|
||||||
// Events
|
|
||||||
getUpcomingEvents(),
|
getUpcomingEvents(),
|
||||||
|
|
||||||
// Sponsors
|
|
||||||
getSponsors(),
|
getSponsors(),
|
||||||
|
|
||||||
// Teams
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await api.get('/teams');
|
const res = await api.get('/teams');
|
||||||
return Array.isArray(res.data) ? res.data : res.data?.data || [];
|
return Array.isArray(res.data) ? res.data : res.data?.data || [];
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
// Contacts
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/contacts');
|
const res = await api.get('/contacts');
|
||||||
// Backend returns { categories: {...}, uncategorized: [...] }
|
|
||||||
// Flatten into a single array
|
|
||||||
const grouped = res.data?.categories || {};
|
const grouped = res.data?.categories || {};
|
||||||
const uncategorized = res.data?.uncategorized || [];
|
const uncategorized = res.data?.uncategorized || [];
|
||||||
const allContacts = [...uncategorized];
|
const allContacts = [...uncategorized];
|
||||||
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
// Gallery albums
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/gallery/albums');
|
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 clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
|
||||||
const clubs: SearchResult[] = clubsData
|
const clubs: SearchResult[] = clubsData
|
||||||
.filter((c: any) => {
|
.filter((c: any) => {
|
||||||
// Filter out clubs with no name or empty name
|
|
||||||
const name = String(c.name || '').trim();
|
const name = String(c.name || '').trim();
|
||||||
if (!name) return false;
|
if (!name) return false;
|
||||||
|
return true;
|
||||||
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));
|
|
||||||
})
|
})
|
||||||
.map((c: any) => {
|
.map((c: any) => {
|
||||||
const idKey = String(c.club_id || c.id || '').toLowerCase();
|
const idKey = String(c.club_id || c.id || '').toLowerCase();
|
||||||
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
|||||||
// Process matches (upcoming)
|
// Process matches (upcoming)
|
||||||
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
|
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
|
||||||
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
|
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) => ({
|
.map((m: any, idx: number) => ({
|
||||||
type: 'match' as const,
|
type: 'match' as const,
|
||||||
id: m.id || idx,
|
id: m.id || idx,
|
||||||
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
|
|||||||
// Process matches (past)
|
// Process matches (past)
|
||||||
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
|
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
|
||||||
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
|
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) => ({
|
.map((m: any, idx: number) => ({
|
||||||
type: 'match_past' as const,
|
type: 'match_past' as const,
|
||||||
id: `past-${m.id || idx}`,
|
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');
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
|
||||||
function resolveBackendOrigin() {
|
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 {
|
try {
|
||||||
|
if (!raw || raw.startsWith('/')) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
const u = new URL(raw);
|
const u = new URL(raw);
|
||||||
u.pathname = '/';
|
u.pathname = '/';
|
||||||
return u.toString();
|
return u.toString();
|
||||||
} catch (e) {
|
} 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.)
|
// Proxy /cache requests to backend (for FACR cache files, etc.)
|
||||||
app.use(
|
app.use(
|
||||||
'/cache',
|
'/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 {
|
.ql-toolbar.ql-snow {
|
||||||
min-height: 42px !important;
|
min-height: 42px !important;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 5000 !important;
|
||||||
|
overflow: visible !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-container.ql-snow {
|
.ql-container.ql-snow {
|
||||||
@@ -148,6 +151,8 @@
|
|||||||
background: white;
|
background: white;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 6000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
|
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
|
||||||
@@ -452,10 +457,21 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
z-index: 1000;
|
z-index: 6000;
|
||||||
pointer-events: none;
|
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 */
|
/* Loading State for Images */
|
||||||
.ql-editor img[src=""] {
|
.ql-editor img[src=""] {
|
||||||
opacity: 0.3;
|
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)
|
// Known backend-served asset paths (/uploads, optionally /dist)
|
||||||
if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/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 || '';
|
||||||
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
|
if (explicit && !explicit.startsWith('/')) {
|
||||||
if (explicit) {
|
|
||||||
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
|
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
|
||||||
baseUrl.pathname = '/';
|
baseUrl.pathname = '/';
|
||||||
return new URL(pathOrUrl, baseUrl).toString();
|
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;
|
return pathOrUrl;
|
||||||
}
|
}
|
||||||
// Otherwise return as-is (relative or other paths)
|
// Otherwise return as-is (relative or other paths)
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ func (ac *AuthController) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-subscribe newly registered fans to the newsletter
|
||||||
|
_ = models.SubscribeToNewsletter(ac.DB, user.Email)
|
||||||
|
|
||||||
// For first user, ensure setup info exists
|
// For first user, ensure setup info exists
|
||||||
if isFirstUser {
|
if isFirstUser {
|
||||||
_, err := ac.setupService.GetSetupStatus()
|
_, err := ac.setupService.GetSetupStatus()
|
||||||
|
|||||||
+1349
-2014
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gopkg.in/mail.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContactController struct {
|
type ContactController struct {
|
||||||
@@ -27,7 +28,61 @@ type ContactController struct {
|
|||||||
emailService email.EmailService
|
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)
|
// GET /api/v1/newsletter/token/me (auth required)
|
||||||
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
||||||
u, ok := c.Get("user")
|
u, ok := c.Get("user")
|
||||||
@@ -43,24 +98,27 @@ func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
|
|||||||
return
|
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)
|
token, err := utils.GenerateSubscriberToken(email, 60*24)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
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
|
||||||
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
|
|
||||||
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var input struct {
|
var input struct {
|
||||||
Type string `json:"type" binding:"required"`
|
Type string `json:"type" binding:"required"`
|
||||||
Competitions string `json:"competitions"`
|
Competitions string `json:"competitions"`
|
||||||
@@ -69,7 +127,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t := strings.ToLower(strings.TrimSpace(input.Type))
|
t := strings.ToLower(strings.TrimSpace(input.Type))
|
||||||
allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true}
|
allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true}
|
||||||
if !allowed[t] {
|
if !allowed[t] {
|
||||||
@@ -77,7 +134,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch active subscribers
|
|
||||||
var subscribers []models.NewsletterSubscription
|
var subscribers []models.NewsletterSubscription
|
||||||
if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil {
|
if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
|
||||||
@@ -88,13 +144,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
|||||||
return
|
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" {
|
if t == "weekly" {
|
||||||
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
|
||||||
prefs.Frequency = "weekly"
|
prefs.Frequency = "weekly"
|
||||||
@@ -103,9 +153,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if strings.TrimSpace(input.Competitions) != "" {
|
if strings.TrimSpace(input.Competitions) != "" {
|
||||||
for _, p := range strings.Split(input.Competitions, ",") {
|
for _, p := range strings.Split(input.Competitions, ",") {
|
||||||
if v := strings.TrimSpace(p); v != "" {
|
if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) }
|
||||||
prefs.Competitions = append(prefs.Competitions, v)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipients list
|
|
||||||
recipients := make([]string, 0, len(subscribers))
|
recipients := make([]string, 0, len(subscribers))
|
||||||
for _, s := range subscribers {
|
for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } }
|
||||||
if s.Email != "" {
|
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}); return }
|
||||||
recipients = append(recipients, s.Email)
|
if subj == "" { subj = strings.Title(t) + " digest" }
|
||||||
}
|
|
||||||
}
|
|
||||||
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}
|
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||||
logger.Error("Failed to send digest newsletter: %v", err)
|
logger.Error("Failed to send digest newsletter: %v", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": len(recipients), "type": t})
|
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
|
||||||
// PATCH /api/v1/admin/newsletter/enable { enabled: boolean }
|
|
||||||
func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
|
func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var input struct {
|
var input struct { Enabled bool `json:"enabled"` }
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Persist to Settings (singleton row)
|
|
||||||
var s models.Settings
|
var s models.Settings
|
||||||
_ = cc.DB.First(&s).Error // ignore not found
|
_ = cc.DB.First(&s).Error
|
||||||
if s.ID == 0 {
|
if s.ID == 0 { s = models.Settings{} }
|
||||||
s = models.Settings{}
|
|
||||||
}
|
|
||||||
s.NewsletterEnabled = input.Enabled
|
s.NewsletterEnabled = input.Enabled
|
||||||
if s.ID == 0 {
|
if s.ID == 0 {
|
||||||
if err := cc.DB.Create(&s).Error; err != nil {
|
if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
|
} else if err := cc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
|
||||||
return
|
if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = input.Enabled }
|
||||||
}
|
|
||||||
} 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
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
|
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetNewsletterStatus returns basic scheduling/status info for newsletters (admin only)
|
// GET /api/v1/admin/newsletter/status
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var total, active int64
|
||||||
var total int64
|
|
||||||
var active int64
|
|
||||||
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
||||||
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
||||||
|
|
||||||
var subs []models.NewsletterSubscription
|
var subs []models.NewsletterSubscription
|
||||||
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
||||||
sample := make([]string, 0, len(subs))
|
sample := make([]string, 0, len(subs))
|
||||||
for _, s := range subs {
|
for _, s := range subs { if s.Email != "" { sample = append(sample, s.Email) } }
|
||||||
if s.Email != "" {
|
|
||||||
sample = append(sample, s.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interval := 24 * time.Hour
|
interval := 24 * time.Hour
|
||||||
if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" {
|
if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" {
|
||||||
if d, err := time.ParseDuration(v + "h"); err == nil {
|
if d, err := time.ParseDuration(v + "h"); err == nil { interval = d }
|
||||||
interval = d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
next := time.Now().Add(interval)
|
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
|
// POST /api/v1/admin/newsletter/preview
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
|
func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var input struct {
|
var input struct { Preferences map[string]interface{} `json:"preferences"` }
|
||||||
Preferences map[string]interface{} `json:"preferences"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&input)
|
_ = c.ShouldBindJSON(&input)
|
||||||
|
prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||||
// Normalize preferences to NewsletterPrefs
|
|
||||||
prefs := services.NewsletterPrefs{
|
|
||||||
Email: "preview@local",
|
|
||||||
ContentTypes: []string{},
|
|
||||||
Competitions: []string{},
|
|
||||||
Frequency: "daily",
|
|
||||||
}
|
|
||||||
if m := input.Preferences; m != nil {
|
if m := input.Preferences; m != nil {
|
||||||
if b, ok := m["blogs"].(bool); ok && b {
|
if b, ok := m["blogs"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") }
|
||||||
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["events"].(bool); ok && b {
|
if b, ok := m["scores"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "scores") }
|
||||||
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) != "" {
|
if cs, ok := m["competitions"].(string); ok && strings.TrimSpace(cs) != "" {
|
||||||
parts := strings.Split(cs, ",")
|
for _, p := range strings.Split(cs, ",") { if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) } }
|
||||||
for _, p := range parts {
|
|
||||||
if v := strings.TrimSpace(p); v != "" {
|
|
||||||
prefs.Competitions = append(prefs.Competitions, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||||
cacheDir := "cache/prefetch"
|
|
||||||
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
|
|
||||||
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
|
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
|
||||||
// GET /api/v1/newsletter/preferences?token=...
|
|
||||||
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
|
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
|
||||||
token := strings.TrimSpace(c.Query("token"))
|
token := strings.TrimSpace(c.Query("token"))
|
||||||
if token == "" {
|
if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
emailStr, err := utils.ParseSubscriberToken(token)
|
emailStr, err := utils.ParseSubscriberToken(token)
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sub models.NewsletterSubscription
|
var sub models.NewsletterSubscription
|
||||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
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.StatusNotFound, gin.H{"error": "Subscription not found"})
|
c.JSON(http.StatusOK, gin.H{"email": sub.Email, "is_active": sub.IsActive, "preferences": sub.Preferences})
|
||||||
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
|
||||||
// POST /api/v1/newsletter/preferences { token, preferences }
|
|
||||||
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
||||||
var input struct {
|
var input struct { Token string `json:"token" binding:"required"`; Preferences map[string]interface{} `json:"preferences" binding:"required"` }
|
||||||
Token string `json:"token" binding:"required"`
|
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||||
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)
|
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sub models.NewsletterSubscription
|
var sub models.NewsletterSubscription
|
||||||
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
|
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.StatusNotFound, gin.H{"error": "Subscription not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jm := datatypes.JSONMap{}
|
jm := datatypes.JSONMap{}
|
||||||
for key, raw := range input.Preferences {
|
for key, raw := range input.Preferences {
|
||||||
switch v := raw.(type) {
|
switch v := raw.(type) {
|
||||||
@@ -343,13 +267,7 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
|||||||
jm[key] = strings.TrimSpace(v)
|
jm[key] = strings.TrimSpace(v)
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
compiled := make([]string, 0, len(v))
|
compiled := make([]string, 0, len(v))
|
||||||
for _, item := range v {
|
for _, item := range v { if s, ok := item.(string); ok { if trimmed := strings.TrimSpace(s); trimmed != "" { compiled = append(compiled, trimmed) } } }
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
if trimmed := strings.TrimSpace(s); trimmed != "" {
|
|
||||||
compiled = append(compiled, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jm[key] = strings.Join(compiled, ", ")
|
jm[key] = strings.Join(compiled, ", ")
|
||||||
case float64, int, int64:
|
case float64, int, int64:
|
||||||
jm[key] = v
|
jm[key] = v
|
||||||
@@ -360,7 +278,6 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if compVal, ok := jm["competitions"]; ok {
|
if compVal, ok := jm["competitions"]; ok {
|
||||||
if compStr, ok := compVal.(string); ok {
|
if compStr, ok := compVal.(string); ok {
|
||||||
comp := strings.TrimSpace(compStr)
|
comp := strings.TrimSpace(compStr)
|
||||||
@@ -374,32 +291,18 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sub.Preferences = jm
|
sub.Preferences = jm
|
||||||
sub.UpdatedAt = time.Now()
|
sub.UpdatedAt = time.Now()
|
||||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}); return }
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnsubscribeByToken disables newsletter using a token (no auth required)
|
// POST /api/v1/newsletter/unsubscribe-token
|
||||||
// POST /api/v1/newsletter/unsubscribe-token { token }
|
|
||||||
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
||||||
var input struct {
|
var input struct { Token string `json:"token" binding:"required"` }
|
||||||
Token string `json:"token" binding:"required"`
|
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
emailStr, err := utils.ParseSubscriberToken(input.Token)
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
|
||||||
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 {
|
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"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
|
||||||
return
|
return
|
||||||
@@ -407,213 +310,67 @@ func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
|
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteNewsletterSubscriber deletes a newsletter subscriber (admin only)
|
// DELETE /api/v1/admin/newsletter/subscribers/:id
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
|
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
|
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"}); return }
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"})
|
if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}); return }
|
||||||
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"})
|
c.JSON(http.StatusOK, gin.H{"message": "Subscriber deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNewsletterSubscriberStatus toggles a subscriber's active status (admin only)
|
// PATCH /api/v1/admin/newsletter/subscribers/:id/status
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
|
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
var input struct { IsActive bool `json:"is_active" binding:"required"` }
|
||||||
return
|
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); 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
|
var sub models.NewsletterSubscription
|
||||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
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"}) }
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sub.IsActive = input.IsActive
|
sub.IsActive = input.IsActive
|
||||||
sub.UpdatedAt = time.Now()
|
sub.UpdatedAt = time.Now()
|
||||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"}); return }
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, sub)
|
c.JSON(http.StatusOK, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNewsletterSubscriberPreferences updates subscriber preferences (admin only)
|
// PATCH /api/v1/admin/newsletter/subscribers/:id/preferences
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
|
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.Atoi(c.Param("id"))
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var prefs map[string]bool
|
var prefs map[string]bool
|
||||||
if err := c.ShouldBindJSON(&prefs); err != nil {
|
if err := c.ShouldBindJSON(&prefs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"}); return }
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var sub models.NewsletterSubscription
|
var sub models.NewsletterSubscription
|
||||||
if err := cc.DB.First(&sub, id).Error; err != nil {
|
if err := cc.DB.First(&sub, id).Error; err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
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"}) }
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert map[string]bool to datatypes.JSONMap
|
|
||||||
jm := datatypes.JSONMap{}
|
jm := datatypes.JSONMap{}
|
||||||
for k, v := range prefs {
|
for k, v := range prefs { jm[k] = v }
|
||||||
jm[k] = v
|
|
||||||
}
|
|
||||||
sub.Preferences = jm
|
sub.Preferences = jm
|
||||||
sub.UpdatedAt = time.Now()
|
sub.UpdatedAt = time.Now()
|
||||||
if err := cc.DB.Save(&sub).Error; err != nil {
|
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}); return }
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, sub)
|
c.JSON(http.StatusOK, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendNewsletterTest sends a test newsletter email to a single recipient (admin only)
|
// POST /api/v1/admin/newsletter/test
|
||||||
// @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]
|
|
||||||
func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
|
||||||
if c.GetString("userRole") != "admin" {
|
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
var input struct { Email string `json:"email"`; Emails []string `json:"emails"`; Type string `json:"type"` }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var input struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Emails []string `json:"emails"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
_ = c.ShouldBindJSON(&input)
|
_ = c.ShouldBindJSON(&input)
|
||||||
|
|
||||||
// Resolve recipients (emails > email > admin)
|
|
||||||
recipients := make([]string, 0)
|
recipients := make([]string, 0)
|
||||||
if len(input.Emails) > 0 {
|
if len(input.Emails) > 0 { for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } } }
|
||||||
for _, e := range input.Emails {
|
if len(recipients) == 0 { if v := strings.TrimSpace(input.Email); v != "" { recipients = append(recipients, v) } }
|
||||||
if v := strings.TrimSpace(e); v != "" {
|
if len(recipients) == 0 { if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" { recipients = append(recipients, 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(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)
|
logger.Info("[SendNewsletterTest] type=%s recipients=%v", t, recipients)
|
||||||
|
|
||||||
switch t {
|
switch t {
|
||||||
case "newsletter":
|
case "newsletter":
|
||||||
testHTML := `<p>Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.</p>`
|
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))
|
logger.Debug("[SendNewsletterTest] invoking emailService.SendNewsletter for %d recipient(s)", len(recipients))
|
||||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
if err := cc.emailService.SendNewsletter(data); err != nil {
|
||||||
logger.Error("Failed to send test newsletter: %v", err)
|
logger.Error("Failed to send test newsletter: %v", err)
|
||||||
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
|
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"}) }
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
case "welcome":
|
case "welcome":
|
||||||
for _, r := range recipients {
|
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: r}) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "welcome_back":
|
case "welcome_back":
|
||||||
for _, r := range recipients {
|
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "setup":
|
case "setup":
|
||||||
// Test subscription setup email with token
|
|
||||||
for _, r := range recipients {
|
for _, r := range recipients {
|
||||||
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
|
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
|
||||||
if tErr != nil {
|
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
|
||||||
logger.Error("Failed to generate token for setup test: %v", tErr)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
|
||||||
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
|
||||||
setupEmail := &email.EmailData{
|
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
|
||||||
Subject: "Test: Nastavte svůj newsletter",
|
_ = cc.emailService.SendEmail(setupEmail)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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":
|
case "blogs", "events", "matches", "scores", "weekly":
|
||||||
prefs := services.NewsletterPrefs{
|
prefs := services.NewsletterPrefs{Email: recipients[0], ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
|
||||||
Email: recipients[0],
|
if t == "weekly" { prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}; prefs.Frequency = "weekly" } else { prefs.ContentTypes = []string{t} }
|
||||||
ContentTypes: []string{},
|
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
|
||||||
Competitions: []string{},
|
if subj == "" { subj = "Test digest" }
|
||||||
Frequency: "daily",
|
if html == "" { html = "<p>Momentálně žádný obsah pro zvolený typ.</p>" }
|
||||||
}
|
|
||||||
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>"
|
|
||||||
}
|
|
||||||
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
|
||||||
if err := cc.emailService.SendNewsletter(data); err != nil {
|
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 }
|
||||||
logger.Error("Failed to send digest test: %v", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
|
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
|
// Create new subscription. Default: enable everything if preferences omitted
|
||||||
prefs := input.Preferences
|
prefs := input.Preferences
|
||||||
if prefs == nil {
|
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
|
// convert to datatypes.JSONMap
|
||||||
jm := datatypes.JSONMap{}
|
jm := datatypes.JSONMap{}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"fotbal-club/internal/config"
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
"fotbal-club/internal/services"
|
"fotbal-club/internal/services"
|
||||||
"fotbal-club/pkg/logger"
|
"fotbal-club/pkg/logger"
|
||||||
@@ -233,7 +234,11 @@ func (fc *FilesController) GetFileUsages(c *gin.Context) {
|
|||||||
|
|
||||||
// ScanAndSyncFiles scans the uploads directory and syncs with database
|
// ScanAndSyncFiles scans the uploads directory and syncs with database
|
||||||
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
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
|
var filesInDB []models.UploadedFile
|
||||||
if err := fc.DB.Find(&filesInDB).Error; err != nil {
|
if err := fc.DB.Find(&filesInDB).Error; err != nil {
|
||||||
@@ -287,7 +292,13 @@ func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
|
|||||||
if !existsOriginal && !existsNormalized {
|
if !existsOriginal && !existsNormalized {
|
||||||
// File exists on disk but not in database - add it
|
// File exists on disk but not in database - add it
|
||||||
mimeType := detectMimeType(path)
|
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{
|
newFile := models.UploadedFile{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fotbal-club/internal/config"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ImageProcessingController struct{}
|
type ImageProcessingController struct{}
|
||||||
@@ -143,9 +146,20 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
|||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"url": outputPath,
|
"url": absolute,
|
||||||
"format": format,
|
"format": format,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,8 +168,14 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
|
|||||||
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
|
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
|
||||||
// Check if it's a local file path
|
// Check if it's a local file path
|
||||||
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
|
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
|
||||||
// Local file
|
// Local file under configured uploads dir
|
||||||
localPath := filepath.Join(".", imageURL)
|
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)
|
file, err := os.Open(localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to open local file: %w", err)
|
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
|
return img, format, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP URL
|
// HTTP URL - use custom client and headers, some CDNs block default Go UA
|
||||||
resp, err := http.Get(imageURL)
|
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 {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
|
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
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
|
// saveProcessedImage saves the processed image and returns the path
|
||||||
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
|
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
|
||||||
// Create uploads directory if it doesn't exist
|
// Create uploads directory if it doesn't exist (use configured UploadDir)
|
||||||
uploadsDir := "./uploads"
|
uploadsDir := config.AppConfig.UploadDir
|
||||||
|
if strings.TrimSpace(uploadsDir) == "" {
|
||||||
|
uploadsDir = "./uploads"
|
||||||
|
}
|
||||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||||
return "", fmt.Errorf("failed to create uploads directory: %w", err)
|
return "", fmt.Errorf("failed to create uploads directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -297,8 +333,20 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
|
|||||||
return
|
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{
|
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: "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: "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: "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
|
// 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
|
// Combine all items
|
||||||
|
|||||||
@@ -15,9 +15,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"fotbal-club/internal/config"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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.
|
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
||||||
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
||||||
img, _, err := image.Decode(bytes.NewReader(data))
|
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
|
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
||||||
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
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 {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusOK, []string{})
|
ctx.JSON(http.StatusOK, []string{})
|
||||||
return
|
return
|
||||||
@@ -125,7 +136,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755)
|
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||||
|
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||||
|
|
||||||
saved := 0
|
saved := 0
|
||||||
if ctx.Request.MultipartForm != nil {
|
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()) }
|
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
||||||
base := name
|
base := name
|
||||||
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
||||||
outName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), base+".png")
|
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||||
outPath := filepath.Join("uploads", "sponsors", outName)
|
outPath := filepath.Join(sponsorDir, outName)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if _, err := io.Copy(&buf, src); err == nil {
|
if _, err := io.Copy(&buf, src); err == nil {
|
||||||
@@ -154,8 +166,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
|||||||
saved++
|
saved++
|
||||||
} else {
|
} else {
|
||||||
// Fallback: write original bytes with original extension
|
// Fallback: write original bytes with original extension
|
||||||
rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name)
|
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||||
rawPath := filepath.Join("uploads", "sponsors", rawName)
|
rawPath := filepath.Join(sponsorDir, rawName)
|
||||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||||
saved++
|
saved++
|
||||||
}
|
}
|
||||||
@@ -173,7 +185,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := filepath.Join("uploads", "sponsors", name)
|
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
@@ -187,7 +199,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
|||||||
|
|
||||||
// GetQR returns the current QR image URL if present
|
// GetQR returns the current QR image URL if present
|
||||||
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
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 {
|
if _, err := os.Stat(path); err == nil {
|
||||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||||
return
|
return
|
||||||
@@ -203,8 +215,9 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
_ = os.MkdirAll("uploads", 0o755)
|
dir := uploadsBaseDir()
|
||||||
out, err := os.Create(filepath.Join("uploads", "qr.png"))
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
|
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||||
return
|
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{
|
subscription := NewsletterSubscription{
|
||||||
Email: email,
|
Email: email,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
|
Preferences: datatypes.JSONMap{
|
||||||
|
"blogs": true,
|
||||||
|
"matches": true,
|
||||||
|
"events": true,
|
||||||
|
"scores": true,
|
||||||
|
"weekly": true,
|
||||||
|
},
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: 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",
|
"prefetch": "/admin/prefetch",
|
||||||
"users": "/admin/uzivatele",
|
"users": "/admin/uzivatele",
|
||||||
"settings": "/admin/nastaveni",
|
"settings": "/admin/nastaveni",
|
||||||
|
"shortlinks": "/admin/shortlinks",
|
||||||
"files": "/admin/soubory",
|
"files": "/admin/soubory",
|
||||||
"docs": "/admin/docs",
|
"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{}
|
imageProcessingController := &controllers.ImageProcessingController{}
|
||||||
articleController := controllers.NewArticleController(db)
|
articleController := controllers.NewArticleController(db)
|
||||||
myuibrixController := &controllers.MyUIbrixController{DB: db}
|
myuibrixController := &controllers.MyUIbrixController{DB: db}
|
||||||
|
shortLinkController := controllers.NewShortLinkController(db)
|
||||||
|
|
||||||
// API v1 group
|
// API v1 group
|
||||||
{
|
{
|
||||||
@@ -151,6 +152,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
|
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 - accessible by editors and admins)
|
||||||
articles := protected.Group("/articles")
|
articles := protected.Group("/articles")
|
||||||
articles.Use(middleware.RoleAuth("editor"))
|
articles.Use(middleware.RoleAuth("editor"))
|
||||||
@@ -284,6 +293,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
|
|||||||
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
|
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
|
||||||
// New: send prebuilt digest by type and toggle automation
|
// New: send prebuilt digest by type and toggle automation
|
||||||
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
|
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
|
||||||
|
admin.POST("/newsletter/smtp-test", contactController.AdminSmtpTest)
|
||||||
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
|
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
|
||||||
// Removed deprecated SMTP test route (use /newsletter/test instead)
|
// Removed deprecated SMTP test route (use /newsletter/test instead)
|
||||||
admin.GET("/newsletter/status", contactController.GetNewsletterStatus)
|
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("/preview", myuibrixController.GetElementPreview)
|
||||||
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
|
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)
|
// SetupRootRoutes registers endpoints at the root (no /api prefix)
|
||||||
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
|
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
|
||||||
seoController := controllers.NewSEOController(db)
|
seoController := controllers.NewSEOController(db)
|
||||||
|
shortLinkController := controllers.NewShortLinkController(db)
|
||||||
r.GET("/robots.txt", seoController.GetRobotsTXT)
|
r.GET("/robots.txt", seoController.GetRobotsTXT)
|
||||||
r.GET("/sitemap.xml", seoController.GetSitemapXML)
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -475,46 +474,62 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML builders
|
|
||||||
|
|
||||||
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
|
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)
|
||||||
// Build tracked link
|
if desc == "" {
|
||||||
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
|
plain := utils.SanitizeString(article.Content)
|
||||||
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
|
if len(plain) > 260 {
|
||||||
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
|
cut := 240
|
||||||
url.QueryEscape(articleURL),
|
if cut < len(plain) {
|
||||||
url.QueryEscape(token))
|
for cut < len(plain) && plain[cut] != ' ' {
|
||||||
|
cut++
|
||||||
html := fmt.Sprintf(`
|
}
|
||||||
|
}
|
||||||
|
if cut > len(plain) { cut = len(plain) }
|
||||||
|
plain = strings.TrimSpace(plain[:cut]) + "…"
|
||||||
|
}
|
||||||
|
desc = plain
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;">
|
<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>
|
<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;">
|
||||||
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
|
%s
|
||||||
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
|
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size:22px;">%s</h3>
|
||||||
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
|
%s
|
||||||
<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>
|
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 12px 0;">%s</p>
|
||||||
</div>
|
<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>
|
||||||
<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>
|
|
||||||
</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 {
|
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
|
||||||
var intro string
|
var intro string
|
||||||
if notifType == "reminder_48h" {
|
if notifType == "reminder_48h" {
|
||||||
intro = "Připomínáme nadcházející zápas:"
|
intro = "Připomínáme nadcházející zápas:"
|
||||||
} else {
|
} else {
|
||||||
intro = "Zápas je dnes!"
|
intro = "Zápas je dnes!"
|
||||||
}
|
}
|
||||||
|
|
||||||
html := fmt.Sprintf(`
|
html := fmt.Sprintf(`
|
||||||
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
|
||||||
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
|
<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"))
|
art := readJSON(filepath.Join(cacheDir, "articles.json"))
|
||||||
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
|
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
|
||||||
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.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)
|
sections := make([]string, 0, 4)
|
||||||
|
|
||||||
@@ -73,10 +78,10 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(sections) == 0 {
|
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")
|
html = strings.Join(sections, "\n\n")
|
||||||
return subject, html
|
return subject, html
|
||||||
}
|
}
|
||||||
@@ -125,12 +130,22 @@ func pickUpcomingEvents(v any, n int) []Event {
|
|||||||
Time: str(m["time"], ""),
|
Time: str(m["time"], ""),
|
||||||
Url: str(m["url"], ""),
|
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)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
return out
|
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 {
|
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
||||||
compSet := make(map[string]bool)
|
compSet := make(map[string]bool)
|
||||||
@@ -142,7 +157,9 @@ func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
|
|||||||
ts := parseDateTimeISO(m.Date, m.Time)
|
ts := parseDateTimeISO(m.Date, m.Time)
|
||||||
if ts.IsZero() || ts.Before(now) { continue }
|
if ts.IsZero() || ts.Before(now) { continue }
|
||||||
if len(compSet) > 0 {
|
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)
|
out = append(out, m)
|
||||||
if len(out) >= n { break }
|
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
|
// treat as result if score like "2:1" exists
|
||||||
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
|
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
|
||||||
if len(compSet) > 0 {
|
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)
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
@@ -188,19 +207,20 @@ func facrAllMatches(v any) []Match {
|
|||||||
for _, c := range asList(comps) {
|
for _, c := range asList(comps) {
|
||||||
cm := asMap(c)
|
cm := asMap(c)
|
||||||
compName := str(cm["name"], str(cm["code"], ""))
|
compName := str(cm["name"], str(cm["code"], ""))
|
||||||
|
compCode := str(cm["code"], "")
|
||||||
for _, mm := range asList(cm["matches"]) {
|
for _, mm := range asList(cm["matches"]) {
|
||||||
out = append(out, toMatch(asMap(mm), compName))
|
out = append(out, toMatch(asMap(mm), compName, compCode))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// flat matches fallback
|
// flat matches fallback
|
||||||
for _, mm := range asList(m["matches"]) {
|
for _, mm := range asList(m["matches"]) {
|
||||||
out = append(out, toMatch(asMap(mm), ""))
|
out = append(out, toMatch(asMap(mm), "", ""))
|
||||||
}
|
}
|
||||||
return out
|
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"], "")
|
dt := str(m["date_time"], "")
|
||||||
var date, tm string
|
var date, tm string
|
||||||
if dt != "" && strings.Contains(dt, " ") {
|
if dt != "" && strings.Contains(dt, " ") {
|
||||||
@@ -215,7 +235,8 @@ func toMatch(m map[string]any, comp string) Match {
|
|||||||
Away: str(m["away"], ""),
|
Away: str(m["away"], ""),
|
||||||
Date: date,
|
Date: date,
|
||||||
Time: tm,
|
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"], "#")),
|
Link: str(m["facr_link"], str(m["report_url"], "#")),
|
||||||
Score: str(m["score"], ""),
|
Score: str(m["score"], ""),
|
||||||
}
|
}
|
||||||
@@ -224,10 +245,19 @@ func toMatch(m map[string]any, comp string) Match {
|
|||||||
func parseDateTimeISO(d, t string) time.Time {
|
func parseDateTimeISO(d, t string) time.Time {
|
||||||
if d == "" { return time.Time{} }
|
if d == "" { return time.Time{} }
|
||||||
if t == "" { t = "00:00" }
|
if t == "" { t = "00:00" }
|
||||||
layout := "2006-01-02T15:04:05"
|
if strings.Contains(d, ".") {
|
||||||
// try shorter HH:MM format
|
if len(t) == 5 {
|
||||||
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
|
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
|
||||||
return parseTime(layout, d+"T"+t)
|
}
|
||||||
|
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 {
|
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