This commit is contained in:
Tomas Dvorak committed 2025-10-28 22:38:27 +01:00
1 parent 3d621e2187
commit 823fabee02
106 files changed
+8982 -3901

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
REACT_APP_API_URL=/api/v1
REACT_APP_ASSET_BASE_URL=http://127.0.0.1:8080
REACT_APP_NAME=Fotbal Club Manager
REACT_APP_ENV=development
+24
View File
@@ -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"
]
}
+1
View File
@@ -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>
@@ -0,0 +1,7 @@
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/.ql-container{box-sizing:border-box;font-family:Helvetica,Arial,sans-serif;font-size:13px;height:100%;margin:0;position:relative}.ql-container.ql-disabled .ql-tooltip{visibility:hidden}.ql-container.ql-disabled .ql-editor ul[data-checked]>li:before{pointer-events:none}.ql-clipboard{height:1px;left:-100000px;overflow-y:hidden;position:absolute;top:50%}.ql-clipboard p{margin:0;padding:0}.ql-editor{word-wrap:break-word;box-sizing:border-box;height:100%;line-height:1.42;outline:none;overflow-y:auto;padding:12px 15px;tab-size:4;-moz-tab-size:4;text-align:left;white-space:pre-wrap}.ql-editor>*{cursor:text}.ql-editor blockquote,.ql-editor h1,.ql-editor h2,.ql-editor h3,.ql-editor h4,.ql-editor h5,.ql-editor h6,.ql-editor ol,.ql-editor p,.ql-editor pre,.ql-editor ul{counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;margin:0;padding:0}.ql-editor ol,.ql-editor ul{padding-left:1.5em}.ql-editor ol>li,.ql-editor ul>li{list-style-type:none}.ql-editor ul>li:before{content:"\2022"}.ql-editor ul[data-checked=false],.ql-editor ul[data-checked=true]{pointer-events:none}.ql-editor ul[data-checked=false]>li *,.ql-editor ul[data-checked=true]>li *{pointer-events:all}.ql-editor ul[data-checked=false]>li:before,.ql-editor ul[data-checked=true]>li:before{color:#777;cursor:pointer;pointer-events:all}.ql-editor ul[data-checked=true]>li:before{content:"\2611"}.ql-editor ul[data-checked=false]>li:before{content:"\2610"}.ql-editor li:before{display:inline-block;white-space:nowrap;width:1.2em}.ql-editor li:not(.ql-direction-rtl):before{margin-left:-1.5em;margin-right:.3em;text-align:right}.ql-editor li.ql-direction-rtl:before{margin-left:.3em;margin-right:-1.5em}.ql-editor ol li:not(.ql-direction-rtl),.ql-editor ul li:not(.ql-direction-rtl){padding-left:1.5em}.ql-editor ol li.ql-direction-rtl,.ql-editor ul li.ql-direction-rtl{padding-right:1.5em}.ql-editor ol li{counter-increment:list-0;counter-reset:list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li:before{content:counter(list-0,decimal) ". "}.ql-editor ol li.ql-indent-1{counter-increment:list-1}.ql-editor ol li.ql-indent-1:before{content:counter(list-1,lower-alpha) ". "}.ql-editor ol li.ql-indent-1{counter-reset:list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-2{counter-increment:list-2}.ql-editor ol li.ql-indent-2:before{content:counter(list-2,lower-roman) ". "}.ql-editor ol li.ql-indent-2{counter-reset:list-3 list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-3{counter-increment:list-3}.ql-editor ol li.ql-indent-3:before{content:counter(list-3,decimal) ". "}.ql-editor ol li.ql-indent-3{counter-reset:list-4 list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-4{counter-increment:list-4}.ql-editor ol li.ql-indent-4:before{content:counter(list-4,lower-alpha) ". "}.ql-editor ol li.ql-indent-4{counter-reset:list-5 list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-5{counter-increment:list-5}.ql-editor ol li.ql-indent-5:before{content:counter(list-5,lower-roman) ". "}.ql-editor ol li.ql-indent-5{counter-reset:list-6 list-7 list-8 list-9}.ql-editor ol li.ql-indent-6{counter-increment:list-6}.ql-editor ol li.ql-indent-6:before{content:counter(list-6,decimal) ". "}.ql-editor ol li.ql-indent-6{counter-reset:list-7 list-8 list-9}.ql-editor ol li.ql-indent-7{counter-increment:list-7}.ql-editor ol li.ql-indent-7:before{content:counter(list-7,lower-alpha) ". "}.ql-editor ol li.ql-indent-7{counter-reset:list-8 list-9}.ql-editor ol li.ql-indent-8{counter-increment:list-8}.ql-editor ol li.ql-indent-8:before{content:counter(list-8,lower-roman) ". "}.ql-editor ol li.ql-indent-8{counter-reset:list-9}.ql-editor ol li.ql-indent-9{counter-increment:list-9}.ql-editor ol li.ql-indent-9:before{content:counter(list-9,decimal) ". "}.ql-editor .ql-indent-1:not(.ql-direction-rtl){padding-left:3em}.ql-editor li.ql-indent-1:not(.ql-direction-rtl){padding-left:4.5em}.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:3em}.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right{padding-right:4.5em}.ql-editor .ql-indent-2:not(.ql-direction-rtl){padding-left:6em}.ql-editor li.ql-indent-2:not(.ql-direction-rtl){padding-left:7.5em}.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:6em}.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right{padding-right:7.5em}.ql-editor .ql-indent-3:not(.ql-direction-rtl){padding-left:9em}.ql-editor li.ql-indent-3:not(.ql-direction-rtl){padding-left:10.5em}.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:9em}.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right{padding-right:10.5em}.ql-editor .ql-indent-4:not(.ql-direction-rtl){padding-left:12em}.ql-editor li.ql-indent-4:not(.ql-direction-rtl){padding-left:13.5em}.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:12em}.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right{padding-right:13.5em}.ql-editor .ql-indent-5:not(.ql-direcLine truncated
/*# sourceMappingURL=290.1124e12e.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/290.1124e12e.css","mappings":"AAAA;;;;;EAKE,CACF,cACE,qBAAsB,CACtB,sCAAyC,CACzC,cAAe,CACf,WAAY,CACZ,QAAW,CACX,iBACF,CACA,sCACE,iBACF,CACA,gEACE,mBACF,CACA,cAEE,UAAW,CADX,cAAe,CAEf,iBAAkB,CAClB,iBAAkB,CAClB,OACF,CACA,gBACE,QAAS,CACT,SACF,CACA,WAWE,oBAAqB,CAVrB,qBAAsB,CAEtB,WAAY,CADZ,gBAAiB,CAEjB,YAAa,CACb,eAAgB,CAChB,iBAAkB,CAClB,UAAW,CACX,eAAgB,CAChB,eAAgB,CAChB,oBAEF,CACA,aACE,WACF,CACA,kKAaE,4EAA6E,CAF7E,QAAS,CACT,SAEF,CACA,4BAEE,kBACF,CACA,kCAEE,oBACF,CACA,wBACE,eACF,CACA,mEAEE,mBACF,CACA,6EAEE,kBACF,CACA,uFAEE,UAAW,CACX,cAAe,CACf,kBACF,CACA,2CACE,eACF,CACA,4CACE,eACF,CACA,qBACE,oBAAqB,CACrB,kBAAmB,CACnB,WACF,CACA,4CACE,kBAAmB,CACnB,iBAAmB,CACnB,gBACF,CACA,sCACE,gBAAkB,CAClB,mBACF,CACA,gFAEE,kBACF,CACA,oEAEE,mBACF,CACA,iBAEE,wBAAyB,CADzB,4EAEF,CACA,wBACE,oCACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,qEACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,8DACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,6BACE,uDACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,gDACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,yCACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,6BACE,kCACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,2BACF,CACA,6BACE,wBACF,CACA,oCACE,wCACF,CACA,6BACE,oBACF,CACA,6BACE,wBACF,CACA,oCACE,oCACF,CACA,+CACE,gBACF,CACA,iDACE,kBACF,CACA,wDACE,iBACF,CACA,0DACE,mBACF,CACA,+CACE,gBACF,CACA,iDACE,kBACF,CACA,wDACE,iBACF,CACA,0DACE,mBACF,CACA,+CACE,gBACF,CACA,iDACE,mBACF,CACA,wDACE,iBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,+CACE,iBACF,CACA,iDACE,mBACF,CACA,wDACE,kBACF,CACA,0DACE,oBACF,CACA,qBACE,aAAc,CACd,cACF,CACA,qCACE,aACF,CACA,oCACE,iBACF,CACA,wBACE,qBACF,CACA,sBACE,wBACF,CACA,yBACE,qBACF,CACA,yBACE,qBACF,CACA,wBACE,wBACF,CACA,uBACE,qBACF,CACA,yBACE,qBACF,CACA,2BACE,UACF,CACA,yBACE,aACF,CACA,4BACE,UACF,CACA,4BACE,UACF,CACA,2BACE,aACF,CACA,0BACE,UACF,CACA,4BACE,UACF,CACA,0BACE,yCACF,CACA,8BACE,wCACF,CACA,0BACE,eACF,CACA,0BACE,eACF,CACA,yBACE,eACF,CACA,6BACE,aAAc,CACd,kBACF,CACA,4BACE,iBACF,CACA,6BACE,kBACF,CACA,2BACE,gBACF,CACA,2BACE,WAAsB,CACtB,8BAA+B,CAC/B,iBAAkB,CAClB,SAAU,CACV,mBAAoB,CACpB,iBAAkB,CAClB,UACF,CACA,qDAEE,UAAW,CACX,UAAW,CACX,aACF,CACA,uDAEE,eAAgB,CAChB,WAAY,CACZ,cAAe,CACf,oBAAqB,CACrB,UAAW,CACX,WAAY,CACZ,eAAgB,CAChB,UACF,CACA,+DAEE,UAAW,CACX,WACF,CACA,iFAEE,YACF,CACA,6FAEE,YACF,CACA,6jBAcE,UACF,CACA,kgDA4BE,SACF,CACA,kgDA4BE,WACF,CACA,wBACE,mGAEE,UACF,CACA,8PAIE,SACF,CACA,8PAIE,WACF,CACF,CAIA,oBACE,qBACF,CACA,oBACE,YACF,CACA,6CAEE,iBACF,CACA,qBACE,iBAAkB,CAClB,0BACF,CACA,uBACE,cAAe,CACf,oBACF,CACA,6BACE,2BACF,CACA,qBACE,oBAAqB,CACrB,qBACF,CACA,2BACE,UAAW,CACX,UAAW,CACX,aACF,CACA,oBACE,SAAU,CACV,WAAY,CACZ,oBAAqB,CACrB,qBAAsB,CACtB,cACF,CACA,0BACE,SAAU,CACV,WAAY,CACZ,oBAAqB,CACrB,cACF,CACA,8CAEE,SACF,CACA,mBACE,SACF,CACA,kBACE,iBACF,CACA,8CAEE,cACF,CACA,yBACE,UACF,CACA,sCACE,YACF,CACA,gDACE,cACF,CACA,iDACE,YACF,CACA,uBACE,aACF,CACA,uBACE,eACF,CACA,uBACE,gBACF,CACA,uBACE,aACF,CACA,uBACE,eACF,CACA,uBACE,eACF,CACA,sBACE,yBACF,CACA,+BACE,0BAA2B,CAC3B,iBAAkB,CAClB,cAAe,CACf,iBACF,CACA,iDAEE,wBAAyB,CACzB,iBACF,CACA,wBAEE,iBAAkB,CAClB,cAAe,CACf,gBAAiB,CAHjB,oBAIF,CACA,yBACE,aAAc,CACd,eACF,CACA,kCACE,wBAAyB,CACzB,aAAc,CACd,gBACF,CACA,wBACE,cACF,CACA,oBACE,UAAW,CACX,oBAAqB,CACrB,UAAW,CACX,cAAe,CACf,eAAgB,CAChB,WAAY,CACZ,iBAAkB,CAClB,qBACF,CACA,0BACE,cAAe,CACf,oBAAqB,CACrB,WAAY,CACZ,gBAAiB,CACjB,iBAAkB,CAClB,iBAAkB,CAClB,UACF,CACA,iCACE,oBAAqB,CACrB,gBACF,CACA,4BACE,qBAAsB,CACtB,YAAa,CACb,cAAe,CACf,eAAgB,CAChB,iBAAkB,CAClB,kBACF,CACA,4CACE,cAAe,CACf,aAAc,CACd,kBAAmB,CACnB,eACF,CACA,iDACE,UAAW,CACX,SACF,CACA,0DACE,SACF,CACA,4DACE,WACF,CACA,mDACE,aAAc,CACd,eAAgB,CAChB,QAAS,CACT,SACF,CACA,mDAEE,UACF,CACA,qFAEE,eACF,CACA,6FAEE,SACF,CACA,4CACE,aACF,CACA,yCACE,WAAY,CAEZ,eAAgB,CADhB,UAEF,CACA,6CACE,eAAgB,CAChB,WACF,CACA,0CACE,sBAA6B,CAC7B,UAAW,CACX,WAAY,CACZ,UAAW,CACX,SAAY,CACZ,UACF,CACA,mEAEE,eAAgB,CADhB,iBAAkB,CAElB,OAAQ,CACR,OAAQ,CACR,UACF,CACA,+fAME,wBACF,CACA,8BACE,UACF,CACA,2GAEE,gBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,2IAEE,mBACF,CACA,qEACE,aACF,CACA,qEACE,eACF,CACA,qEACE,gBACF,CACA,qEACE,aACF,CACA,qEACE,eACF,CACA,qEACE,eACF,CACA,4BACE,WACF,CACA,uGAEE,oBACF,CACA,2IAEE,eACF,CACA,mJAEE,mBACF,CACA,qEACE,yCACF,CACA,yEACE,wCACF,CACA,4BACE,UACF,CACA,uGAEE,gBACF,CACA,2IAEE,eACF,CACA,2IAEE,eACF,CACA,yIAEE,cACF,CACA,qEACE,cACF,CACA,qEACE,cACF,CACA,oEACE,cACF,CACA,wDACE,qBACF,CACA,mDACE,qBACF,CACA,oBACE,qBAAsB,CACtB,qBAAsB,CACtB,qDAA+D,CAC/D,WACF,CACA,gCACE,iBACF,CACA,qCACE,sBACF,CACA,uCACE,sBAA6B,CAC7B,0BACF,CAIA,0HACE,iBACF,CACA,4HAEE,iBACF,CACA,0CACE,YACF,CACA,qBACE,qBAAsB,CACtB,qBAAsB,CACtB,uBAA4B,CAC5B,UAAW,CACX,gBAAiB,CACjB,kBACF,CACA,4BACE,oBAAqB,CACrB,gBAAiB,CACjB,gBACF,CACA,sCAEE,qBAAsB,CADtB,YAAa,CAELine truncated
@@ -0,0 +1,2 @@
body,html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif));margin:0;padding:0}.chakra-ui-dark #root,.chakra-ui-dark body,html.chakra-ui-dark{background-color:#0f1115!important;color:#e8eaf0!important}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}*,body,html{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))}h1,h2,h3,h4,h5,h6{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif!important;font-family:var(--font-heading,var(--chakra-fonts-heading,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))!important}button,input,select,textarea{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-family:var(--font-body,var(--chakra-fonts-body,-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto",sans-serif))}@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,:after,:before{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-duration:.01ms!important}}:focus-visible{border-radius:4px;outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:2px}:focus:not(:focus-visible){outline:none}a:focus-visible{outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:3px}button:focus-visible{outline:3px solid #c53030;outline:3px solid var(--primary,#c53030);outline-offset:2px}input:focus-visible,select:focus-visible,textarea:focus-visible{border-color:#c53030;border-color:var(--primary,#c53030);outline:2px solid #c53030;outline:2px solid var(--primary,#c53030);outline-offset:0}::selection{background:#c53030;background:var(--primary,#c53030);color:#fff;text-shadow:none}::-moz-selection{background:#c53030;background:var(--primary,#c53030);color:#fff;text-shadow:none}::-webkit-scrollbar-track{background:#0000000d}::-webkit-scrollbar-thumb{background:#0003;background-clip:padding-box;border:2px solid #0000}::-webkit-scrollbar-thumb:hover{background:#0000004d;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:#0006;background-clip:padding-box}*{scrollbar-color:#0003 #0000000d}.chakra-ui-dark ::-webkit-scrollbar-track{background:#ffffff0d}.chakra-ui-dark ::-webkit-scrollbar-thumb{background:#fff3}.chakra-ui-dark ::-webkit-scrollbar-thumb:hover{background:#ffffff4d}.chakra-ui-dark *{scrollbar-color:#fff3 #ffffff0d}@keyframes globalFadeIn{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes globalFadeInUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes globalSlideInLeft{0%{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes globalSlideInRight{0%{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}.fade-in{animation:globalFadeIn .6s ease-out forwards}.fade-in-up{animation:globalFadeInUp .7s cubic-bezier(.4,0,.2,1) forwards}.slide-in-left{animation:globalSlideInLeft .6s ease-out forwards}.slide-in-right{animation:globalSlideInRight .6s ease-out forwards}.fade-in-visible{animation:globalFadeIn .6s ease-out forwards}.stagger-1{animation-delay:.1s}.stagger-2{animation-delay:.2s}.stagger-3{animation-delay:.3s}.stagger-4{animation-delay:.4s}.stagger-5{animation-delay:.5s}.skeleton{background:linear-gradient(90deg,#f0f0f0,#e0e0e0 50%,#f0f0f0);border-radius:4px}.chakra-ui-dark .skeleton{background:linear-gradient(90deg,#ffffff0d,#ffffff1a 50%,#ffffff0d)}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}.pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.spin{animation:spin 1s linear infinite}.back-to-top{align-items:center;background:#c53030;background:var(--primary,#c53030);border:none;border-radius:50%;bottom:24px;box-shadow:0 4px 12px #00000026;color:#fff;cursor:pointer;display:flex;height:48px;justify-content:center;opacity:0;position:fixed;right:24px;transition:all .3s ease;visibility:hidden;width:48px;z-index:1000}.back-to-top.visible{opacity:1;visibility:visible}.back-to-top:hover{box-shadow:0 8px 24px #00000040;transform:translateY(-4px)}.back-to-top:active{transform:translateY(-2px)}.back-to-top:focus-visible{outline:3px solid #fff;outline-offset:2px}.skip-to-content{background:#c53030;background:var(--primary,#c53030);border-radius:0 0 4px 0;color:#fff;left:0;padding:8px 16px;position:absolute;text-decoration:none;top:-40px;transition:top .3s;z-index:10000}.skip-to-content:focus{outline:3px solid #fff;outline-offset:-3px;top:0}Line truncated
/*# sourceMappingURL=main.3ca1fc6e.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.3ca1fc6e.css","mappings":"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 truncated
+3
View File
@@ -0,0 +1,3 @@
/*! For license information please see 290.0640644c.js.LICENSE.txt */
(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[290],{14:t=>{t.exports=function(){return!1}},45:(t,e,n)=>{"use strict";n.d(e,{A:()=>o});var r=n(8587);function o(t,e){if(null==t)return{};var n,o,i=(0,r.A)(t,e);if(Object.getOwnPropertySymbols){var a=Object.getOwnPropertySymbols(t);for(o=0;o<a.length;o++)n=a[o],-1===e.indexOf(n)&&{}.propertyIsEnumerable.call(t,n)&&(i[n]=t[n])}return i}},66:(t,e,n)=>{var r=n(7937)(n(6552),"DataView");t.exports=r},67:(t,e,n)=>{"use strict";n.d(e,{A:()=>r});const r=(0,n(7784).A)("Image",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["circle",{cx:"9",cy:"9",r:"2",key:"af1f0g"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21",key:"1xmnt7"}]])},86:(t,e,n)=>{"use strict";n.d(e,{A:()=>r});const r=(0,n(7784).A)("RotateCw",[["path",{d:"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8",key:"1p45f6"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}]])},116:(t,e,n)=>{"use strict";n.d(e,{$:()=>c,s:()=>l});var r=n(9379),o=n(685),i=n(9939),a=n(7264),s=n(7988);class l extends a.k{constructor(t){super(),this.defaultOptions=t.defaultOptions,this.mutationId=t.mutationId,this.mutationCache=t.mutationCache,this.logger=t.logger||o.U,this.observers=[],this.state=t.state||c(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=(0,r.A)((0,r.A)({},this.defaultOptions),t),this.updateCacheTime(this.options.cacheTime)}get meta(){return this.options.meta}setState(t){this.dispatch({type:"setState",state:t})}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),this.mutationCache.notify({type:"observerAdded",mutation:this,observer:t}))}removeObserver(t){this.observers=this.observers.filter(e=>e!==t),this.scheduleGc(),this.mutationCache.notify({type:"observerRemoved",mutation:this,observer:t})}optionalRemove(){this.observers.length||("loading"===this.state.status?this.scheduleGc():this.mutationCache.remove(this))}continue(){var t,e;return null!=(t=null==(e=this.retryer)?void 0:e.continue())?t:this.execute()}async execute(){const t=()=>{var t;return this.retryer=(0,s.II)({fn:()=>this.options.mutationFn?this.options.mutationFn(this.state.variables):Promise.reject("No mutationFn found"),onFail:(t,e)=>{this.dispatch({type:"failed",failureCount:t,error:e})},onPause:()=>{this.dispatch({type:"pause"})},onContinue:()=>{this.dispatch({type:"continue"})},retry:null!=(t=this.options.retry)?t:0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode}),this.retryer.promise},e="loading"===this.state.status;try{var n,r,o,i,a,l,c,u;if(!e){var d,h,f,p;this.dispatch({type:"loading",variables:this.options.variables}),await(null==(d=(h=this.mutationCache.config).onMutate)?void 0:d.call(h,this.state.variables,this));const t=await(null==(f=(p=this.options).onMutate)?void 0:f.call(p,this.state.variables));t!==this.state.context&&this.dispatch({type:"loading",context:t,variables:this.state.variables})}const s=await t();return await(null==(n=(r=this.mutationCache.config).onSuccess)?void 0:n.call(r,s,this.state.variables,this.state.context,this)),await(null==(o=(i=this.options).onSuccess)?void 0:o.call(i,s,this.state.variables,this.state.context)),await(null==(a=(l=this.mutationCache.config).onSettled)?void 0:a.call(l,s,null,this.state.variables,this.state.context,this)),await(null==(c=(u=this.options).onSettled)?void 0:c.call(u,s,null,this.state.variables,this.state.context)),this.dispatch({type:"success",data:s}),s}catch(_){try{var m,g,v,y,b,x,w,k;throw await(null==(m=(g=this.mutationCache.config).onError)?void 0:m.call(g,_,this.state.variables,this.state.context,this)),await(null==(v=(y=this.options).onError)?void 0:v.call(y,_,this.state.variables,this.state.context)),await(null==(b=(x=this.mutationCache.config).onSettled)?void 0:b.call(x,void 0,_,this.state.variables,this.state.context,this)),await(null==(w=(k=this.options).onSettled)?void 0:w.call(k,void 0,_,this.state.variables,this.state.context)),_}finally{this.dispatch({type:"error",error:_})}}}dispatch(t){this.state=(e=>{switch(t.type){case"failed":return(0,r.A)((0,r.A)({},e),{},{failureCount:t.failureCount,failureReason:t.error});case"pause":return(0,r.A)((0,r.A)({},e),{},{isPaused:!0});case"continue":return(0,r.A)((0,r.A)({},e),{},{isPaused:!1});case"loading":return(0,r.A)((0,r.A)({},e),{},{context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:!(0,s.v_)(this.options.networkMode),status:"loading",variables:t.variables});case"success":return(0,r.A)((0,r.A)({},e),{},{data:t.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1});case"error":return(0,r.A)((0,r.A)({},e),{},{data:void 0,error:t.error,failureCount:e.failureCount+1,failureReason:t.error,isPaused:!1,status:"error"});case"setState":return(0,r.A)((0,r.A)({},e),t.state)}})(this.state),i.j.batch(()=>{this.observers.forEach(e=>{e.onMutationUpdate(t)}),this.mutationCache.notify({mutation:this,type:"updated",action:t})})}}function c(){return{context:void 0Line truncated
//# sourceMappingURL=290.0640644c.js.map
@@ -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.
*/
@@ -0,0 +1 @@
{"version":3,"file":"static/js/290.0640644c.js","mappings":"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 truncated
@@ -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
@@ -0,0 +1 @@
{"version":3,"file":"static/js/453.54292a4b.chunk.js","mappings":"qLAAA,IAAIA,EAAEC,EAAEC,EAAEC,EAAEC,EAAE,SAASJ,EAAEC,GAAG,MAAM,CAACI,KAAKL,EAAEM,WAAM,IAASL,GAAG,EAAEA,EAAEM,MAAM,EAAEC,QAAQ,GAAGC,GAAG,MAAMC,OAAOC,KAAKC,MAAM,KAAKF,OAAOG,KAAKC,MAAM,cAAcD,KAAKE,UAAU,MAAM,EAAEC,EAAE,SAAShB,EAAEC,GAAG,IAAI,GAAGgB,oBAAoBC,oBAAoBC,SAASnB,GAAG,CAAC,GAAG,gBAAgBA,KAAK,2BAA2BoB,MAAM,OAAO,IAAIlB,EAAE,IAAIe,oBAAqB,SAASjB,GAAG,OAAOA,EAAEqB,aAAaC,IAAIrB,EAAE,GAAI,OAAOC,EAAEqB,QAAQ,CAACC,KAAKxB,EAAEyB,UAAS,IAAKvB,CAAC,CAAC,CAAC,MAAMF,GAAG,CAAC,EAAE0B,EAAE,SAAS1B,EAAEC,GAAG,IAAIC,EAAE,SAASA,EAAEC,GAAG,aAAaA,EAAEqB,MAAM,WAAWG,SAASC,kBAAkB5B,EAAEG,GAAGF,IAAI4B,oBAAoB,mBAAmB3B,GAAE,GAAI2B,oBAAoB,WAAW3B,GAAE,IAAK,EAAE4B,iBAAiB,mBAAmB5B,GAAE,GAAI4B,iBAAiB,WAAW5B,GAAE,EAAG,EAAE6B,EAAE,SAAS/B,GAAG8B,iBAAiB,WAAY,SAAS7B,GAAGA,EAAE+B,WAAWhC,EAAEC,EAAE,GAAG,EAAG,EAAEgC,EAAE,SAASjC,EAAEC,EAAEC,GAAG,IAAIC,EAAE,OAAO,SAASC,GAAGH,EAAEK,OAAO,IAAIF,GAAGF,KAAKD,EAAEM,MAAMN,EAAEK,OAAOH,GAAG,IAAIF,EAAEM,YAAO,IAASJ,KAAKA,EAAEF,EAAEK,MAAMN,EAAEC,IAAI,CAAC,EAAEiC,GAAG,EAAEC,EAAE,WAAW,MAAM,WAAWR,SAASC,gBAAgB,EAAE,GAAG,EAAEQ,EAAE,WAAWV,EAAG,SAAS1B,GAAG,IAAIC,EAAED,EAAEqC,UAAUH,EAAEjC,CAAC,GAAG,EAAG,EAAEqC,EAAE,WAAW,OAAOJ,EAAE,IAAIA,EAAEC,IAAIC,IAAIL,EAAG,WAAWQ,WAAY,WAAWL,EAAEC,IAAIC,GAAG,EAAG,EAAE,IAAK,CAAC,mBAAII,GAAkB,OAAON,CAAC,EAAE,EAAEO,EAAE,SAASzC,EAAEC,GAAG,IAAIC,EAAEC,EAAEmC,IAAIZ,EAAEtB,EAAE,OAAO8B,EAAE,SAASlC,GAAG,2BAA2BA,EAAEK,OAAO+B,GAAGA,EAAEM,aAAa1C,EAAE2C,UAAUxC,EAAEqC,kBAAkBd,EAAEpB,MAAMN,EAAE2C,UAAUjB,EAAElB,QAAQoC,KAAK5C,GAAGE,GAAE,IAAK,EAAEiC,EAAEU,OAAOC,aAAaA,YAAYC,kBAAkBD,YAAYC,iBAAiB,0BAA0B,GAAGX,EAAED,EAAE,KAAKnB,EAAE,QAAQkB,IAAIC,GAAGC,KAAKlC,EAAE+B,EAAEjC,EAAE0B,EAAEzB,GAAGkC,GAAGD,EAAEC,GAAGJ,EAAG,SAAS5B,GAAGuB,EAAEtB,EAAE,OAAOF,EAAE+B,EAAEjC,EAAE0B,EAAEzB,GAAG+C,sBAAuB,WAAWA,sBAAuB,WAAWtB,EAAEpB,MAAMwC,YAAYlC,MAAMT,EAAEkC,UAAUnC,GAAE,EAAG,EAAG,EAAG,GAAI,EAAE+C,GAAE,EAAGC,GAAG,EAAEC,EAAE,SAASnD,EAAEC,GAAGgD,IAAIR,EAAG,SAASzC,GAAGkD,EAAElD,EAAEM,KAAK,GAAI2C,GAAE,GAAI,IAAI/C,EAAEC,EAAE,SAASF,GAAGiD,GAAG,GAAGlD,EAAEC,EAAE,EAAEiC,EAAE9B,EAAE,MAAM,GAAG+B,EAAE,EAAEC,EAAE,GAAGE,EAAE,SAAStC,GAAG,IAAIA,EAAEoD,eAAe,CAAC,IAAInD,EAAEmC,EAAE,GAAGjC,EAAEiC,EAAEA,EAAEiB,OAAO,GAAGlB,GAAGnC,EAAE2C,UAAUxC,EAAEwC,UAAU,KAAK3C,EAAE2C,UAAU1C,EAAE0C,UAAU,KAAKR,GAAGnC,EAAEM,MAAM8B,EAAEQ,KAAK5C,KAAKmC,EAAEnC,EAAEM,MAAM8B,EAAE,CAACpC,IAAImC,EAAED,EAAE5B,QAAQ4B,EAAE5B,MAAM6B,EAAED,EAAE1B,QAAQ4B,EAAElC,IAAI,CAAC,EAAEiD,EAAEnC,EAAE,eAAesB,GAAGa,IAAIjD,EAAE+B,EAAE9B,EAAE+B,EAAEjC,GAAGyB,EAAG,WAAWyB,EAAEG,cAAchC,IAAIgB,GAAGpC,GAAE,EAAG,GAAI6B,EAAG,WAAWI,EAAE,EAAEe,GAAG,EAAEhB,EAAE9B,EAAE,MAAM,GAAGF,EAAE+B,EAAE9B,EAAE+B,EAAEjC,EAAE,GAAI,EAAEsD,EAAE,CAACC,SAAQ,EAAGC,SAAQ,GAAIC,EAAE,IAAI/C,KAAKgD,EAAE,SAASxD,EAAEC,GAAGJ,IAAIA,EAAEI,EAAEH,EAAEE,EAAED,EAAE,IAAIS,KAAKiD,EAAE/B,qBAAqBgC,IAAI,EAAEA,EAAE,WAAW,GAAG5D,GAAG,GAAGA,EAAEC,EAAEwD,EAAE,CAAC,IAAItD,EAAE,CAAC0D,UAAU,cAAczD,KAAKL,EAAEwB,KAAKuC,OAAO/D,EAAE+D,OAAOC,WAAWhE,EAAEgE,WAAWrB,UAAU3C,EAAEqC,UAAU4B,gBAAgBjE,EAAEqC,UAAUpC,GAAGE,EAAE+D,QAAS,SAASlE,GAAGA,EAAEI,EAAE,GAAID,EAAE,EAAE,CAAC,EAAEgE,EAAE,SAASnE,GAAG,GAAGA,EAAEgE,WAAW,CAAC,IAAI/D,GAAGD,EAAEqC,UAAU,KAAK,IAAI1B,KAAKmC,YAAYlC,OAAOZ,EAAEqC,UAAU,eAAerC,EAAEwB,KAAK,SAASxB,EAAEC,GAAG,IAAIC,EAAE,WAAWyD,EAAE3D,EAAEC,GAAGG,GAAG,EAAED,EAAE,WAAWC,GAAG,EAAEA,EAAE,WAAWyB,oBAAoB,YAAY3B,EAAEqD,GAAG1B,oBAAoB,gBAAgB1B,EAAEoD,EAAE,EAAEzB,iBAAiB,YAAY5B,EAAEqD,GAAGzB,iBAAiB,gBAAgB3B,EAAEoD,EAAE,CAAhO,CAAkOtD,EAAED,GAAG2D,EAAE1D,EAAED,EAAE,CAAC,EAAE4D,EAAE,SAAS5D,GAAG,CAAC,YAAY,UAAU,aAAa,eAAekE,QAAS,SAASjE,GAAG,OAAOD,EAAEC,EAAEkE,EAAEZ,EAAE,EAAG,EAAEa,EAAE,SAASlE,EAAEgC,GAAG,IAAIC,EAAEC,EAAEE,IAAIG,EAAErC,EAAE,OAAO6C,EAAE,SAASjD,GAAGA,EAAE2C,UAAUP,EAAEI,kBAAkBC,EAAEnC,MAAMN,EAAEiE,gBAAgBjE,EAAE2C,UAAUF,EAAEjC,QAAQoC,KAAK5C,GAAGmC,GAAE,GAAI,EAAEe,EAAElC,EAAE,cAAciC,GAAGd,EAAEF,EAAE/B,EAAEuC,EAAEP,GAAGgB,GAAGxB,EAAG,WAAWwB,EAAEI,cAAchC,IAAI2B,GAAGC,EAAER,YAAY,GAAG,GAAIQ,GAAGnB,EAAG,WAAW,IAAIf,EAAEyB,EAAErC,EAAE,OAAO+B,EAAEF,EAAE/B,EAAEuC,EAAEP,GAAG/B,EAAE,GAAGF,GAAG,EAAED,EAAE,KAAK4D,EAAE9B,kBAAkBd,EAAEiC,EAAE9C,EAAEyC,KAAK5B,GAAG6C,GAAG,EAAG,EAAEQ,EAAE,CAAC,EAAEC,EAAE,SAAStE,EAAEC,GAAG,IAAIC,EAAEC,EAAEmC,IAAIJ,EAAE9B,EAAE,OAAO+B,EAAE,SAASnC,GAAG,IAAIC,EAAED,EAAE2C,UAAU1C,EAAEE,EAAEqC,kBAAkBN,EAAE5B,MAAML,EAAEiC,EAAE1B,QAAQoC,KAAK5C,GAAGE,IAAI,EAAEkC,EAAEpB,EAAE,2BAA2BmB,GAAG,GAAGC,EAAE,CAAClC,EAAE+B,EAAEjC,EAAEkC,EAAEjC,GAAG,IAAIwC,EAAE,WAAW4B,EAAEnC,EAAEzB,MAAM2B,EAAEkB,cAAchC,IAAIa,GAAGC,EAAEM,aAAa2B,EAAEnC,EAAEzB,KAAI,EAAGP,GAAE,GAAI,EAAE,CAAC,UAAU,SAASgE,QAAS,SAASlE,GAAG8B,iBAAiB9B,EAAEyC,EAAE,CAAC8B,MAAK,EAAGd,SAAQ,GAAI,GAAI/B,EAAEe,GAAE,GAAIV,EAAG,SAAS5B,GAAG+B,EAAE9B,EAAE,OAAOF,EAAE+B,EAAEjC,EAAEkC,EAAEjC,GAAG+C,sBAAuB,WAAWA,sBAAuB,WAAWd,EAAE5B,MAAMwC,YAAYlC,MAAMT,EAAEkC,UAAUgC,EAAEnC,EAAEzB,KAAI,EAAGP,GAAE,EAAG,EAAG,EAAG,EAAG,CAAC,EAAEsE,EAAE,SAASxE,GAAG,IAAIC,EAAEC,EAAEE,EAAE,QAAQH,EAAE,WAAW,IAAI,IAAIA,EAAE6C,YAAY2B,iBAAiB,cAAc,IAAI,WAAW,IAAIzE,EAAE8C,YAAY4B,OAAOzE,EAAE,CAAC6D,UAAU,aAAanB,UAAU,GAAG,IAAI,IAAIzC,KAAKF,EAAE,oBAAoBE,GAAG,WAAWA,IAAID,EAAEC,GAAGWLine truncated
@@ -0,0 +1,2 @@
"use strict";(self.webpackChunkfrontend=self.webpackChunkfrontend||[]).push([[792],{857:(e,t,n)=>{var a=n(5043),i=n(4391),o=(n(5884),n(9576),n(3309)),r=n(359),s=n(9043),l=n(9781),c=n(3216),d=n(5475),h=n(9379),u=n(1860),m=n(6291),p=n(579);const x=(0,a.createContext)(void 0),g=e=>{let{children:t}=e;const[n,i]=(0,a.useState)(null),[o,r]=(0,a.useState)(!1),[s,l]=(0,a.useState)(!0),[c,d]=(0,a.useState)(!1);(0,a.useEffect)(()=>{(async()=>{const e=await(0,u.Ec)();d(e);const t=(0,u.gf)();if(t&&(0,u.wR)())try{const n=m.API_URL,a=await fetch("".concat(n,"/auth/me"),{credentials:"include",headers:{Authorization:"Bearer ".concat(t)}});if(a.ok){const o=await a.json(),s=o.user||o;if(i(s),r(!0),!e&&!s.role){if((await fetch("".concat(n,"/auth/make-admin"),{method:"POST",headers:{Authorization:"Bearer ".concat(t),"Content-Type":"application/json"}})).ok){const e=(0,h.A)((0,h.A)({},s),{},{role:"admin"});i(e),d(!0)}}}else(0,u.L)()}catch(n){console.error("Error initializing auth:",n),(0,u.L)()}l(!1)})()},[]);return(0,p.jsx)(x.Provider,{value:{isAuthenticated:o,user:n,isLoading:s,adminExists:c,login:async function(e,t){let n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];if((0,u.WG)(e,n),"admin"===t.role)d(!0);else if(!c&&!t.role)try{const n=m.API_URL;(await fetch("".concat(n,"/auth/make-admin"),{method:"POST",headers:{Authorization:"Bearer ".concat(e),"Content-Type":"application/json"}})).ok&&(t.role="admin",d(!0))}catch(a){console.error("Error making user admin:",a)}i(t),r(!0)},logout:()=>{try{m.Ay.post("/auth/logout").catch(()=>{})}catch(e){}(0,u.L)(),i(null),r(!1)},updateUser:e=>{n&&i((0,h.A)((0,h.A)({},n),e))}},children:t})},v=()=>{const e=(0,a.useContext)(x);if(void 0===e)throw new Error("useAuth must be used within an AuthProvider");return e};var j=n(502),b=n(4093),y=n(2107),f=n(8659),k=n(3572),z=n(8846),w=n(202),_=n(3225),S=n(6668),C=n(5699),A=n(5973),E=n(9244),T=n(8662),N=n(4946);const R=async()=>(await m.Ay.get("/settings")).data,P=async()=>(await m.Ay.get("/admin/settings")).data,I=async e=>(await m.Ay.put("/admin/settings",e)).data;function W(e){if(e)try{if(/^(?:https?:)?\/\//i.test(e)||/^data:/i.test(e))return e;if(e.startsWith("/uploads")||e.startsWith("/dist")){const t="http://127.0.0.1:8080";if(t&&!t.startsWith("/")){const n=new URL(t,"undefined"!==typeof window?window.location.origin:void 0);return n.pathname="/",new URL(e,n).toString()}return e}return e}catch(t){return e||void 0}}function M(e){if(!e)return"";let t=e.trim();return t=t.replace(/^T\u011blov\xfdchovn\xe1 jednota\s+/i,"TJ "),t=t.replace(/^Sportovn\xed klub\s+/i,"SK "),t=t.replace(/^Fotbalov\xfd klub\s+/i,"FK "),t=t.replace(/^Fotbal klub\s+/i,"FK "),t=t.replace(/^Sokol\s+/i,"SK "),t=t.replace(/,\s*(spolek|z\.\s*s\.|o\.\s*s\.|z\s*s|o\s*s|spolkov\xe1\s+organizace|spolek\s+registrovan\xfd|z\.s\.|o\.s\.)$/i,""),t.trim()}const D=()=>{var e,t;const{isAuthenticated:n,adminExists:i,login:o}=v(),[r,s]=(0,a.useState)(""),[l,d]=(0,a.useState)(""),[h,u]=(0,a.useState)(!1),[x,g]=(0,a.useState)(!1),[P,I]=(0,a.useState)(!0),[M,D]=(0,a.useState)(null),U=(0,c.Zp)(),L=(0,c.zy)(),F=(0,j.d)(),O=(0,b.dU)("white","gray.800");if((0,a.useEffect)(()=>{let e=!0;return(async()=>{try{const t=await R();e&&D(t)}catch(t){}})(),()=>{e=!1}},[]),n)return(0,p.jsx)(c.C5,{to:"/admin",replace:!0});null===(e=L.state)||void 0===e||null===(t=e.from)||void 0===t||t.pathname;return(0,p.jsx)(y.a,{minH:"100vh",display:"flex",alignItems:"center",justifyContent:"center",bg:"gray.50",children:(0,p.jsxs)(y.a,{w:"100%",maxW:"md",p:8,bg:O,borderRadius:"lg",boxShadow:"lg",children:[(0,p.jsx)(f.o,{mb:4,children:null!==M&&void 0!==M&&M.club_logo_url?(0,p.jsx)(k._,{src:W(M.club_logo_url)||M.club_logo_url,alt:M.club_name||"Club logo",maxH:"64px",objectFit:"contain"}):(0,p.jsx)(z.D,{size:"md",children:"P\u0159ihl\xe1\u0161en\xed"})}),(0,p.jsxs)(w.T,{as:"form",onSubmit:async e=>{e.preventDefault(),g(!0);try{const e=await m.Ay.post("/auth/login",{email:r,password:l}),{token:t,user:n}=e.data;o(t,n,P),F({title:"P\u0159ihl\xe1\u0161en\xed prob\u011bhlo \xfasp\u011b\u0161n\u011b",status:"success",duration:3e3,isClosable:!0});const a=String((null===n||void 0===n?void 0:n.role)||"").toLowerCase();U("admin"===a||"editor"===a?"/admin":"/",{replace:!0})}catch(s){var t,n,a,i;F({title:"P\u0159ihl\xe1\u0161en\xed selhalo",description:(null===(t=s.response)||void 0===t||null===(n=t.data)||void 0===n?void 0:n.error)||(null===(a=s.response)||void 0===a||null===(i=a.data)||void 0===i?void 0:i.message)||"Neplatn\xfd e-mail nebo heslo",status:"error",duration:5e3,isClosable:!0})}finally{g(!1)}},spacing:4,align:"stretch",children:[(0,p.jsxs)(_.MJ,{id:"login-email",isRequired:!0,children:[(0,p.jsx)(S.l,{children:"E-mailov\xe1 adresa"}),(0,p.jsx)(C.p,{type:"email",value:r,onChange:e=>s(e.target.value),placeholder:"Zadejte sv\u016fj e-mail"})]}),(0,p.jsxs)(_.MJ,{id:"login-password",isRequired:!0,children:[(0,p.jsx)(S.l,{children:"Heslo"}),(0,p.jsxs)(A.M,{children:[(0,p.jsx)(C.p,{type:h?"text":"password",value:l,onChLine truncated
//# sourceMappingURL=main.8c4bb3a7.js.map
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.8c4bb3a7.js","mappings":"8OAeA,MAAMA,GAAcC,EAAAA,EAAAA,oBAA2CC,GAElDC,EAAkDC,IAAmB,IAAlB,SAAEC,GAAUD,EAC1E,MAAOE,EAAMC,IAAWC,EAAAA,EAAAA,UAAsB,OACvCC,EAAeC,IAAoBF,EAAAA,EAAAA,WAAkB,IACrDG,EAASC,IAAcJ,EAAAA,EAAAA,WAAkB,IACzCK,EAAaC,IAAkBN,EAAAA,EAAAA,WAAkB,IAExDO,EAAAA,EAAAA,WAAU,KACeC,WAErB,MAAMC,QAAmBC,EAAAA,EAAAA,MACzBJ,EAAeG,GAGf,MAAME,GAAQC,EAAAA,EAAAA,MACd,GAAID,IAASE,EAAAA,EAAAA,MACX,IAEE,MAAMC,EAAOC,EAAAA,QACPC,QAAiBC,MAAM,GAADC,OAAIJ,EAAI,YAAY,CAC9CK,YAAa,UACbC,QAAS,CACP,cAAgB,UAADF,OAAYP,MAG/B,GAAIK,EAASK,GAAI,CACf,MAAMC,QAAaN,EAASO,OACtBC,EAAWF,EAAKxB,MAAQwB,EAK9B,GAJAvB,EAAQyB,GACRtB,GAAiB,IAGZO,IAAee,EAASC,KAAM,CASjC,UARgCR,MAAM,GAADC,OAAIJ,EAAI,oBAAoB,CAC/DY,OAAQ,OACRN,QAAS,CACP,cAAgB,UAADF,OAAYP,GAC3B,eAAgB,uBAIEU,GAAI,CACxB,MAAMM,GAAWC,EAAAA,EAAAA,IAAAA,EAAAA,EAAAA,GAAA,GAAQJ,GAAQ,IAAEC,KAAM,UACzC1B,EAAQ4B,GACRrB,GAAe,EACjB,CACF,CACF,MACEuB,EAAAA,EAAAA,IAEJ,CAAE,MAAOC,GACPC,QAAQD,MAAM,2BAA4BA,IAC1CD,EAAAA,EAAAA,IACF,CAEFzB,GAAW,IAGb4B,IACC,IAkDH,OACEC,EAAAA,EAAAA,KAACzC,EAAY0C,SAAQ,CAACC,MAAO,CAC3BtB,gBAAiBZ,EACjBH,OACAsC,UAAWjC,EACXE,cACAgC,MAtDU7B,eAAOG,EAAea,GAA8C,IAA9Bc,IAAiBC,UAAAC,OAAA,QAAA9C,IAAA6C,UAAA,KAAAA,UAAA,GAInE,IAHAE,EAAAA,EAAAA,IAAW9B,EAAO2B,GAGI,UAAlBd,EAASC,KACXnB,GAAe,QAGZ,IAAKD,IAAgBmB,EAASC,KACjC,IACE,MAAMX,EAAOC,EAAAA,eACUE,MAAM,GAADC,OAAIJ,EAAI,oBAAoB,CACtDY,OAAQ,OACRN,QAAS,CACP,cAAgB,UAADF,OAAYP,GAC3B,eAAgB,uBAIPU,KACXG,EAASC,KAAO,QAChBnB,GAAe,GAEnB,CAAE,MAAOwB,GACPC,QAAQD,MAAM,2BAA4BA,EAC5C,CAEF/B,EAAQyB,GACRtB,GAAiB,EACnB,EA0BIwC,OAlBWA,KAEb,IAEEC,EAAAA,GAAIC,KAAK,gBAAgBC,MAAM,OACjC,CAAE,MAAAC,GAAO,EACTjB,EAAAA,EAAAA,KACA9B,EAAQ,MACRG,GAAiB,IAWf6C,WAzBgBvB,IACd1B,GACFC,GAAO6B,EAAAA,EAAAA,IAAAA,EAAAA,EAAAA,GAAC,CAAC,EAAI9B,GAAS0B,MAwBtB3B,SACCA,KAKMmD,EAAUA,KACrB,MAAMC,GAAUC,EAAAA,EAAAA,YAAW1D,GAC3B,QAAgBE,IAAZuD,EACF,MAAM,IAAIE,MAAM,+CAElB,OAAOF,G,8ICvBF,MAAMG,EAAoB5C,gBACbmC,EAAAA,GAAIU,IAAI,cACf/B,KAGAgC,EAAmB9C,gBACZmC,EAAAA,GAAIU,IAAI,oBACf/B,KAGAiC,EAAsB/C,gBACfmC,EAAAA,GAAIa,IAAI,kBAAmBC,IAClCnC,KCnIN,SAASoC,EAASC,GACvB,GAAKA,EACL,IAEE,GAAI,qBAAqBC,KAAKD,IAAc,UAAUC,KAAKD,GACzD,OAAOA,EAGT,GAAIA,EAAUE,WAAW,aAAeF,EAAUE,WAAW,SAAU,CACrE,MAAMC,EAAWC,wBACjB,GAAID,IAAaA,EAASD,WAAW,KAAM,CACzC,MAAMG,EAAU,IAAIC,IAAIH,EAA4B,qBAAXI,OAAyBA,OAAOC,SAASC,YAAS1E,GAE3F,OADAsE,EAAQK,SAAW,IACZ,IAAIJ,IAAIN,EAAWK,GAASM,UACrC,CAOA,OAAOX,CACT,CAEA,OAAOA,CACT,CAAE,MAAAY,GACA,OAAOZ,QAAajE,CACtB,CACF,CAYO,SAAS8E,EAAiBC,GAC/B,IAAKA,EAAM,MAAO,GAElB,IAAIC,EAAYD,EAAKE,OAoBrB,OAjBAD,EAAYA,EAAUE,QAAQ,uCAA6B,OAG3DF,EAAYA,EAAUE,QAAQ,yBAAuB,OAGrDF,EAAYA,EAAUE,QAAQ,yBAAuB,OAGrDF,EAAYA,EAAUE,QAAQ,mBAAoB,OAGlDF,EAAYA,EAAUE,QAAQ,aAAc,OAG5CF,EAAYA,EAAUE,QAAQ,iHAA4G,IAEnIF,EAAUC,MACnB,CCtCA,MAmIA,EAnI2BE,KAAO,IAADC,EAAAC,EAE/B,MAAM,gBAAElE,EAAe,YAAER,EAAW,MAAEgC,GAAUW,KACzCgC,EAAOC,IAAYjF,EAAAA,EAAAA,UAAS,KAC5BkF,EAAUC,IAAenF,EAAAA,EAAAA,UAAS,KAClCoF,EAAcC,IAAmBrF,EAAAA,EAAAA,WAAS,IAC1CoC,EAAWkD,IAAgBtF,EAAAA,EAAAA,WAAS,IACpCsC,EAAUiD,IAAevF,EAAAA,EAAAA,WAAS,IAClCwF,EAAUC,IAAezF,EAAAA,EAAAA,UAAgC,MAC1D0F,GAAWC,EAAAA,EAAAA,MACXxB,GAAWyB,EAAAA,EAAAA,MACXC,GAAQC,EAAAA,EAAAA,KACRC,GAAUC,EAAAA,EAAAA,IAAkB,QAAS,YAiB3C,IAdAzF,EAAAA,EAAAA,WAAU,KACR,IAAI0F,GAAU,EASd,MARA,WACE,IACE,MAAMC,QAAU9C,IACZ6C,GAASR,EAAYS,EAC3B,CAAE,MAAOC,GACP,CAEH,EAPD,GAQO,KAAQF,GAAU,IACxB,IAGCpF,EACF,OAAOoB,EAAAA,EAAAA,KAACmE,EAAAA,GAAQ,CAACC,GAAG,SAASzB,SAAO,IAGV,QAAfE,EAACX,EAASmC,aAAK,IAAAxB,GAAwB,QAAxBC,EAAfD,EAAmCyB,YAAI,IAAAxB,GAAvCA,EAAyCV,SA6CtD,OACEpC,EAAAA,EAAAA,KAACuE,EAAAA,EAAG,CAACC,KAAK,QAAQC,QAAQ,OAAOC,WAAW,SAASC,eAAe,SAASC,GAAG,UAAShH,UACvFiH,EAAAA,EAAAA,MAACN,EAAAA,EAAG,CAACO,EAAE,OAAOC,KAAK,KAAKC,EAAG,EAAGJ,GAAId,EAASmB,aAAa,KAAKC,UAAU,KAAItH,SAAA,EACzEoC,EAAAA,EAAAA,KAACmF,EAAAA,EAAM,CAACC,GAAI,EAAExH,SACH,OAAR2F,QAAQ,IAARA,GAAAA,EAAU8B,eACTrF,EAAAA,EAAAA,KAACsF,EAAAA,EAAK,CAACC,IAAK9D,EAAS8B,EAAS8B,gBAAkB9B,EAAS8B,cAAeG,IAAKjC,EAASkC,WAAa,YAAaC,KAAK,OAAOC,UAAU,aAEtI3F,EAAAA,EAAAA,KAAC4F,EAAAA,EAAO,CAACC,KAAK,KAAIjI,SAAC,kCAGvBiH,EAAAA,EAAAA,MAACiB,EAAAA,EAAM,CAACC,GAAG,OAAOC,SArDJzH,UAClB0H,EAAEC,iBACF7C,GAAa,GAEb,IACE,MAAMtE,QAAiB2B,EAAAA,GAAIC,KAAK,cAAe,CAAEoC,QAAOE,cAClD,MAAEvE,EAAK,KAAEb,GAASkB,EAASM,KACjCe,EAAM1B,EAAOb,EAAMwC,GACnBuD,EAAM,CACJuC,MAAO,qEACPC,OAAQ,UACRC,SAAU,IACVC,YAAY,IAGd,MAAM9G,EAAO+G,QAAW,OAAJ1I,QAAI,IAAJA,OAAI,EAAJA,EAAM2B,OAAQ,IAAIgH,cAEpC/C,EADW,UAATjE,GAEgB,WAATA,EADA,SAIA,IAJU,CAAEmD,SAAS,GASlC,CAAE,MAAO9C,GAAa,IAAD4G,EAAAC,EAAAC,EAAAC,EACnBhD,EAAM,CACJuC,MAAO,qCACPU,aAA2B,QAAdJ,EAAA5G,EAAMd,gBAAQ,IAAA0H,GAAM,QAANC,EAAdD,EAAgBpH,YAAI,IAAAqH,OAAN,EAAdA,EAAsB7G,SAAuB,QAAlB8G,EAAI9G,EAAMd,gBAAQ,IAAA4H,GAAM,QAANC,EAAdD,EAAgBtH,YAAI,IAAAuH,OAAN,EAAdA,EAAsBE,UAAW,gCAC7EV,OAAQ,QACRC,SAAU,IACVC,YAAY,GAEhB,CAAC,QACCjD,GAAa,EACf,GAiB6C0D,QAAS,EAAGC,MAAM,UAASpJ,SAAA,EAClEiH,EAAAA,EAAAA,MAACoC,EAAAA,GAAW,CAACC,GAAG,cAAcC,YAAU,EAAAvJ,SAAA,EACtCoC,EAAAA,EAAAA,KAACoH,EAAAA,EAAS,CAAAxJ,SAAC,yBACXoC,EAAAA,EAAAA,KAACqH,EAAAA,EAAK,CACJC,KAAK,QACLpH,MAAO6C,EACPwE,SAAWtB,GAAMjD,EAASiD,EAAEuB,OAAOtH,OACnCuH,YAAY,iCAGhB5C,EAAAA,EAAAA,MAACoC,EAAAA,GAAW,CAACC,GAAG,iBAAiBC,YALine truncated
@@ -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
@@ -0,0 +1 @@
{"version":3,"file":"static/js/runtime.18a1ba68.js","mappings":"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 truncated
+22
View File
@@ -1,6 +1,8 @@
server {
listen 80;
server_name localhost;
# Allow larger uploads (default is 1MB) – must be >= backend MAX_UPLOAD_SIZE
client_max_body_size 50m;
# Enable gzip compression for text-based assets
gzip on;
@@ -78,6 +80,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Accept-Encoding gzip;
# Ensure Nginx accepts large uploads on this route as well
client_max_body_size 50m;
# Enable buffering for better performance
proxy_buffering on;
@@ -86,6 +90,24 @@ server {
proxy_busy_buffers_size 8k;
}
# Short links and tracked redirect - must bypass SPA and hit backend
location ^~ /s/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /r {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy backend-served assets so the frontend can use relative URLs
location /uploads/ {
proxy_pass http://backend:8080;
+13 -2
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { ChakraProvider, extendTheme } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { BrowserRouter as Router, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import './styles/custom-scrollbar.css';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import AuthPage from './pages/AuthPage';
@@ -51,6 +51,7 @@ import AnalyticsAdminPage from './pages/admin/AnalyticsAdminPage';
import FilesAdminPage from './pages/admin/FilesAdminPage';
import ContactsAdminPage from './pages/admin/ContactsAdminPage';
import NavigationAdminPage from './pages/admin/NavigationAdminPage';
import ShortlinksAdminPage from './pages/admin/ShortlinksAdminPage';
import SemiAdminPage from './pages/SemiAdminPage';
import PollsAdminPage from './pages/admin/PollsAdminPage';
// Admin pages render their own AdminLayout internally
@@ -75,6 +76,7 @@ import ForbiddenPage from './pages/ForbiddenPage';
import NotFoundPage from './pages/NotFoundPage';
import VideosPage from './pages/VideosPage';
import SearchPage from './pages/SearchPage';
import ShortRedirectPage from './pages/ShortRedirectPage';
import ClothingPage from './pages/ClothingPage';
import PollsPage from './pages/PollsPage';
import { useUmami } from './hooks/useUmami';
@@ -258,6 +260,12 @@ const FontLoader: React.FC = () => {
return null;
};
// Redirect /news -> /blog while preserving query parameters
const NewsRedirect: React.FC = () => {
const loc = useLocation();
return <Navigate to={`/blog${loc.search || ''}`} replace />;
};
const App: React.FC = () => {
// Uses shared ProtectedRoute component for auth guard
@@ -352,7 +360,9 @@ const App: React.FC = () => {
<Route path="/pravidla-cookies" element={<CookiePolicyPage />} />
<Route path="/obchodni-podminky" element={<TermsPage />} />
<Route path="/zasady-ochrany-osobnich-udaju" element={<PrivacyPolicyPage />} />
<Route path="/news" element={<Navigate to="/blog" replace />} />
{/* Short links - forward to backend origin if frontend captured it */}
<Route path="/s/:code" element={<ShortRedirectPage />} />
<Route path="/news" element={<NewsRedirect />} />
{/* Slug routes must precede id route to avoid conflicts */}
<Route path="/news/:slug" element={<ArticleDetailPage />} />
<Route path="/articles/slug/:slug" element={<ArticleDetailPage />} />
@@ -440,6 +450,7 @@ const App: React.FC = () => {
<Route path="/admin/scoreboard" element={<ScoreboardAdminPage />} />
<Route path="/admin/scoreboard/remote" element={<MobileScoreboardControlPage />} />
<Route path="/admin/analytika" element={<AnalyticsAdminPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
<Route path="/admin/soubory" element={<FilesAdminPage />} />
<Route path="/admin/kontakty" element={<ContactsAdminPage />} />
<Route path="/admin/navigace" element={<NavigationAdminPage />} />
+55 -30
View File
@@ -1,4 +1,4 @@
import { Box, Button, Flex, Text, Link } from '@chakra-ui/react';
import { Box, Button, Flex, Text, Link, Checkbox } from '@chakra-ui/react';
import { useEffect, useState } from 'react';
const STORAGE_KEY = 'cookie_consent';
@@ -41,6 +41,18 @@ const CookieBanner: React.FC = () => {
}
}, []);
// Allow opening the preferences from anywhere (e.g. Cookie Policy page)
useEffect(() => {
const openHandler = (_e: Event) => {
setManaging(true);
setVisible(true);
};
window.addEventListener('cookie-consent-open', openHandler);
return () => {
window.removeEventListener('cookie-consent-open', openHandler);
};
}, []);
const saveAndClose = (c: Consent) => {
const payload = { ...c, timestamp: new Date().toISOString() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
@@ -62,46 +74,59 @@ const CookieBanner: React.FC = () => {
if (!visible) return null;
return (
<Box role="dialog" aria-live="polite" position="fixed" bottom={0} left={0} right={0} bg="gray.900" color="gray.100" zIndex={1000} py={4} px={4}>
<Box
role="dialog"
aria-live="polite"
position="fixed"
bottom={{ base: 4, md: 6 }}
left="50%"
transform="translateX(-50%)"
bg="blackAlpha.800"
color="gray.100"
zIndex={1000}
px={{ base: 4, md: 6 }}
py={{ base: 4, md: 5 }}
borderRadius="xl"
boxShadow="xl"
borderWidth="1px"
borderColor="whiteAlpha.300"
w={{ base: 'calc(100% - 2rem)', sm: 'calc(100% - 3rem)', md: 'auto' }}
maxW="3xl"
style={{ backdropFilter: 'blur(6px)' }}
>
<Flex align="start" justify="space-between" gap={6} wrap="wrap">
<Box maxW={{ base: '100%', md: '70%' }}>
<Box maxW={{ base: '100%', md: '75%' }}>
<Text fontSize="sm" mb={2}>
<span role="img" aria-label="cookie">🍪</span>{' '}
Tento web používá soubory cookies pro zajištění správného fungování (nezbytné) a za účelem vylepšení obsahu.
O vybraných kategoriích rozhodujete vy. Podrobnosti najdete v&nbsp;
<Link href="/pravidla-cookies" color="blue.300" textDecoration="underline">Pravidlech cookies</Link>.
</Text>
{managing && (
<Box mt={3} bg="gray.800" borderRadius="md" p={3} border="1px solid" borderColor="gray.700">
<Box mt={3} bg="gray.800" borderRadius="lg" p={4} borderWidth="1px" borderColor="gray.700">
<Text fontWeight="semibold" mb={2}>Nastavení preferencí</Text>
<Flex direction="column" gap={2}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked readOnly />
<Checkbox isChecked isDisabled>
<Text fontSize="sm">Nezbytné cookies (vždy aktivní)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.preferences}
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
/>
</Checkbox>
<Checkbox
isChecked={!!consent.preferences}
onChange={(e) => setConsent((c) => ({ ...c, preferences: e.target.checked }))}
>
<Text fontSize="sm">Preferenční cookies (např. zapamatování voleb)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.analytics}
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
/>
</Checkbox>
<Checkbox
isChecked={!!consent.analytics}
onChange={(e) => setConsent((c) => ({ ...c, analytics: e.target.checked }))}
>
<Text fontSize="sm">Analytické cookies (anonymní měření návštěvnosti)</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={!!consent.marketing}
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
/>
</Checkbox>
<Checkbox
isChecked={!!consent.marketing}
onChange={(e) => setConsent((c) => ({ ...c, marketing: e.target.checked }))}
>
<Text fontSize="sm">Marketingové cookies</Text>
</label>
</Checkbox>
<Flex gap={2} mt={2} wrap="wrap">
<Button size="sm" colorScheme="blue" onClick={() => saveAndClose(consent)}>Uložit nastavení</Button>
<Button size="sm" variant="outline" onClick={() => setManaging(false)}>Zpět</Button>
@@ -111,8 +136,8 @@ const CookieBanner: React.FC = () => {
)}
</Box>
<Flex gap={2} align="center" wrap="wrap">
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="outline">Nastavit</Button>
<Button size="sm" onClick={rejectNonEssential} variant="ghost">Odmítnout nepovinné</Button>
<Button size="sm" onClick={() => setManaging((v) => !v)} variant="ghost">Nastavit</Button>
<Button size="sm" onClick={rejectNonEssential} variant="outline" colorScheme="gray">Odmítnout nepovinné</Button>
<Button size="sm" colorScheme="blue" onClick={acceptAll}>Přijmout vše</Button>
</Flex>
</Flex>
+62 -38
View File
@@ -52,6 +52,7 @@ import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -164,15 +165,15 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
{hasActivities !== false && (
{hasActivities === true && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
)}
{hasPlayers !== false && (
{hasPlayers === true && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
)}
{hasTables ? (
{hasTables === true && (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
) : null}
)}
{Array.isArray(settings?.custom_nav) && settings.custom_nav.length > 0 && settings.custom_nav.map((item: any, idx: number) => {
const customLinkIsExternal = typeof item?.url === 'string' && /^https?:\/\//i.test(item.url);
const linkProps = customLinkIsExternal ? { href: item.url } : { to: item.url || '/' };
@@ -191,7 +192,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
</Button>
);
})}
{hasArticles !== false && (
{hasArticles === true && (
<>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
@@ -210,11 +211,11 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, isAuthenticated, menuBg, divider
)}
</>
)}
{hasVideos !== false && (
{hasVideos === true && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
)}
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
{hasGallery !== false && (
{hasGallery === true && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
)}
{settings?.shop_url && (
@@ -258,6 +259,7 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isSearchOpen, onOpen: onSearchOpen, onClose: onSearchClose } = useDisclosure();
const isAdmin = user?.role === 'admin';
const accountPath = isAdmin ? '/admin/nastaveni' : '/semiadmin';
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
@@ -324,16 +326,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
// Set favicon/logo in head for fan pages (SPA)
useEffect(() => {
try {
let url = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!url) return;
// Normalize relative upload paths to API origin so favicon resolves on all pages
try {
const apiOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
if (/^\/.+/.test(url) && !/^https?:\/\//i.test(url)) {
// If starts with /uploads or any absolute path, prefix API origin
url = apiOrigin + url;
}
} catch {}
const raw = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
if (!raw) return;
const url = assetUrl(raw) || raw;
const setIcon = (rel: string) => {
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
@@ -544,11 +539,29 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
}));
}, [navCategories]);
// Filter dynamic navigation items based on available data (only show when data exists)
const filteredDynamicNavItems = useMemo(() => {
const filterItem = (item: NavigationItem): NavigationItem | null => {
const url = item.url || '';
if (url.startsWith('/aktivity') && hasActivities !== true) return null;
if (url.startsWith('/hraci') && hasPlayers !== true) return null;
if (url.startsWith('/blog') && hasArticles !== true) return null;
if (url.startsWith('/videa') && hasVideos !== true) return null;
if (url.startsWith('/galerie') && hasGallery !== true) return null;
if (item.type === 'dropdown' && Array.isArray(item.children)) {
const children = item.children.map(filterItem).filter(Boolean) as NavigationItem[];
return { ...item, children };
}
return item;
};
return dynamicNavItems.map(filterItem).filter(Boolean) as NavigationItem[];
}, [dynamicNavItems, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery]);
// Use dynamic navigation if available, otherwise fallback to hardcoded
let NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
if (!navLoading && filteredDynamicNavItems.length > 0) {
// Use dynamic navigation from API
const navLinks = dynamicNavItems.map(convertToNavLink);
const navLinks = filteredDynamicNavItems.map(convertToNavLink);
// Inject categories into "Články" or "Blog" navigation item if it exists
if (categoryItems.length > 0) {
@@ -567,7 +580,18 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
}
}
return navLinks;
// Ensure we only show sections when there is data
const filtered = navLinks.filter((link) => {
const to = link.to || '';
if (to.startsWith('/aktivity')) return hasActivities === true;
if (to.startsWith('/hraci')) return hasPlayers === true;
if (to.startsWith('/blog')) return hasArticles === true;
if (to.startsWith('/videa')) return hasVideos === true;
if (to.startsWith('/galerie')) return hasGallery === true;
return true;
});
return filtered;
}
// Fallback to hardcoded navigation
@@ -607,40 +631,40 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity when there are no activities
if (hasActivities === false) {
// Hide Aktivity unless there are activities
if (hasActivities !== true) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči when there are no players
if (hasPlayers === false) {
// Hide Hráči unless there are players
if (hasPlayers !== true) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články when there are no articles
if (hasArticles === false) {
// Hide Články unless there are articles
if (hasArticles !== true) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa when there are no videos
if (hasVideos === false) {
// Hide Videa unless there are videos
if (hasVideos !== true) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie when there is no gallery content
if (hasGallery === false) {
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
// Hide Fotogalerie unless there is gallery content
if (hasGallery !== true) {
links = links.filter((n) => n.to !== '/galerie');
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
}, [filteredDynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
return (
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
@@ -674,15 +698,15 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW}>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} isAuthenticated={isAuthenticated} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={filteredDynamicNavItems} navLoading={navLoading} />
<Container maxW={containerMaxW} px={fullWidth ? 0 : undefined}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
<HStack as={RouterLink} to="/" spacing={3} align="center">
{(settings?.club_logo_url || theme.logoUrl) && (
<Image
src={settings?.club_logo_url || theme.logoUrl}
src={assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl}
alt={settings?.club_name || theme.name || 'Logo'}
boxSize={{ base: '36px', md: '40px' }}
objectFit="contain"
@@ -826,9 +850,9 @@ const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
<Avatar size="sm" name={user?.name || 'Uživatel'} />
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem as={RouterLink} to={accountPath}>Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>E‑mailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin/nastaveni">Nastavení stránky</MenuItem>}
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
+26 -3
View File
@@ -1,6 +1,6 @@
import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import {
FaTachometerAlt,
FaUsers,
@@ -30,13 +30,15 @@ import {
FaTshirt,
FaBullhorn,
FaUserShield,
FaFileAlt
FaFileAlt,
FaLink
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
interface NavItemProps {
icon: any;
@@ -146,6 +148,7 @@ const getIconForPageType = (pageType?: string): any => {
settings: FaPalette,
files: FaFolder,
docs: FaBook,
shortlinks: FaLink,
};
return iconMap[pageType || ''] || FaFileAlt;
};
@@ -175,6 +178,9 @@ const AdminSidebar = ({
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const hasShortlinks = useMemo(() => {
return navItems.some(it => (it.page_type === 'shortlinks') || (it.url === '/admin/shortlinks'));
}, [navItems]);
// Restore scroll on mount
useEffect(() => {
@@ -287,7 +293,7 @@ const AdminSidebar = ({
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<Image
src={publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
src={assetUrl(publicSettings?.club_logo_url) || publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="Club Logo"
boxSize="48px"
objectFit="contain"
@@ -365,6 +371,16 @@ const AdminSidebar = ({
>
MyUIbrix Editor
</NavItem>
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -592,6 +608,13 @@ const AdminSidebar = ({
>
Nastavení
</NavItem>
<NavItem
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Zkrácené odkazy
</NavItem>
<NavItem
icon={FaFolder}
to="/admin/soubory"
+2 -2
View File
@@ -44,7 +44,7 @@ interface PollLinkerProps {
const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChanged }) => {
const toast = useToast();
const queryClient = useQueryClient();
const [isExpanded, setIsExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(true);
const [selectedPollId, setSelectedPollId] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
@@ -363,7 +363,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
<Divider />
<Tabs size="sm" variant="enclosed">
<Tabs size="sm" variant="enclosed" defaultIndex={1}>
<TabList>
<Tab>Propojit existující</Tab>
<Tab>Vytvořit novou</Tab>
@@ -76,6 +76,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const quillRef = useRef<ReactQuill | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const onChangeRef = useRef(onChange);
const selectedImageIdRef = useRef<string | null>(null);
const selectImageByIdRef = useRef<(id: string) => void>(() => {});
const [isMounted, setIsMounted] = useState(false);
// Ensure component is mounted before rendering Quill
@@ -192,6 +194,54 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
}), [toolbarConfig, onImageUpload, handleImageUpload]);
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
const setTitle = (selector: string, title: string) => {
toolbarEl.querySelectorAll(selector).forEach((el) => {
(el as HTMLElement).setAttribute('title', title);
(el as HTMLElement).setAttribute('aria-label', title);
});
};
// Basic formatting
setTitle('button.ql-bold', 'Tučné');
setTitle('button.ql-italic', 'Kurzíva');
setTitle('button.ql-underline', 'Podtržení');
setTitle('button.ql-strike', 'Přeškrtnutí');
setTitle('button.ql-link', 'Vložit odkaz');
setTitle('button.ql-image', 'Vložit obrázek');
setTitle('button.ql-blockquote', 'Citace');
setTitle('button.ql-clean', 'Vyčistit formátování');
// Lists
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
// Alignment
setTitle('button.ql-align', 'Zarovnání');
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
setTitle('button.ql-align[value="justify"]', 'Do bloku');
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
}, [isMounted, toolbar]);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
const canvas = document.createElement('canvas');
@@ -368,18 +418,40 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editorRect = editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const scrollLeft = editor.root.scrollLeft;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
position: absolute;
top: -26px;
right: 0;
background: rgba(26,32,44,0.9);
color: #fff;
font-size: 11px;
line-height: 1;
padding: 4px 6px;
border-radius: 4px;
pointer-events: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
`;
const updateSizeLabel = (w: number) => {
try {
const edW = editor.root.clientWidth || w || 1;
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`;
} catch {
sizeLabel.textContent = `${Math.round(w)} px`;
}
};
// Create edge handles (right, bottom, left, top)
const handles = [
{ position: 'right', cursor: 'ew-resize', width: '8px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '8px' },
{ position: 'left', cursor: 'ew-resize', width: '8px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '8px' },
// Corner handles
{ position: 'bottom-right', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'top-left', cursor: 'nwse-resize', width: '16px', height: '16px', isCorner: true },
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-left', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
];
const updateHandlePositions = () => {
@@ -467,7 +539,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
}
handle.addEventListener('mousedown', (e) => {
handle.addEventListener('pointerdown', (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
@@ -477,26 +549,19 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const aspectRatio = startWidth / startHeight;
const onMouseMove = (e: MouseEvent) => {
const onPointerMove = (ev: PointerEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
const deltaX = ev.clientX - startX;
const deltaY = ev.clientY - startY;
let newWidth = startWidth;
// Calculate new width based on handle position
if (position.includes('right')) {
newWidth = startWidth + deltaX;
} else if (position.includes('left')) {
newWidth = startWidth - deltaX;
} else if (position.includes('bottom') || position.includes('top')) {
// For vertical handles, maintain aspect ratio
newWidth = startWidth + (deltaY * aspectRatio);
}
// Constrain width
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
@@ -508,25 +573,28 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const onMouseUp: (ev: MouseEvent) => void = () => {
const onPointerUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
onChangeRef.current(editor.root.innerHTML);
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
});
container.appendChild(handle);
});
updateHandlePositions();
updateSizeLabel(img.offsetWidth || img.width || 0);
editor.root.style.position = 'relative';
editor.root.appendChild(container);
container.appendChild(sizeLabel);
resizeHandle = container;
return container;
@@ -547,6 +615,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
selectedImage = img;
// Ensure image has a persistent ID for reselection after content updates
let id = img.getAttribute('data-img-id') || '';
if (!id) {
id = 'img-' + Date.now() + '-' + Math.random().toString(36).slice(2);
try { img.setAttribute('data-img-id', id); } catch {}
}
selectedImageIdRef.current = id;
img.style.outline = '3px solid #3182ce';
img.style.cursor = 'move';
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
@@ -622,6 +697,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setShowImageToolbar(true);
};
// Expose reselection helper bound to current effect scope
selectImageByIdRef.current = (id: string) => {
const ed = quillRef.current?.getEditor();
if (!ed) return;
const node = ed.root.querySelector(`img[data-img-id="${id}"]`) as HTMLImageElement | null;
if (node) {
selectImage(node);
}
};
const deselectImage = () => {
if (selectedImage) {
selectedImage.style.outline = '';
@@ -965,6 +1050,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
} catch (e: any) {
@@ -998,10 +1084,20 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
}, [selectedImageElement, toast]);
// Reselect helper after content updates (e.g., when value change triggers rerender)
const reselectAfterContentUpdate = useCallback(() => {
const id = selectedImageIdRef.current;
if (!id) return;
setTimeout(() => {
try { selectImageByIdRef.current?.(id); } catch {}
}, 30);
}, []);
const applyWidthPx = useCallback((px: number, opts?: { silent?: boolean }) => {
if (!selectedImageElement) return;
const editor = quillRef.current?.getEditor();
@@ -1017,6 +1113,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
if (!opts?.silent) {
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
}
@@ -1036,6 +1134,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
onChangeRef.current(editor.root.innerHTML);
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
reselectAfterContentUpdate();
toast({ title: 'Šířka resetována', status: 'info', duration: 1200 });
}, [selectedImageElement, toast]);
+55 -7
View File
@@ -3,6 +3,34 @@ import { Image, ImageProps, Skeleton } from '@chakra-ui/react';
import { getTeamLogo } from '../../utils/sportLogosAPI';
import { getLogoStyle, getLogoClassName } from '../../utils/logoUtils';
import '../../styles/logos.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { assetUrl } from '../../utils/url';
// Lightweight cached overrides loader
let __teamOverridesCache: { ts: number; data: { by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> } } | null = null;
const loadTeamOverrides = async (): Promise<{ by_id?: Record<string, { name?: string; logo_url?: string }>; by_name?: Record<string, string> }> => {
const now = Date.now();
if (__teamOverridesCache && now - __teamOverridesCache.ts < 60_000) {
return __teamOverridesCache.data || {};
}
try {
const res = await fetch(`/api/v1/public/team-logo-overrides?t=${now}`, { cache: 'no-cache' });
if (res.ok) {
const json = await res.json();
__teamOverridesCache = { ts: now, data: json || {} };
return json || {};
}
} catch {}
try {
const res2 = await fetch('/cache/prefetch/team_logo_overrides.json', { cache: 'no-cache' });
if (res2.ok) {
const json = await res2.json();
__teamOverridesCache = { ts: now, data: json || {} };
return json || {};
}
} catch {}
__teamOverridesCache = { ts: now, data: {} };
return {};
};
interface TeamLogoProps extends Omit<ImageProps, 'src'> {
teamId?: string;
@@ -32,6 +60,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { data: publicSettings } = usePublicSettings();
useEffect(() => {
let mounted = true;
@@ -40,11 +69,30 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
try {
setLoading(true);
setError(false);
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
// Load admin overrides (cached)
let overrides: { by_id?: Record<string, { name?: string; logo_url?: string }> } = {};
try { overrides = await loadTeamOverrides(); } catch {}
// Prefer local club logo for own team when IDs match
if (
teamId && publicSettings?.club_id && String(teamId) === String(publicSettings.club_id) && publicSettings?.club_logo_url
) {
if (mounted) {
setLogoUrl(assetUrl(publicSettings.club_logo_url) || publicSettings.club_logo_url);
}
} else if (teamId && overrides?.by_id?.[teamId]?.logo_url) {
const v = overrides.by_id[teamId]!.logo_url as string;
if (mounted) {
if (typeof v === 'string' && v.startsWith('/')) {
setLogoUrl(assetUrl(v) || v);
} else {
setLogoUrl(v);
}
}
} else {
const url = await getTeamLogo(teamId, teamName, facrLogo);
if (mounted) {
setLogoUrl(url);
}
}
} catch (e) {
console.error('Failed to fetch logo:', e);
@@ -65,7 +113,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return () => {
mounted = false;
};
}, [teamId, teamName, facrLogo]);
}, [teamId, teamName, facrLogo, publicSettings?.club_id, publicSettings?.club_logo_url]);
// Size mapping
const sizeMap = {
@@ -101,7 +149,7 @@ export const TeamLogo: React.FC<TeamLogoProps> = ({
return (
<Image
src={logoUrl || '/logo192.png'}
src={(assetUrl(logoUrl || undefined) || logoUrl || '/logo192.png')}
alt={alt || teamName || 'Team logo'}
{...sizeProps}
{...imageProps}
@@ -50,6 +50,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
borderRadius={borderRadius}
borderWidth="1px"
borderColor={borderColor}
fallbackSrc="/dist/img/logo-club-empty.svg"
loading="lazy"
/>
</Box>
@@ -70,6 +71,7 @@ const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
maxH="400px"
objectFit="contain"
borderRadius="md"
fallbackSrc="/dist/img/logo-club-empty.svg"
loading="lazy"
/>
</PopoverBody>
@@ -5,6 +5,7 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
import { assetUrl } from '../../utils/url';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -121,7 +122,7 @@ const SpartaNavbar: React.FC = () => {
return links;
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const logoUrl = (assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl) || '/dist/img/logo-club-empty.svg';
const clubName = settings?.club_name || theme.name || 'Klub';
return (
@@ -133,7 +133,17 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
return null;
}
const openStreetMapUrl = `https://www.openstreetmap.org/search?query=${encodeURIComponent(location.trim())}`;
const encodedQuery = encodeURIComponent(location.trim());
// Build external map URLs – prefer coordinates when available, otherwise fallback to address search
const openStreetMapUrl = coords
? `https://www.openstreetmap.org/?mlat=${coords.lat}&mlon=${coords.lon}#map=17/${coords.lat}/${coords.lon}`
: `https://www.openstreetmap.org/search?query=${encodedQuery}`;
const googleMapsUrl = coords
? `https://www.google.com/maps/search/?api=1&query=${coords.lat},${coords.lon}`
: `https://www.google.com/maps/search/?api=1&query=${encodedQuery}`;
const mapyCzUrl = coords
? `https://mapy.cz/zakladni?x=${coords.lon}&y=${coords.lat}&z=17`
: `https://mapy.cz/zakladni?q=${encodedQuery}`;
return (
<VStack align="stretch" spacing={3} mt={4} data-testid="event-location-map">
@@ -148,9 +158,13 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
<AlertIcon />
<Box>
<Text mb={1}>{error}</Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">
Otevřít v OpenStreetMap
</Link>
<Text>
<Link href={openStreetMapUrl} isExternal color="blue.400">Otevřít v OpenStreetMap</Link>
{' · '}
<Link href={googleMapsUrl} isExternal color="blue.400">Otevřít v Google Maps</Link>
{' · '}
<Link href={mapyCzUrl} isExternal color="blue.400">Otevřít v Mapy.cz</Link>
</Text>
</Box>
</Alert>
)}
@@ -172,10 +186,12 @@ const EventLocationMap: React.FC<EventLocationMapProps> = ({ location, title, la
)}
<Text fontSize="sm" color="gray.500">
Přesnost určena pomocí otevřených mapových dat.{' '}
<Link href={openStreetMapUrl} isExternal color="blue.400">
Zobrazit v OpenStreetMap
</Link>
Přesnost určena pomocí otevřených mapových dat. Zobrazit v{' '}
<Link href={openStreetMapUrl} isExternal color="blue.400">OpenStreetMap</Link>
{' · '}
<Link href={googleMapsUrl} isExternal color="blue.400">Google Maps</Link>
{' · '}
<Link href={mapyCzUrl} isExternal color="blue.400">Mapy.cz</Link>
</Text>
</VStack>
);
+11 -2
View File
@@ -1,8 +1,9 @@
import { Box, Flex, Heading, Image, HStack, Button, Text } from '@chakra-ui/react';
import { Box, Flex, Heading, HStack, Button, Text } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { facrApi } from '../../services/facr/facrApi';
import { FACR_CLUB_ID, FACR_CLUB_TYPE } from '../../config/facr';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { TeamLogo } from '../common/TeamLogo';
const ClubHeader: React.FC = () => {
const { data: settings } = usePublicSettings();
@@ -18,7 +19,15 @@ const ClubHeader: React.FC = () => {
return (
<Flex align="center" justify="space-between" bg="white" borderWidth="1px" borderRadius="lg" p={4}>
<HStack spacing={4}>
<Image src={data?.logo_url || '/logo192.png'} alt={data?.name || 'Club'} boxSize="64px" objectFit="contain" />
<TeamLogo
teamId={clubId}
teamName={data?.name}
facrLogo={data?.logo_url || undefined}
size="custom"
boxSize="64px"
alt={data?.name || 'Club'}
borderRadius="full"
/>
<Box>
<Heading size="lg">{data?.name || 'Club Name'}</Heading>
<Text color="gray.600" fontSize="sm">
@@ -0,0 +1,44 @@
import React from 'react';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { assetUrl } from '../../utils/url';
export type ClubHeroTopbarVariant = 'brand' | 'minimal' | 'badge';
const cls = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
const ClubHeroTopbar: React.FC<{ variant?: ClubHeroTopbarVariant; fullBleed?: boolean }>= ({ variant = 'brand', fullBleed = false }) => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const title = settings?.club_name || theme.name || 'Fotbalový klub';
const tagline = 'Oficiální web klubu';
const logo = assetUrl(settings?.club_logo_url || theme.logoUrl) || settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const shopUrl = settings?.shop_url || undefined;
const calendarUrl = '/kalendar';
return (
<div className={cls('club-hero-topbar', fullBleed && 'full-bleed',
variant === 'brand' && 'club-hero-topbar--brand',
variant === 'minimal' && 'club-hero-topbar--minimal',
variant === 'badge' && 'club-hero-topbar--badge'
)}>
<div className="club-hero-topbar__logo">
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img src={logo} alt={title} style={{ width: 36, height: 36, objectFit: 'contain' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="club-hero-topbar__title">{title}</div>
<div className="club-hero-topbar__tagline">{tagline}</div>
</div>
<div className="club-hero-topbar__spacer" />
<div className="club-hero-topbar__actions">
<a href={calendarUrl} className="sparta-button-tertiary">Kalendář</a>
{shopUrl && (
<a href={shopUrl} target="_blank" rel="noreferrer" className="sparta-button-primary">Fanshop</a>
)}
</div>
</div>
);
};
export default ClubHeroTopbar;
@@ -139,11 +139,19 @@ const CompetitionMatches: React.FC = () => {
return (
<Box>
<Tabs variant="soft-rounded" colorScheme="blue" isFitted>
<TabList>
<Tabs variant="soft-rounded" colorScheme="blue" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
}}>
{sortedCompetitions.map((c) => {
const label = c.alias || c.name;
return <Tab key={c.id}>{label}</Tab>;
return (
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
<Text as="span" noOfLines={1} maxW="220px" title={label}>{label}</Text>
</Tab>
);
})}
</TabList>
<TabPanels>
@@ -1,6 +1,7 @@
import React from 'react';
import { Box, Flex, HStack, Image, Text, Container, useColorModeValue } from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { assetUrl } from '../../utils/url';
interface HeaderVariantsProps {
variant: 'unified' | 'edge' | 'minimal' | 'modern';
@@ -15,9 +16,9 @@ const HeaderVariants: React.FC<HeaderVariantsProps> = ({
clubLogo,
clubId,
}) => {
const displayLogo = clubId
const displayLogo = (assetUrl(clubLogo) || clubLogo) || (clubId
? `http://logoapi.sportcreative.eu/logos/${clubId}?format=svg`
: clubLogo || '/images/club-logo.png';
: '/images/club-logo.png');
// Unified variant - classic header
if (variant === 'unified') {
@@ -99,10 +99,16 @@ const MatchesSection: React.FC = () => {
)}
{isLoading && <Skeleton height="200px" />}
{!isLoading && data && (
<Tabs variant="enclosed-colored" isFitted>
<TabList>
<Tabs variant="enclosed-colored" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'gray.300', borderRadius: '4px' },
}}>
{data.competitions?.map((c) => (
<Tab key={c.id}>{c.name}</Tab>
<Tab key={c.id} flex="0 0 auto" px={3} py={2} fontSize="sm">
<Text as="span" noOfLines={1} maxW="220px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
<TabPanels>
+30 -5
View File
@@ -44,6 +44,13 @@ const TableSection: React.FC = () => {
const rankTopText = useColorModeValue('green.800', 'white');
const pointsBg = useColorModeValue('blue.600', 'blue.400');
const pointsText = 'white';
// TabList theming constants (hoisted to avoid hooks in loops/conditions)
const tabListBg = useColorModeValue('white', 'gray.800');
const tabListBorder = useColorModeValue('gray.200', 'gray.700');
const tabSelectedBg = useColorModeValue('blue.50', 'blue.900');
const tabSelectedColor = useColorModeValue('blue.700', 'blue.200');
const tabSelectedBorderColor = useColorModeValue('blue.200', 'blue.600');
const tabColor = useColorModeValue('gray.800', 'gray.200');
const { data, isLoading, isError, error } = useQuery({
queryKey: ['facr-table', clubId, clubType],
queryFn: () => facrApi.getClubTable(clubId, clubType),
@@ -135,15 +142,33 @@ const TableSection: React.FC = () => {
</HStack>
)}
{!isLoading && !isError && data && data.competitions?.length > 0 && (
<Tabs variant="enclosed" colorScheme="blue" isFitted>
<TabList bg={useColorModeValue('white', 'gray.800')} borderRadius="md" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Tabs variant="enclosed" colorScheme="blue" size="sm">
<TabList
bg={tabListBg}
borderRadius="md"
borderWidth="1px"
borderColor={tabListBorder}
px={2}
pt={2}
overflowX="auto"
overflowY="hidden"
css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
}}
>
{data.competitions?.map((c) => (
<Tab
key={c.id}
_selected={{ bg: useColorModeValue('blue.50', 'blue.900'), color: useColorModeValue('blue.700', 'blue.200'), borderColor: useColorModeValue('blue.200', 'blue.600') }}
color={useColorModeValue('gray.800', 'gray.200')}
_selected={{ bg: tabSelectedBg, color: tabSelectedColor, borderColor: tabSelectedBorderColor }}
color={tabColor}
flex="0 0 auto"
px={3}
py={2}
fontSize="sm"
>
{c.name}
<Text as="span" noOfLines={1} maxW="240px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
@@ -1,6 +1,7 @@
import { Box, Heading, HStack, VStack, Image, Text, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers, Player } from '../../services/players';
import { assetUrl } from '../../utils/url';
const TeamScroller: React.FC = () => {
const { data } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
@@ -13,7 +14,7 @@ const TeamScroller: React.FC = () => {
<HStack spacing={6} overflowX="auto" py={2} className="hide-scrollbar">
{players.map((p: Player) => (
<VStack key={p.id} minW="160px" spacing={2} bg={useColorModeValue('white', 'gray.800')} borderRadius="xl" p={4} boxShadow="sm" borderWidth="1px" borderColor={useColorModeValue('gray.200', 'gray.700')}>
<Image src={p.image_url || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" />
<Image src={assetUrl(p.image_url) || '/logo192.png'} alt={p.first_name + ' ' + p.last_name} w="140px" h="140px" objectFit="cover" borderRadius="lg" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Text fontWeight="bold" textAlign="center">{p.first_name} {p.last_name}</Text>
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.400')}>{p.position}</Text>
</VStack>
+14 -7
View File
@@ -16,6 +16,9 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
const sponsorsVariant = getVariant('sponsors', 'grid');
const footerVariant = getVariant('footer', 'standard');
const headerIsInside = headerInsideContainer && headerVariant !== 'fullwidth';
useEffect(() => {
const onScroll = () => {
@@ -39,10 +42,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
{headerInsideContainer ? (
{headerIsInside ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -51,14 +54,16 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
</Box>
{children}
</Container>
<SponsorsSection />
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
<Box as="header" data-element="header" data-variant={headerVariant} style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
@@ -69,8 +74,10 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideCo
{children}
</Container>
{/* Global sponsors section across front-facing pages */}
<SponsorsSection />
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Box data-element="sponsors" data-variant={sponsorsVariant} style={{ ...getStyles('sponsors') }}>
<SponsorsSection />
</Box>
<Box as="footer" data-element="footer" data-variant={footerVariant} style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
@@ -0,0 +1,33 @@
import React from 'react';
import { assetUrl } from '../../utils/url';
export type ActivityItem = {
id: number | string;
title: string;
image_url?: string | null;
start_time: string;
location?: string | null;
};
const ActivitiesList: React.FC<{
items: ActivityItem[];
}> = ({ items }) => {
const list = Array.isArray(items) ? items.slice(0, 4) : [];
return (
<div className="blog-list">
{list.map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `• ${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
);
};
export default ActivitiesList;
@@ -0,0 +1,127 @@
import React, { useEffect, useRef } from 'react';
import { TeamLogo } from '../common/TeamLogo';
import { sanitizeClubName } from '../../utils/url';
export type SliderMatch = {
id?: string | number;
date?: string;
time?: string;
home_id?: any;
home?: string;
home_logo_url?: string;
away_id?: any;
away?: string;
away_logo_url?: string;
venue?: string;
score?: string;
};
export type CompetitionBucket = { name: string; matches: SliderMatch[] };
const MatchesSlider: React.FC<{
title?: string;
comps: CompetitionBucket[];
activeIndex: number;
onActiveChange: (idx: number) => void;
onMatchClick?: (m: SliderMatch, compName?: string) => void;
elementProps?: any;
}> = ({ title = 'Zápasy', comps, activeIndex, onActiveChange, onMatchClick, elementProps }) => {
const trackRef = useRef<HTMLDivElement | null>(null);
const current = comps[Math.max(0, Math.min(activeIndex, comps.length - 1))];
// Auto-center closest match by current time when comp/tab changes
useEffect(() => {
try {
const el = trackRef.current;
if (!el) return;
const items = Array.isArray(current?.matches) ? current!.matches : [];
const now = Date.now();
let best = 0;
let bestDiff = Number.POSITIVE_INFINITY;
items.forEach((m, idx) => {
const iso = `${m.date || ''}T${(m.time || '00:00')}:00`;
const t = new Date(iso).getTime();
if (!isNaN(t)) {
const d = Math.abs(t - now);
if (d < bestDiff) { bestDiff = d; best = idx; }
}
});
const child = el.children?.[best] as HTMLElement | undefined;
if (!child) return;
const run = () => {
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
};
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(run); else setTimeout(run, 0);
} catch {}
}, [activeIndex, JSON.stringify(current?.matches)]);
return (
<section className="matches-slider" {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card"
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
<div className="match-meta">
<span>{(m.venue || '').split(',')[0] || ''}</span>
<span>•</span>
<span>{m.date ? new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString() : (m.time || '')}</span>
</div>
<div className="teams">
<div className="team">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
alt={m.home}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.home || '')}</div>
</div>
<div className="score">
{m.score ? (
<>
<span className="home">{String(m.score).split(':')[0]}</span>
<span className="sep">:</span>
<span className="away">{String(m.score).split(':')[1]}</span>
</>
) : (
<span className="time">{m.time}</span>
)}
</div>
<div className="team">
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
alt={m.away}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.away || '')}</div>
</div>
</div>
</div>
))}
</div>
<div className="matches-tabs">
{comps.map((c, i) => (
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
))}
</div>
</div>
</section>
);
};
export default MatchesSlider;
+48
View File
@@ -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;
+91 -25
View File
@@ -6,6 +6,7 @@ import {
Spinner,
Text,
useColorModeValue,
SimpleGrid,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPolls, getPoll } from '../../services/polls';
@@ -17,6 +18,7 @@ interface EmbeddedPollProps {
videoUrl?: string;
title?: string;
showTitle?: boolean;
maxPolls?: number;
}
/**
@@ -29,6 +31,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
videoUrl,
title = 'Hlasování',
showTitle = true,
maxPolls,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
@@ -46,16 +49,31 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
staleTime: 2 * 60 * 1000,
});
// Get full poll data for each
const pollsToDisplay = polls?.slice(0, 3) || []; // Max 3 polls per content
// Get full poll data for each (all linked polls)
const pollsToDisplay = polls || [];
const preSortedLimited = React.useMemo(() => {
const sorted = [...pollsToDisplay].sort((a, b) => {
const aRating = a.type === 'rating' ? 1 : 0;
const bRating = b.type === 'rating' ? 1 : 0;
if (aRating !== bRating) return bRating - aRating;
const aFeat = a.featured ? 1 : 0;
const bFeat = b.featured ? 1 : 0;
if (aFeat !== bFeat) return bFeat - aFeat;
const aDate = new Date(a.created_at).getTime();
const bDate = new Date(b.created_at).getTime();
return bDate - aDate;
});
return typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
}, [pollsToDisplay, maxPolls]);
const { data: pollsData, isLoading: isLoadingPolls } = useQuery({
queryKey: ['embedded-polls-details', pollsToDisplay.map((p) => p.id)],
queryKey: ['embedded-polls-details', preSortedLimited.map((p) => p.id)],
queryFn: async () => {
const promises = pollsToDisplay.map((poll) => getPoll(poll.id));
const promises = preSortedLimited.map((poll) => getPoll(poll.id));
return await Promise.all(promises);
},
enabled: pollsToDisplay.length > 0,
enabled: preSortedLimited.length > 0,
});
// Don't render anything if no content identifier provided
@@ -84,35 +102,83 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<VStack spacing={6} maxW="3xl" mx="auto">
<VStack spacing={6} maxW="6xl" mx="auto">
{showTitle && (
<Heading size="md" textAlign="center">
{title}
</Heading>
)}
<VStack spacing={4} w="full">
{isLoadingPolls ? (
<VStack py={8}>
<Spinner />
<Text>Načítání...</Text>
</VStack>
) : (
pollsData.map((pollResponse) => (
<Box key={pollResponse.poll.id} w="full">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))
)}
</VStack>
{isLoadingPolls ? (
<VStack py={8}>
<Spinner />
<Text>Načítání...</Text>
</VStack>
) : (
(() => {
// Sort: rating first, then featured, then newest
const sorted = [...(pollsData || [])].sort((a, b) => {
const aRating = a.poll.type === 'rating' ? 1 : 0;
const bRating = b.poll.type === 'rating' ? 1 : 0;
if (aRating !== bRating) return bRating - aRating;
const aFeat = a.poll.featured ? 1 : 0;
const bFeat = b.poll.featured ? 1 : 0;
if (aFeat !== bFeat) return bFeat - aFeat;
const aDate = new Date(a.poll.created_at).getTime();
const bDate = new Date(b.poll.created_at).getTime();
return bDate - aDate;
});
const limited = typeof maxPolls === 'number' ? sorted.slice(0, maxPolls) : sorted;
const count = limited.length;
if (count === 1) {
const pollResponse = limited[0];
return (
<Box w="full">
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
);
}
if (count === 2) {
return (
<SimpleGrid w="full" columns={{ base: 1, md: 2 }} spacing={4}>
{limited.map((pollResponse) => (
<Box key={pollResponse.poll.id}>
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</SimpleGrid>
);
}
return (
<SimpleGrid w="full" columns={{ base: 1, sm: 2, lg: 3 }} spacing={4}>
{limited.map((pollResponse) => (
<Box key={pollResponse.poll.id}>
<PollCard
poll={pollResponse.poll}
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
/>
</Box>
))}
</SimpleGrid>
);
})()
)}
</VStack>
</Box>
);
};
export default EmbeddedPoll;
+19 -6
View File
@@ -20,11 +20,12 @@ import {
FormLabel,
Link,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
PollResultsResponse,
votePoll,
getPollResults,
generateSessionToken,
@@ -87,6 +88,16 @@ const PollCard: React.FC<PollCardProps> = ({
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
// Live results polling when results are visible and allowed
const showLiveResults = canShowResults && showingResults;
const { data: liveResultsData } = useQuery<PollResultsResponse>({
queryKey: ['poll-results', poll.id],
queryFn: () => getPollResults(poll.id),
enabled: showLiveResults,
refetchInterval: 4000,
refetchOnWindowFocus: true,
});
// Vote mutation
const voteMutation = useMutation({
mutationFn: () => {
@@ -113,6 +124,7 @@ const PollCard: React.FC<PollCardProps> = ({
queryClient.invalidateQueries({ queryKey: ['polls'] });
queryClient.invalidateQueries({ queryKey: ['poll', poll.id] });
queryClient.invalidateQueries({ queryKey: ['poll-results', poll.id] });
toast({
title: 'Hlas zaznamenán!',
@@ -227,13 +239,14 @@ const PollCard: React.FC<PollCardProps> = ({
// Show results if available
if (showingResults && canShowResults) {
const displayResults = results.length > 0 ? results : poll.options.map(opt => ({
const totalVotesToShow = liveResultsData?.total_votes ?? poll.total_votes;
const displayResults = liveResultsData?.results || (results.length > 0 ? results : poll.options.map(opt => ({
option_id: opt.id,
text: opt.text,
vote_count: opt.vote_count,
percentage: calculatePercentage(opt.vote_count),
percentage: totalVotesToShow ? (opt.vote_count / totalVotesToShow) * 100 : 0,
image_url: opt.image_url,
}));
})));
return (
<Box
@@ -275,7 +288,7 @@ const PollCard: React.FC<PollCardProps> = ({
<VStack spacing={3} align="stretch">
<Text fontWeight="bold" fontSize="sm" color="gray.500">
Výsledky ({poll.total_votes} hlasů)
Výsledky ({totalVotesToShow} hlasů)
</Text>
{displayResults.map((result) => (
<Box key={result.option_id}>
@@ -569,7 +582,7 @@ const PollCard: React.FC<PollCardProps> = ({
)}
<Text fontSize="xs" color="gray.500" textAlign="center">
Celkem hlasů: {poll.total_votes}
Celkem hlasů: {liveResultsData?.total_votes ?? poll.total_votes}
</Text>
</VStack>
</Box>
@@ -83,6 +83,7 @@ export const MatchesWidget = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const getLogo = (teamName?: string, original?: string) => {
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
@@ -147,12 +148,14 @@ export const MatchesWidget = () => {
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
home: m.home || m.home_team,
away: m.away || m.away_team,
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
away: (m.away_id && byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : (m.away || m.away_team),
score: m.score,
venue: m.venue,
home_logo_url: getLogo(m.home || m.home_team, m.home_logo_url),
away_logo_url: getLogo(m.away || m.away_team, m.away_logo_url),
home_logo_url: (m.home_id && byId?.[m.home_id]?.logo_url) ? String(byId[m.home_id].logo_url) : getLogo(m.home || m.home_team, m.home_logo_url),
away_logo_url: (m.away_id && byId?.[m.away_id]?.logo_url) ? String(byId[m.away_id].logo_url) : getLogo(m.away || m.away_team, m.away_logo_url),
home_id: m.home_id,
away_id: m.away_id,
})) as Match[];
return upcoming;
+2 -2
View File
@@ -134,8 +134,8 @@ export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ chil
textOnAccent = pickTextColor(accent!);
}
// Prefer logo from logoapi.sportcreative.eu when club ID is known
if (clubId) {
// Prefer local logo from settings; only fetch from logoapi when no explicit local logo is configured
if (!explicitLogo && clubId) {
try {
const apiLogo = await fetchLogoFromLogoAPI(String(clubId), name);
if (apiLogo) {
+18
View File
@@ -6,7 +6,9 @@ import { PageElementConfig } from '../services/pageElements';
// Elements that are actually implemented on HomePage
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'style-pack', // Global style pack selector
'header', // Site navigation/header
'hero-topbar', // Club bar above hero
'hero', // Hero section with news cards (grid/scroller/swiper variants)
'news', // Featured news articles
'matches', // Upcoming/recent matches
@@ -23,6 +25,14 @@ export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
];
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'style-pack',
variant: 'default',
visible: true,
display_order: -1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'header',
@@ -31,6 +41,14 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
display_order: 0,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero-topbar',
variant: 'brand',
visible: true,
display_order: 1,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero',
+3 -2
View File
@@ -23,6 +23,7 @@ import { getCategories, CategoryItem } from '../services/categories';
import { Link as RouterLink } from 'react-router-dom';
import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
import { assetUrl } from '../utils/url';
type AboutPageData = {
title: string;
@@ -155,7 +156,7 @@ const AboutPage: React.FC = () => {
<Helmet>
<title>{settings?.club_name ? `O klubu | ${settings.club_name}` : 'O klubu'}</title>
<meta name="description" content="Informace o našem klubu, soutěžích, nadcházejících zápasech a rubrikách." />
{settings?.club_logo_url && <meta property="og:image" content={settings.club_logo_url} />}
{settings?.club_logo_url && <meta property="og:image" content={assetUrl(settings.club_logo_url) || settings.club_logo_url} />}
</Helmet>
<Container maxW="container.lg" py={8}>
<Box textAlign="center" py={6}>
@@ -177,7 +178,7 @@ const AboutPage: React.FC = () => {
const seoTitle = data.seo_title || data.title;
const seoDesc = data.seo_description || data.subtitle;
const clubName = settings?.club_name || data.title;
const clubLogo = settings?.club_logo_url;
const clubLogo = settings?.club_logo_url ? (assetUrl(settings.club_logo_url) || settings.club_logo_url) : undefined;
const cleanContent = DOMPurify.sanitize(data.content);
const renderContent = () => {
+29 -2
View File
@@ -6,6 +6,7 @@ import { Box, Container, Heading, Text, VStack, HStack, Badge, Spinner, Button,
import { FiDownload, FiMapPin, FiClock } from 'react-icons/fi';
import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import { trackEvent as umamiTrackEvent } from '../utils/umami';
import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview';
@@ -35,6 +36,24 @@ const ActivityDetailPage: React.FC = () => {
const typeLabel = (t?: string) => t === 'match' ? 'Zápas' : t === 'training' ? 'Trénink' : t === 'meeting' ? 'Schůzka' : 'Jiné';
const typeColor = (t?: string) => t === 'match' ? 'red' : t === 'training' ? 'blue' : t === 'meeting' ? 'green' : 'gray';
// Delegate click tracking for links inside activity description
const contentRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const a = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
if (a && a.href) {
const href = a.getAttribute('href') || a.href;
try { umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'activity_content' }); } catch {}
}
};
el.addEventListener('click', handler);
return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]);
// Extract YouTube video ID from various URL formats
const getYouTubeEmbedUrl = (url: string): string | null => {
if (!url) return null;
@@ -92,7 +111,14 @@ const ActivityDetailPage: React.FC = () => {
<VStack align="stretch" spacing={5}>
{data.image_url && (
<Box borderRadius="xl" overflow="hidden" borderWidth="1px">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" maxH="420px" objectFit="cover" />
<Image
src={assetUrl(data.image_url) || data.image_url}
alt={data.title}
w="100%"
maxH="420px"
objectFit="cover"
fallbackSrc="/dist/img/logo-club-empty.svg"
/>
</Box>
)}
@@ -132,6 +158,7 @@ const ActivityDetailPage: React.FC = () => {
' a': { color: linkColor, textDecoration: 'underline', _hover: { color: linkHoverColor } },
' img': { maxWidth: '100%', borderRadius: 'md' },
}}
ref={contentRef}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(String(data.description)) }}
/>
)}
@@ -161,7 +188,7 @@ const ActivityDetailPage: React.FC = () => {
)}
{data?.id && (
<EmbeddedPoll eventId={data.id} />
<EmbeddedPoll eventId={data.id} maxPolls={2} />
)}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
+255 -153
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio } from '@chakra-ui/react';
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView } from '../services/articles';
@@ -13,6 +13,11 @@ import React from 'react';
import { trackEvent as umamiTrackEvent, trackMatchView as umamiTrackMatchView, trackVideoPlay as umamiTrackVideoPlay, trackArticleView as umamiTrackArticleView } from '../utils/umami';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import TeamLogo from '../components/common/TeamLogo';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
const toText = (html?: string) => {
if (!html) return '';
@@ -29,6 +34,22 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// UI colors and public settings
const { data: publicSettings } = usePublicSettings();
const cardBg = useColorModeValue('white','gray.900');
const videoBg = useColorModeValue('gray.50','gray.800');
const textMuted = useColorModeValue('gray.600','gray.400');
// Hoist all color mode values to top-level to avoid conditional hook calls
const videoTitleColor = useColorModeValue('gray.700','gray.300');
const galleryBg = useColorModeValue('blue.50','blue.900');
const galleryBorder = useColorModeValue('blue.200','blue.700');
const attachmentsBg = useColorModeValue('gray.50','gray.800');
// Derive opponent color (for right edge fade) from team logo
const [opponentColor, setOpponentColor] = React.useState<string | null>(null);
// Placeholders; moved tracking effects below to avoid using variables before declaration
// Track article view when data is loaded
@@ -40,6 +61,27 @@ const ArticleDetailPage: React.FC = () => {
}
}, [data]);
// Delegated click tracking for normal links inside content
const contentRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => {
const el = contentRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
let target = e.target as HTMLElement | null;
if (!target) return;
// Find nearest anchor
const anchor = (target.closest ? target.closest('a') : null) as HTMLAnchorElement | null;
if (anchor && anchor.href) {
try {
const href = anchor.getAttribute('href') || anchor.href;
umamiTrackEvent('Link Click', { href, page: window.location.pathname, context: 'article_content' });
} catch {}
}
};
el.addEventListener('click', handler);
return () => { el.removeEventListener('click', handler); };
}, [contentRef.current]);
// Fetch linked match (public)
const matchLinkQuery = useQuery({
queryKey: ['article-match-link', (data as any)?.id],
@@ -81,10 +123,10 @@ const ArticleDetailPage: React.FC = () => {
staleTime: 60_000,
});
// Fetch gallery album if article has one
// Fetch gallery album if article has one (fallback to URL when ID is missing)
const galleryAlbumQuery = useQuery({
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id],
enabled: Boolean((data as any)?.gallery_album_id),
queryKey: ['article-gallery-album', (data as any)?.gallery_album_id || (data as any)?.gallery_album_url],
enabled: Boolean((data as any)?.gallery_album_id || (data as any)?.gallery_album_url),
queryFn: async () => {
const albumId = (data as any)?.gallery_album_id;
let photoIds: string[] = [];
@@ -139,6 +181,24 @@ const ArticleDetailPage: React.FC = () => {
}
}
// Fallback: load by URL via proxy endpoint
const albumUrl = (data as any)?.gallery_album_url;
if (albumUrl) {
const params = new URLSearchParams({ link: albumUrl, photo_limit: '12', rendered: 'true' });
const resp = await fetch(`${API_URL}/zonerama-album?${params.toString()}`);
if (resp.ok) {
const payload = await resp.json();
let photos: any[] = [];
if (Array.isArray(payload?.albums) && payload.albums.length > 0) {
photos = payload.albums[0]?.photos || [];
} else if (Array.isArray(payload?.photos)) {
photos = payload.photos;
}
if (photoIds.length > 0) photos = photos.filter((p: any) => photoIds.includes(p.id));
return { id: albumUrl, title: 'Album', date: '', photos } as any;
}
}
return null;
},
staleTime: 60_000,
@@ -152,15 +212,6 @@ const ArticleDetailPage: React.FC = () => {
}
}, [(data as any)?.gallery_album_id, galleryAlbumQuery.data]);
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
// Transform content to ensure /uploads URLs resolve against API origin and allow iframes
// Memoize the transformation function to prevent infinite loops
const toAbsoluteUploads = React.useCallback((html?: string) => {
@@ -177,13 +228,27 @@ const ArticleDetailPage: React.FC = () => {
}, []);
const safeContentHTML = React.useMemo(() => {
const transformed = toAbsoluteUploads(data.content);
const transformed = toAbsoluteUploads((data as any)?.content);
return DOMPurify.sanitize(transformed || '', {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen'],
});
}, [data.content, toAbsoluteUploads]);
}, [(data as any)?.content, toAbsoluteUploads]);
if (isLoading) return <Spinner />;
if (isError || !data) return <Text color="red.500">Článek nenalezen</Text>;
const title = (data as any).seo_title || data.title;
const description = (data as any).seo_description || toText(data.content).slice(0, 160);
const ogImageRaw = (data as any).og_image_url || data.image_url || '/logo512.png';
const ogImage = assetUrl(ogImageRaw) || ogImageRaw;
const canonical = typeof window !== 'undefined' ? window.location.href : undefined;
const publishedAt = (data as any).published_at || (data as any).created_at;
const monthParam = (() => {
if (!publishedAt) return '';
try { const d = new Date(publishedAt); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; } catch { return ''; }
})();
return (
<MainLayout>
@@ -243,201 +308,238 @@ const ArticleDetailPage: React.FC = () => {
})}
</script>
</Helmet>
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={4}>
<Box bg="transparent" color="inherit" py={{ base: 6, md: 8 }} mb={2}>
<Container maxW="7xl">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={3}>{data.title}</Heading>
<HStack spacing={4} fontSize="sm" color="gray.600">
{((data as any).read_time || (data as any).estimated_read_minutes) && (
<Heading as="h1" size={{ base: 'xl', md: '2xl' }} mb={2}>{data.title}</Heading>
<HStack spacing={2} rowGap={2} wrap="wrap" fontSize="sm" color={textMuted}>
{((data as any).read_time || (data as any).estimated_read_minutes) ? (
<HStack spacing={1}>
<Clock size={16} />
<Text>{(data as any).read_time || (data as any).estimated_read_minutes} min čtení</Text>
</HStack>
) : null}
{publishedAt && (
<Tag as={RouterLink} to={`/news?month=${monthParam}`} size="sm" variant="subtle">{new Date(publishedAt).toLocaleDateString('cs-CZ')}</Tag>
)}
{(data as any).view_count !== undefined && (data as any).view_count > 0 && (
<HStack spacing={1}>
{(data as any)?.category?.id && (
<Tag as={RouterLink} to={`/news?category_id=${(data as any).category.id}`} size="sm" variant="subtle">{(data as any).category.name || 'Kategorie'}</Tag>
)}
{(matchLinkQuery.data as any)?.external_match_id && (
<Tag as={RouterLink} to={`/news?match_id=${(matchLinkQuery.data as any).external_match_id}`} size="sm" variant="subtle">Zápas</Tag>
)}
{(data as any).view_count ? (
<HStack spacing={1} ml={{ base: 0, md: 2 }}>
<Eye size={16} />
<Text>{(data as any).view_count} zobrazení</Text>
</HStack>
)}
) : null}
</HStack>
</Container>
</Box>
<Container maxW="7xl">
<Stack spacing={6}>
{/* Featured Image - Top */}
{/* Featured Image - smaller with subtle overlay */}
{data.image_url && (
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} borderRadius="lg" />
<Box position="relative" borderRadius="xl" overflow="hidden">
<Image src={assetUrl(data.image_url) || data.image_url} alt={data.title} w="100%" h={{ base: '220px', md: '360px' }} objectFit="cover" />
<Box position="absolute" inset={0} bg="brand.primary" opacity={0.08} pointerEvents="none" />
<Box position="absolute" inset={0} bgGradient="linear(to-b, rgba(0,0,0,0.12), rgba(0,0,0,0.02))" pointerEvents="none" />
</Box>
)}
{/* YouTube Video Section - If attached to article */}
{/* YouTube Video Section - smaller and rounded */}
{(data as any)?.youtube_video_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={videoBg}>
<Heading as="h3" size="md" mb={2}>🎬 Video k článku</Heading>
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
{ (data as any).youtube_video_title ? (
<Text mt={2} color="gray.700">{(data as any).youtube_video_title}</Text>
) : null }
<Box maxW="3xl" mx="auto" borderRadius="lg" overflow="hidden">
<AspectRatio ratio={16 / 9}>
<Box
as="iframe"
src={`https://www.youtube-nocookie.com/embed/${(data as any).youtube_video_id}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={(data as any).youtube_video_title || 'YouTube video'}
onLoad={() => umamiTrackEvent('Video Widget Shown', { id: (data as any).youtube_video_id, title: (data as any).youtube_video_title })}
onClick={() => umamiTrackVideoPlay((data as any).youtube_video_id, (data as any).youtube_video_title)}
/>
</AspectRatio>
</Box>
{(data as any).youtube_video_title ? (
<Text mt={2} color={videoTitleColor}>{(data as any).youtube_video_title}</Text>
) : null}
</Box>
)}
{/* Match Section - After Image */}
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="gray.50">
<HStack justify="space-between" align="start">
<Box>
<Heading as="h3" size="md" mb={1}>Zápas k článku</Heading>
{facrMatchQuery.isLoading ? (
<Text color="gray.600">Načítám údaje o zápasu…</Text>
) : facrMatchQuery.data ? (
<>
<HStack spacing={2} wrap="wrap">
{facrMatchQuery.data.competitionName && (
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
)}
<Badge>{String(facrMatchQuery.data.date_time || facrMatchQuery.data.date || '')}</Badge>
</HStack>
<Text mt={2} fontWeight="600">
{String(facrMatchQuery.data.home || facrMatchQuery.data.home_team || '')}
{' '}
{String(facrMatchQuery.data.score || (facrMatchQuery.data.result_home!=null && facrMatchQuery.data.result_away!=null ? `${facrMatchQuery.data.result_home}:${facrMatchQuery.data.result_away}` : 'vs'))}
{' '}
{String(facrMatchQuery.data.away || facrMatchQuery.data.away_team || '')}
</Text>
{facrMatchQuery.data.venue && (
<Text color="gray.600">{String(facrMatchQuery.data.venue)}</Text>
)}
{facrMatchQuery.data.report_url && (
<Box mt={2}>
<Link href={String(facrMatchQuery.data.report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)}
</>
) : (
<Text color="gray.600">Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
{/* Edge fades */}
<Box position="absolute" top={0} left={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-r, var(--club-primary, #0b5cff), transparent)`} pointerEvents="none" />
{opponentColor && (
<Box position="absolute" top={0} right={0} bottom={0} w={{ base: '6px', md: '12px' }} bgGradient={`linear(to-l, ${opponentColor}, transparent)`} pointerEvents="none" />
)}
<Heading as="h3" size="md" mb={3}>Zápas k článku</Heading>
{facrMatchQuery.isLoading ? (
<Text color={textMuted}>Načítám údaje o zápasu…</Text>
) : facrMatchQuery.data ? (
<>
<HStack spacing={2} wrap="wrap" mb={3}>
{facrMatchQuery.data.competitionName && (
<Badge colorScheme="blue">{String(facrMatchQuery.data.competitionName)}</Badge>
)}
<Badge>{String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '')}</Badge>
</HStack>
<Flex align="center" justify="space-between" gap={4}>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).home_team_id || (facrMatchQuery.data as any).home_id || '')} teamName={String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).home || (facrMatchQuery.data as any).home_team || '')}</Text>
</VStack>
<VStack minW={{ base: '100px', md: '140px' }}>
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
const hasScore = ((facrMatchQuery.data as any).result_home != null && (facrMatchQuery.data as any).result_away != null) || Boolean((facrMatchQuery.data as any).score && (facrMatchQuery.data as any).score !== 'vs');
if (hasScore) {
const score = String((facrMatchQuery.data as any).score || `${(facrMatchQuery.data as any).result_home}:${(facrMatchQuery.data as any).result_away}`);
return (<Heading size="2xl">{score}</Heading>);
}
const now = Date.now();
const ms = d.getTime() - now;
const days = Math.max(0, Math.floor(ms / (1000*60*60*24)));
const hours = Math.max(0, Math.floor((ms % (1000*60*60*24))/(1000*60*60)));
const mins = Math.max(0, Math.floor((ms % (1000*60*60))/(1000*60)));
return (<Text fontSize="lg" fontWeight="700">Za {days} d {hours} h {mins} min</Text>);
})()}
{(facrMatchQuery.data as any).venue && <Text fontSize="sm" color={textMuted}>{String((facrMatchQuery.data as any).venue)}</Text>}
{(() => {
const dRaw = String((facrMatchQuery.data as any).date_time || (facrMatchQuery.data as any).date || '');
const d = new Date(dRaw);
return <Text fontSize="sm" color={textMuted}>{d.toLocaleDateString('cs-CZ')} {d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}</Text>;
})()}
</VStack>
<VStack flex={1} spacing={2} minW="0">
<TeamLogo size="custom" style={{ width: 64, height: 64 }} teamId={String((facrMatchQuery.data as any).away_team_id || (facrMatchQuery.data as any).away_id || '')} teamName={String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')} />
<Text fontWeight="600" noOfLines={2} textAlign="center">{String((facrMatchQuery.data as any).away || (facrMatchQuery.data as any).away_team || '')}</Text>
</VStack>
</Flex>
{(facrMatchQuery.data as any).report_url && (
<Box mt={3}>
<Link href={String((facrMatchQuery.data as any).report_url)} isExternal color="blue.600">Protokol zápasu (fotbal.cz)</Link>
</Box>
)}
</Box>
</HStack>
</>
) : (
<Text color={textMuted}>Propojeno s FACR ID: {(matchLinkQuery.data as any)?.external_match_id}</Text>
)}
</Box>
)}
{/* Article Content - Main Section */}
{/* Article Content - Main Section with editor-like lists */}
<Box
className="article-content"
bg="white"
bg={useColorModeValue('white','gray.900')}
borderRadius="lg"
p={{ base: 4, md: 6 }}
ref={contentRef}
sx={{ 'ul, ol': { pl: 6, listStylePosition: 'outside' }, 'ul': { listStyleType: 'disc' }, 'ol': { listStyleType: 'decimal' }, 'li': { mb: 2 } }}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{/* Gallery Section - At the End */}
{(data as any)?.gallery_album_id && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg="blue.50" borderColor="blue.200">
{/* Gallery Section - Mosaic of 5 images with grayscale + hover color */}
{((data as any)?.gallery_album_id || (data as any)?.gallery_album_url) && (
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={galleryBg} borderColor={galleryBorder}>
<Box mb={3}>
<HStack justify="space-between" align="center" mb={2}>
<Heading as="h3" size="md">Fotogalerie k článku</Heading>
<Button
as={RouterLink}
to={`/galerie/album/${(data as any).gallery_album_id}`}
to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'}
size="sm"
colorScheme="blue"
variant="outline"
rightIcon={<ArrowRight size={16} />}
onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}
>
Zobrazit celé album
Zobrazit galerii
</Button>
</HStack>
{galleryAlbumQuery.isLoading ? (
<Text color="gray.600">Načítám fotografie…</Text>
) : galleryAlbumQuery.data ? (
<>
<HStack spacing={2} mb={3}>
<Badge colorScheme="purple">
{galleryAlbumQuery.data.title}
</Badge>
{galleryAlbumQuery.data.date && (
<Badge>{galleryAlbumQuery.data.date}</Badge>
)}
<Badge colorScheme="blue">
{galleryAlbumQuery.data.photos?.length || 0} foto
</Badge>
</HStack>
{/* Photo Grid */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (
<SimpleGrid columns={{ base: 2, sm: 3, md: 4, lg: 6 }} spacing={2}>
{galleryAlbumQuery.data.photos.slice(0, 12).map((photo: any) => (
<Box
key={photo.id}
as={RouterLink}
to={`/galerie/album/${(data as any).gallery_album_id}`}
position="relative"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)', boxShadow: 'lg' }}
onClick={() => umamiTrackEvent('Gallery Photo Click', { album_id: (data as any).gallery_album_id, photo_id: photo.id })}
>
<Image
src={photo.image_1500}
alt={`Fotka ${photo.id}`}
w="100%"
h="120px"
objectFit="cover"
/>
</Box>
{/* Custom 5-image mosaic */}
{galleryAlbumQuery.data.photos && galleryAlbumQuery.data.photos.length > 0 && (() => {
const photos = galleryAlbumQuery.data.photos.slice(0, 5);
if (photos.length < 5) {
return (
<SimpleGrid columns={{ base: 2, sm: 3 }} spacing={2}>
{photos.map((p: any) => (
<Image key={p.id} src={p.image_1500} alt={String(p.id)} w="100%" h="140px" objectFit="cover" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} />
))}
</SimpleGrid>
)}
);
}
return (
<Box position="relative" sx={{
display: 'grid',
gridTemplateColumns: '1fr 1.2fr 1fr',
gridTemplateRows: 'repeat(2, 140px)',
gap: '8px'
}}>
<Image src={photos[0].image_1500} alt={String(photos[0].id)} sx={{ gridColumn: 1, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[1].image_1500} alt={String(photos[1].id)} sx={{ gridColumn: 1, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[2].image_1500} alt={String(photos[2].id)} sx={{ gridColumn: 2, gridRow: '1 / span 2' }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[3].image_1500} alt={String(photos[3].id)} sx={{ gridColumn: 3, gridRow: 1 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Image src={photos[4].image_1500} alt={String(photos[4].id)} sx={{ gridColumn: 3, gridRow: 2 }} objectFit="cover" w="100%" h="100%" filter="grayscale(100%)" _hover={{ filter: 'grayscale(0%)' }} borderRadius="md" />
<Button as={RouterLink} to={(data as any).gallery_album_id ? `/galerie/album/${(data as any).gallery_album_id}` : '#'} size="sm" colorScheme="blue" position="absolute" top="50%" left="50%" transform="translate(-50%, -50%)" onClick={() => umamiTrackEvent('Gallery Link Click', { album_id: (data as any).gallery_album_id })}>Zobrazit galerii</Button>
</Box>
);
})()}
{/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text>
<Link
href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal
fontWeight="600"
color="blue.600"
display="inline-flex"
alignItems="center"
gap={1}
>
Zonerama
<ExternalLink size={12} />
</Link>
</HStack>
</>
) : (
<Text color="gray.600">Album s ID: {(data as any).gallery_album_id}</Text>
)}
{/* Zonerama Attribution */}
<HStack mt={3} spacing={1} fontSize="xs" color="blue.700">
<Text>📸 Fotografie z</Text>
<Link
href={(data as any).gallery_album_url || `https://zonerama.com`}
isExternal
fontWeight="600"
color="blue.600"
display="inline-flex"
alignItems="center"
gap={1}
>
Zonerama
<ExternalLink size={12} />
</Link>
</HStack>
</Box>
</Box>
)}
{/* Embedded Poll - directly under content/gallery */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
</Stack>
</Container>
</Box>
{/* Embedded Poll - shows polls related to this article */}
{data?.id && <EmbeddedPoll articleId={data.id} />}
{/* Attachments - bottom above CTA */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Container maxW="7xl" mt={4}>
<Box borderWidth="1px" borderRadius="lg" p={{ base: 3, md: 4 }} bg={attachmentsBg}>
<Heading as="h3" size="md" mb={2}>Přílohy</Heading>
<Stack spacing={2}>
{(data as any).attachments.map((f: any, idx: number) => (
<HStack key={idx} justify="space-between">
<Text noOfLines={1}>{f.name || f.url}</Text>
<FilePreview url={assetUrl(f.url) || f.url} name={f.name || ''} mimeType={f.mime_type || ''} size={f.size} />
</HStack>
))}
</Stack>
</Box>
</Container>
)}
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Newsletter CTA */}
<NewsletterCTA />
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
{/* Sponsors Section */}
<SponsorsSection />
</MainLayout>
);
};
export default ArticleDetailPage;
+21 -4
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue } from '@chakra-ui/react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated } from '../services/articles';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useSearchParams } from 'react-router-dom';
import { assetUrl } from '../utils/url';
import MainLayout from '../components/layout/MainLayout';
import { getCategories, CategoryItem } from '../services/categories';
@@ -92,7 +92,14 @@ const BlogTile: React.FC<{ article: Article }> = ({ article }) => {
const BlogPage: React.FC = () => {
const pageSize = 18;
const [categories, setCategories] = React.useState<CategoryItem[]>([]);
const [categoryId, setCategoryId] = React.useState<number | ''>('');
const [searchParams, setSearchParams] = useSearchParams();
const initialCategory = React.useMemo(() => {
const cid = searchParams.get('category_id');
return cid ? Number(cid) : '';
}, []);
const [categoryId, setCategoryId] = React.useState<number | ''>(initialCategory);
const month = searchParams.get('month') || '';
const matchId = searchParams.get('match_id') || '';
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
@@ -111,13 +118,15 @@ const BlogPage: React.FC = () => {
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<Paginated<Article>>(
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined }],
['articles-public', { page_size: pageSize, published: true, category_id: categoryId || undefined, month: month || undefined, match_id: matchId || undefined }],
({ pageParam = 1 }) =>
getArticles({
page: pageParam,
page_size: pageSize,
published: true,
...(categoryId ? { category_id: Number(categoryId) } : {}),
...(month ? { month } : {}),
...(matchId ? { match_id: matchId } : {}),
}),
{
getNextPageParam: (lastPage, allPages) => {
@@ -159,7 +168,15 @@ const BlogPage: React.FC = () => {
maxW={{ base: '52%', md: '320px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
setCategoryId(val);
const next: Record<string, string> = {};
if (val) next.category_id = String(val);
if (month) next.month = month;
if (matchId) next.match_id = matchId;
setSearchParams(next);
}}
>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
+39 -13
View File
@@ -13,6 +13,7 @@ import { sortCategoriesWithOrder } from '../utils/categorySort';
import ClubModal from '../components/home/ClubModal';
import { assetUrl } from '../utils/url';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
// Weekday headers (Czech, starting Monday)
const WEEKDAYS_SHORT: string[] = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
@@ -226,9 +227,16 @@ const CalendarPage: React.FC = () => {
} catch {}
}
const byName: Record<string, string> = (overrides?.by_name || {}) as any;
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const getOverrideLogo = (teamName?: string, original?: string) => {
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
// Prefer admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url as string;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const normName = normalize(teamName);
@@ -260,17 +268,19 @@ const CalendarPage: React.FC = () => {
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home: homeName,
away: awayName,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
home_logo_url: getOverrideLogo(homeName, m.home_logo_url, m.home_id),
away_logo_url: getOverrideLogo(awayName, m.away_logo_url, m.away_id),
report_url: m.report_url,
facr_link: m.facr_link,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
@@ -309,13 +319,13 @@ const CalendarPage: React.FC = () => {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: m.home,
away: m.away,
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
home_logo_url: getOverrideLogo(m.home, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url),
home_logo_url: getOverrideLogo(m.home, m.home_logo_url, m.home_id),
away_logo_url: getOverrideLogo(m.away, m.away_logo_url, m.away_id),
report_url: m.report_url,
score: score && /\d+\s*:\s*\d+/.test(score) ? score.replace(/\s+/g,'') : undefined,
} as MatchItem;
@@ -381,7 +391,7 @@ const CalendarPage: React.FC = () => {
})();
return {
position: Number(r.rank || idx + 1),
team_name: teamName,
team_name: (teamId && byId?.[teamId]?.name && String(byId[teamId].name).trim()) ? String(byId[teamId].name) : teamName,
team_id: teamId,
points: Number(r.points || r.pts || 0),
played: Number(r.played || r.matches || 0),
@@ -390,7 +400,7 @@ const CalendarPage: React.FC = () => {
losses: Number(r.losses || r.loss || 0),
goals_for: Number(r.goals_for ?? r.gf ?? r.goalsFor ?? r.scored ?? r.goals ?? 0),
goals_against: Number(r.goals_against ?? r.ga ?? r.goalsAgainst ?? r.conceded ?? 0),
logo_url: r.team_logo_url || undefined,
logo_url: (teamId && byId?.[teamId]?.logo_url) ? String(byId[teamId].logo_url) : (r.team_logo_url || undefined),
};
}),
}));
@@ -660,10 +670,26 @@ const CalendarPage: React.FC = () => {
<Badge colorScheme="purple">{m.__compName || c.name}</Badge>
</Flex>
<Flex align="center" gap={2} justify="center">
{m.home_logo_url && <Image src={m.home_logo_url} alt={m.home} boxSize="18px" borderRadius="full" objectFit="cover" />}
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
boxSize="18px"
alt={m.home}
borderRadius="full"
/>
<Text fontSize="sm">{m.home}</Text>
<Badge colorScheme={getSentiment(m)?.color || 'gray'}>{m.score || 'vs'}</Badge>
{m.away_logo_url && <Image src={m.away_logo_url} alt={m.away} boxSize="18px" borderRadius="full" objectFit="cover" />}
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
boxSize="18px"
alt={m.away}
borderRadius="full"
/>
<Text fontSize="sm">{m.away}</Text>
</Flex>
{href && <Link href={href} isExternal onClick={(e)=> e.stopPropagation()} display="none"/>}
+123 -322
View File
@@ -3,6 +3,8 @@ import MainLayout from '../components/layout/MainLayout';
import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import '../styles/theme.css';
import '../styles/sparta-styles.css';
import '../styles/club-styles.css';
import '../styles/home-style-pack.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl, sanitizeClubName } from '../utils/url';
@@ -25,6 +27,12 @@ import MatchModal from '../components/home/MatchModal';
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
import { API_URL } from '../services/api';
import { TeamLogo } from '../components/common/TeamLogo';
import ClubHeroTopbar from '../components/home/ClubHeroTopbar';
import NewsList from '../components/pack/NewsList';
import StandingsCard from '../components/pack/StandingsCard';
import NextMatch from '../components/pack/NextMatch';
import MatchesSlider from '../components/pack/MatchesSlider';
import ActivitiesList from '../components/pack/ActivitiesList';
// Types for real API-driven data
type NewsItem = {
@@ -87,9 +95,7 @@ const HomePage: React.FC = () => {
// Index for the NEXT MATCH competition carousel
const [nextCompIdx, setNextCompIdx] = useState<number>(0);
const [nextMatchLink, setNextMatchLink] = useState<string | undefined>(undefined);
// Ref to the draggable matches track and per-competition closest index
const trackRef = useRef<HTMLDivElement | null>(null);
const [closestIndexByComp, setClosestIndexByComp] = useState<number[]>([]);
// Matches slider auto-centering handled internally by MatchesSlider component
// API-driven players and sponsors
type UiPlayer = { id:number|string; name:string; number?:number; position?:string; image?:string; slug?:string };
@@ -114,6 +120,16 @@ const HomePage: React.FC = () => {
// MyUIbrix element configuration hook for live preview
const { getVariant, isVisible, getStyles, loading: configLoading, refreshKey } = useAllPageElementConfigs('homepage');
const stylePack = getVariant('style-pack', 'default');
useEffect(() => {
try {
const cls = `style-pack-${stylePack}`;
const all = ['style-pack-default','style-pack-modern','style-pack-minimal','style-pack-sparta'];
all.forEach(c => document.body.classList.remove(c));
document.body.classList.add(cls);
} catch {}
}, [stylePack]);
const heroFallbackArticles = useMemo(() => featured.map((item, index) => ({
id: typeof item.id === 'number' ? item.id : index,
@@ -360,22 +376,6 @@ const HomePage: React.FC = () => {
});
setFacrCompetitions(comps);
// Compute closest match index per competition to current time
const nowTs = Date.now();
const closestIdx: number[] = comps.map((c: { matches: any[] }) => {
let bestIdx = -1;
let bestDiff = Number.POSITIVE_INFINITY;
(c.matches || []).forEach((m: any, idx: number) => {
const ts = new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime();
if (!isNaN(ts)) {
const diff = Math.abs(ts - nowTs);
if (diff < bestDiff) { bestDiff = diff; bestIdx = idx; }
}
});
return bestIdx;
});
setClosestIndexByComp(closestIdx);
// Next match FACR link
const first = filteredMatches?.[0];
setNextMatchLink((first && (first.facr_link || first.report_url)) || comps?.[0]?.matches_link || facrClubJSON?.url);
@@ -528,24 +528,7 @@ const HomePage: React.FC = () => {
};
}, []);
// Auto-scroll matches track to the closest match for current tab
useEffect(() => {
const el = trackRef.current;
if (!el) return;
const idx = (closestIndexByComp[matchesTab] ?? 0);
const child = el.children?.[idx] as HTMLElement | undefined;
if (!child) return;
const run = () => {
const targetLeft = child.offsetLeft - (el.clientWidth - child.clientWidth) / 2;
el.scrollTo({ left: Math.max(0, targetLeft), behavior: 'smooth' });
};
// Wait for layout
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(run);
} else {
setTimeout(run, 0);
}
}, [facrCompetitions, matchesTab, closestIndexByComp]);
// Removed: legacy auto-scroll. Handled by MatchesSlider.
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
@@ -1340,26 +1323,38 @@ const HomePage: React.FC = () => {
return (
<MainLayout headerInsideContainer>
<div className="container" data-element="container" style={{ ...getStyles('container') }}>
{/* Header: logo + club name */}
<div className="home-header">
<TeamLogo
teamId={settings?.club_id}
teamName={clubName}
facrLogo={assetUrl(clubLogo) || undefined}
size="custom"
alt="Klub"
borderRadius="full"
style={{ width: 56, height: 56 }}
/>
<div>
<h1 style={{ margin: 0 }}>{clubName}</h1>
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
<div data-element="style-pack" data-variant={stylePack} style={{ display: 'none' }} />
{/* Above-hero club bar (MyUIbrix managed) */}
{isVisible('hero-topbar', true) && (
<section data-element="hero-topbar" data-variant={getVariant('hero-topbar', 'brand')} style={{ ...getStyles('hero-topbar') }}>
<ClubHeroTopbar
variant={(getVariant('hero-topbar', 'brand') as any) as 'brand' | 'minimal' | 'badge'}
fullBleed={getVariant('header', 'unified') === 'fullwidth'}
/>
</section>
)}
{/* Header: logo + club name (legacy). Hidden when hero-topbar is visible */}
{!isVisible('hero-topbar', true) && (
<div className="home-header">
<TeamLogo
teamId={settings?.club_id}
teamName={clubName}
facrLogo={assetUrl(clubLogo) || undefined}
size="custom"
alt="Klub"
borderRadius="full"
style={{ width: 56, height: 56 }}
/>
<div>
<h1 style={{ margin: 0 }}>{clubName}</h1>
<div className="subtitle" style={{ fontSize: '0.95rem' }}>Oficiální web klubu</div>
</div>
</div>
</div>
)}
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
<section key={`hero-grid-${refreshKey}`} data-element="hero" className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
<section key={`hero-grid-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} className="hero-grid" style={{ position: 'relative', ...getStyles('hero') }}>
{featured[0] ? (
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
@@ -1401,7 +1396,7 @@ const HomePage: React.FC = () => {
)}
{/* Banner: homepage_middle */}
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
<section data-element="banner" data-variant={getVariant('banner', 'top')} className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_middle').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
@@ -1415,7 +1410,7 @@ const HomePage: React.FC = () => {
{/* Sidebar banners (homepage_sidebar) */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section data-element="sidebar" className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
<section data-element="sidebar" data-variant={getVariant('sidebar', 'right')} className="banner banner-sidebar" style={{ margin: '24px 0', ...getStyles('sidebar') }}>
{/* Simple responsive behavior: stack on mobile, sticky right rail on desktop */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ width: 320, maxWidth: '100%', position: 'sticky' as const, top: 96 }}>
@@ -1430,12 +1425,12 @@ const HomePage: React.FC = () => {
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section key={`hero-scroller-${refreshKey}`} data-element="hero" style={{ position: 'relative', ...getStyles('hero') }}>
<section key={`hero-scroller-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={{ position: 'relative', ...getStyles('hero') }}>
<BlogCardsScroller />
</section>
)}
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
<section key={`hero-swiper-${refreshKey}`} data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
<section key={`hero-swiper-${refreshKey}`} data-element="hero" data-variant={getVariant('hero', heroStyle)} style={getVariant('hero', heroStyle) === 'swiper_full' ? { position: 'relative', marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)', ...getStyles('hero') } : { position: 'relative', ...getStyles('hero') }}>
<BlogSwiper fallbackArticles={heroFallbackArticles}
/>
</section>
@@ -1457,155 +1452,54 @@ const HomePage: React.FC = () => {
setSelectedMatch({
...show,
competition: comp?.name,
competitionName: comp?.name,
});
setIsMatchModalOpen(true);
} else if (link) {
window.open(link, '_blank', 'noopener,noreferrer');
}
};
return (
<section data-element="matches" className="next-match" onClick={handleNextMatchClick} style={{ cursor: 'pointer', position: 'relative', ...getStyles('matches') }}>
<button
aria-label="Předchozí soutěž"
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length); }}
className="nav prev"
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
>
<FiChevronLeft size={24} />
</button>
<div className="team">
<TeamLogo
className="logo"
teamId={show?.home_id}
teamName={show?.home}
facrLogo={show?.home_logo_url}
size="custom"
alt="Domácí"
borderRadius="full"
/>
<div>{sanitizeClubName(show?.home || matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</div>
{countdown || '—'}
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
</div>
<div className="team">
<TeamLogo
className="logo"
teamId={show?.away_id}
teamName={show?.away}
facrLogo={show?.away_logo_url}
size="custom"
alt="Hosté"
borderRadius="full"
/>
<div>{sanitizeClubName(show?.away || matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
<button
aria-label="Další soutěž"
onClick={(e) => { e.stopPropagation(); setMatchesTab((i) => (i + 1) % facrCompetitions.length); }}
className="nav next"
style={{ background:'transparent', border:'none', color:'var(--text-on-primary)' }}
>
<FiChevronRight size={24} />
</button>
</section>
<NextMatch
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : isVisible('matches', true) ? (
<section data-element="matches" className="next-match" style={{ position: 'relative', ...getStyles('matches') }}>
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
{countdown || '—'}
<div style={{ fontSize: '0.8rem', opacity: 0.85 }}>Začátek zápasu</div>
{nextMatchLink && (
<div style={{ marginTop: 6 }}>
<a href={nextMatchLink} target="_blank" rel="noopener noreferrer" style={{ color: '#fff', textDecoration: 'underline', fontSize: '0.85rem' }}>Detail na FACR</a>
</div>
)}
</div>
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
</section>
<NextMatch
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
/>
) : null}
{/* Matches slider with scores by competition (moved after news+tables) */}
{facrCompetitions.length > 0 && (
<section data-element="matches-slider" className="matches-slider" style={{ position: 'relative', ...getStyles('matches-slider') }}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 16 }}>
<h3>Zápasy</h3>
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
</div>
<div className="matches-grid">
<div className="matches-track" ref={trackRef}>
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
const handleMatchClick = (e: React.MouseEvent) => {
e.preventDefault();
setSelectedMatch({
...m,
competition: facrCompetitions[matchesTab]?.name,
competitionName: facrCompetitions[matchesTab]?.name,
});
setIsMatchModalOpen(true);
};
return (
<div key={m.id || idx} className="match-card" onClick={handleMatchClick} style={{ cursor: 'pointer' }}>
<div className="match-meta">
<span>{(m.venue || '').split(',')[0] || ''}</span>
<span>•</span>
<span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</span>
</div>
<div className="teams">
<div className="team">
<TeamLogo
teamId={m.home_id}
teamName={m.home}
facrLogo={m.home_logo_url}
size="custom"
alt={m.home}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.home)}</div>
</div>
<div className="score">
{m.score ? (
<>
<span className="home">{String(m.score).split(':')[0]}</span>
<span className="sep">:</span>
<span className="away">{String(m.score).split(':')[1]}</span>
</>
) : (
<span className="time">{m.time}</span>
)}
</div>
<div className="team">
<TeamLogo
teamId={m.away_id}
teamName={m.away}
facrLogo={m.away_logo_url}
size="custom"
alt={m.away}
borderRadius="full"
/>
<div className="name">{sanitizeClubName(m.away)}</div>
</div>
</div>
</div>
);
})}
</div>
<div className="matches-tabs">
{facrCompetitions.map((c, i) => (
<button key={`${c.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>{c.name}</button>
))}
</div>
</div>
</section>
<MatchesSlider
comps={facrCompetitions as any}
activeIndex={matchesTab}
onActiveChange={setMatchesTab}
onMatchClick={(m: any, compName?: string) => {
setSelectedMatch({ ...m, competition: compName, competitionName: compName });
setIsMatchModalOpen(true);
}}
elementProps={{ 'data-element': 'matches-slider', 'data-variant': getVariant('matches-slider', 'carousel'), style: { position: 'relative', ...getStyles('matches-slider') } }}
/>
)}
{/* News + Tables: split into two independent sections */}
@@ -1631,121 +1525,40 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section data-element="news" className="news-list" style={{ ...getStyles('news') }}>
<section data-element="news" data-variant={getVariant('news', 'grid')} className="news-list" style={{ ...getStyles('news') }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<div className="blog-list">
{news.length > 0 ? news.slice(0, 4).map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{n.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>{n.excerpt}</div>
</div>
</a>
)) : (
<div style={{ padding: '24px', textAlign: 'center', color: 'var(--dark-gray)', background: 'var(--bg-soft)', borderRadius: '12px' }}>
<p>Zatím nejsou k dispozici žádné aktuality.</p>
</div>
)}
</div>
{news.length > 0 && (
<div style={{ marginTop: 12 }}>
<a className="btn" href="/news">Zobrazit všechny aktuality</a>
</div>
)}
<NewsList items={news as any} />
</section>
)}
{showTable && (
<div data-element="table" style={{ ...getStyles('table') }}>
<div className="table-card">
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<div className="standings-table-wrapper" style={{ overflowX: 'auto' }}>
<table className="standings-table-compact" style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 4px' }}>
<thead>
<tr style={{ fontSize: '0.75rem', color: 'var(--dark-gray)', textTransform: 'uppercase' }}>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>#</th>
<th style={{ padding: '6px 8px', textAlign: 'left', fontWeight: 600 }}>Tým</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>Z</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>V</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>R</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600 }}>P</th>
<th style={{ padding: '6px 4px', textAlign: 'center', fontWeight: 600, display: 'none' }} className="hide-mobile">Skóre</th>
<th style={{ padding: '6px 8px', textAlign: 'center', fontWeight: 600 }}>Body</th>
</tr>
</thead>
<tbody>
{(matchingStanding?.table || matchingStanding?.rows || []).slice(0,8).map((row: any, idx: number) => {
const handleClick = () => {
const clubData = {
team: row.team?.name ?? row.team ?? row.club ?? '-',
team_id: row.team_id || '',
team_logo_url: row.team_logo_url,
rank: row.position ?? row.pos ?? row.rank ?? idx+1,
played: row.played ?? row.matches ?? '-',
wins: row.wins ?? row.win ?? '-',
draws: row.draws ?? row.draw ?? '-',
losses: row.losses ?? row.loss ?? '-',
score: row.score ?? '-',
points: row.points ?? row.pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
};
return (
<tr
key={idx}
onClick={handleClick}
style={{
cursor: 'pointer',
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: '8px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.08)';
e.currentTarget.style.borderColor = 'var(--primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.borderColor = 'var(--card-border)';
}}
>
<td style={{ padding: '10px 8px', fontWeight: 700, color: 'var(--primary)', fontSize: '0.9rem' }}>#{row.position ?? row.pos ?? row.rank ?? idx+1}</td>
<td style={{ padding: '10px 8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
{row.team_logo_url && (
<TeamLogo
teamId={row.team_id}
teamName={row.team?.name ?? row.team ?? row.club}
facrLogo={row.team_logo_url}
size="custom"
alt={row.team?.name ?? row.team ?? row.club ?? '-'}
style={{ width: '24px', height: '24px', borderRadius: '50%', objectFit: 'cover', background: 'var(--bg-soft)', border: '1px solid var(--card-border)', flexShrink: 0 }}
/>
)}
<span style={{ fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{row.team?.name ?? row.team ?? row.club ?? '-'}</span>
</div>
</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.played ?? row.matches ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.wins ?? row.win ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.draws ?? row.draw ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)' }}>{row.losses ?? row.loss ?? '-'}</td>
<td style={{ padding: '10px 4px', textAlign: 'center', fontSize: '0.85rem', color: 'var(--text)', display: 'none' }} className="hide-mobile">{row.score ?? '-'}</td>
<td style={{ padding: '10px 8px', textAlign: 'center', fontWeight: 700, color: 'var(--secondary)', fontSize: '1rem' }}>{row.points ?? row.pts ?? '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<StandingsCard
rows={(matchingStanding?.table || matchingStanding?.rows || []) as any}
onRowClick={(row) => {
const clubData = {
team: (row as any).team?.name ?? (row as any).team ?? (row as any).club ?? '-',
team_id: (row as any).team_id || '',
team_logo_url: (row as any).team_logo_url,
rank: (row as any).position ?? (row as any).pos ?? (row as any).rank ?? 0,
played: (row as any).played ?? (row as any).matches ?? '-',
wins: (row as any).wins ?? (row as any).win ?? '-',
draws: (row as any).draws ?? (row as any).draw ?? '-',
losses: (row as any).losses ?? (row as any).loss ?? '-',
score: (row as any).score ?? '-',
points: (row as any).points ?? (row as any).pts ?? '-',
};
setSelectedClub(clubData);
setIsModalOpen(true);
}}
/>
</div>
)}
</section>
@@ -1755,32 +1568,20 @@ const HomePage: React.FC = () => {
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section data-element="activities" style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<section data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="blog-list">
{upcomingEvents.slice(0,4).map((e) => (
<a key={e.id} href={`/aktivita/${e.id}`} className="card" style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="thumb" style={{ backgroundImage: `url(${assetUrl(e.image_url) || '/images/news/placeholder.jpg'})` }} />
<div>
<h4>{e.title}</h4>
<div style={{ color: 'var(--dark-gray)', fontSize: '0.9rem' }}>
{new Date(e.start_time).toLocaleDateString()} {e.location ? `• ${e.location}` : ''}
</div>
</div>
</a>
))}
</div>
<ActivitiesList items={upcomingEvents as any} />
</div>
</section>
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
<div className="section-head">
<h3>Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
@@ -1799,7 +1600,7 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section data-element="gallery" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<section data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<GallerySection zoneramaUrl={galleryUrl} />
</div>
@@ -1808,7 +1609,7 @@ const HomePage: React.FC = () => {
{/* Videos */}
{isVisible('videos', false) && (
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section data-element="videos" data-variant={getVariant('videos', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection />
</div>
@@ -1816,7 +1617,7 @@ const HomePage: React.FC = () => {
)}
{isVisible('merch', true) && (
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<section data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<MerchSection />
</div>
@@ -1825,7 +1626,7 @@ const HomePage: React.FC = () => {
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section data-element="poll" style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<section data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
@@ -1834,7 +1635,7 @@ const HomePage: React.FC = () => {
{/* Banner: homepage_footer */}
{(banners || []).some(b => b.placement === 'homepage_footer') && (
<section data-element="banner" className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
<section data-element="banner" data-variant={getVariant('banner', 'bottom')} className="banner banner-footer" style={{ margin: '24px 0', textAlign: 'center', ...getStyles('banner') }}>
{(banners || []).filter(b => b.placement === 'homepage_footer').map((b) => (
<a key={b.id} href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'inline-block', margin: 8 }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
@@ -1846,7 +1647,7 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<section data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
+26 -5
View File
@@ -277,8 +277,9 @@ const MatchesPage: React.FC = () => {
setAliasMap(amap);
// Build override helpers
const teamLogoOverridesJSON = (teamLogoOverridesAPI && teamLogoOverridesAPI.by_name) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
const teamLogoOverridesJSON = (teamLogoOverridesAPI && (teamLogoOverridesAPI.by_name || teamLogoOverridesAPI.by_id)) ? teamLogoOverridesAPI : (teamLogoOverridesFile || {});
const byName: Record<string, string> = (teamLogoOverridesJSON?.by_name || {}) as any;
const byId: Record<string, { name?: string; logo_url?: string }> = (teamLogoOverridesJSON?.by_id || {}) as any;
const normalize = (s: string) => String(s)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -321,9 +322,27 @@ const MatchesPage: React.FC = () => {
};
const getOverrideLogo = (teamName?: string, teamId?: string, original?: string) => {
// Prefer explicit admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url as string;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
// Prefer local club logo for our own team
try {
if (
teamId && settingsJSON?.club_id && String(teamId) === String(settingsJSON.club_id) && settingsJSON?.club_logo_url
) {
const local = settingsJSON.club_logo_url as string;
if (typeof local === 'string' && local.startsWith('/')) return resolveBackendUrl(local);
return local;
}
} catch {}
// If we have a team ID and no explicit override, use logoapi as a high-quality source
if (teamId) {
return `http://logoapi.sportcreative.eu/logos/${teamId}`;
}
// Fallback to by-name overrides or original
return getFallbackLogo(teamName, original);
};
@@ -351,6 +370,8 @@ const MatchesPage: React.FC = () => {
const [day, month, year] = (d || '').split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
// Check if match is in the future - if so, ignore score
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
@@ -361,12 +382,12 @@ const MatchesPage: React.FC = () => {
id: m.match_id || idx + 1,
date: isoDate,
time,
home: m.home,
away: m.away,
home: homeName,
away: awayName,
home_id: m.home_id,
away_id: m.away_id,
home_logo_url: getOverrideLogo(m.home, m.home_id, m.home_logo_url),
away_logo_url: getOverrideLogo(m.away, m.away_id, m.away_logo_url),
home_logo_url: getOverrideLogo(homeName, m.home_id, m.home_logo_url),
away_logo_url: getOverrideLogo(awayName, m.away_id, m.away_logo_url),
score: actualScore,
facr_link: m.facr_link,
report_url: m.report_url,
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
@@ -15,6 +16,11 @@ import {
AlertIcon,
Card,
CardBody,
SimpleGrid,
Badge,
Divider,
Spinner,
Spacer,
} from '@chakra-ui/react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -54,8 +60,9 @@ const NewsletterPreferencesPage: React.FC = () => {
blogs: true,
matches: true,
events: true,
scores: false,
...(data?.preferences || {} as SubscriberPreferences),
scores: true,
weekly: true,
...(data?.preferences || ({} as SubscriberPreferences)),
}), [data]);
const [prefs, setPrefs] = useState<SubscriberPreferences>(initialPrefs);
@@ -117,7 +124,14 @@ const NewsletterPreferencesPage: React.FC = () => {
}
if (isLoading) {
return <Box maxW="640px" mx="auto" p={6}><Text>Načítání…</Text></Box>;
return (
<Container maxW="container.md" py={8}>
<HStack>
<Spinner />
<Text>Načítání…</Text>
</HStack>
</Container>
);
}
if (isError || !data) {
@@ -129,54 +143,83 @@ const NewsletterPreferencesPage: React.FC = () => {
}
// helpers to toggle all main content types on/off
const setAll = (on: boolean) => setPrefs({
...prefs,
blogs: on,
matches: on,
events: on,
scores: on,
weekly: on,
});
return (
<Box maxW="720px" mx="auto" p={6}>
<Heading size="lg" mb={2}>Nastavení newsletteru</Heading>
<Text color="gray.600" mb={6}>Spravujte, jaké e-maily chcete dostávat na adresu {data.email}.</Text>
<Container maxW="container.md" py={8}>
<HStack mb={2} align="center">
<Heading size="lg">Nastavení newsletteru</Heading>
<Spacer />
<Button size="sm" variant="outline" onClick={() => setAll(true)}>Zapnout vše</Button>
<Button size="sm" variant="ghost" onClick={() => setAll(false)}>Vypnout vše</Button>
</HStack>
<HStack mb={6} color="gray.600">
<Text>Spravujte, jaké e-maily chcete dostávat na adresu</Text>
<Badge colorScheme={data.is_active ? 'green' : 'red'}>{data.email}</Badge>
</HStack>
<Card mb={6}><CardBody>
<VStack spacing={4} align="stretch">
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Články (blog)</FormLabel>
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Události</FormLabel>
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
</FormControl>
<FormControl>
<FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string;
const checked = selectedCodes.has(String(code));
return (
<HStack key={code} justify="space-between">
<Text>{name}</Text>
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
)}
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
</FormControl>
</VStack>
</CardBody></Card>
<Card mb={6}>
<CardBody>
<VStack spacing={6} align="stretch">
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Články (blog)</FormLabel>
<Switch isChecked={!!prefs.blogs} onChange={(e) => setPrefs({ ...prefs, blogs: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Nadcházející zápasy</FormLabel>
<Switch isChecked={!!prefs.matches} onChange={(e) => setPrefs({ ...prefs, matches: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Události</FormLabel>
<Switch isChecked={!!prefs.events} onChange={(e) => setPrefs({ ...prefs, events: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Výsledky (souhrn týdne)</FormLabel>
<Switch isChecked={!!prefs.scores} onChange={(e) => setPrefs({ ...prefs, scores: e.target.checked })} />
</FormControl>
<FormControl display="flex" alignItems="center" justifyContent="space-between">
<FormLabel m={0}>Týdenní souhrn (digest)</FormLabel>
<Switch isChecked={!!(prefs as any).weekly} onChange={(e) => setPrefs({ ...prefs, weekly: e.target.checked })} />
</FormControl>
</SimpleGrid>
<HStack spacing={3}>
<Divider />
<FormControl>
<FormLabel>Preferované soutěže</FormLabel>
{Array.isArray(competitions) && competitions.length > 0 ? (
<VStack align="stretch" spacing={1} maxH="220px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{competitions.map((c: any, idx: number) => {
const code = (c?.code || c?.id || c?.name || `comp-${idx}`) as string;
const name = (c?.name || c?.code || code) as string;
const checked = selectedCodes.has(String(code));
return (
<HStack key={code} justify="space-between">
<Text>{name}</Text>
<Switch isChecked={checked} onChange={(e) => toggleCode(String(code), e.target.checked)} />
</HStack>
);
})}
</VStack>
) : (
<Input placeholder="např. 5LM, POH" value={(prefs.competitions as string) || ''} onChange={(e) => setPrefs({ ...prefs, competitions: e.target.value })} />
)}
<Text mt={2} fontSize="sm" color="gray.500">Pokud není seznam k dispozici, můžete zadat kódy soutěží ručně, oddělené čárkou.</Text>
</FormControl>
</VStack>
</CardBody>
</Card>
<HStack spacing={3} pt={2} borderTopWidth="1px">
<Button
colorScheme="blue"
onClick={() => saveMut.mutate()}
@@ -192,6 +235,7 @@ const NewsletterPreferencesPage: React.FC = () => {
>
Obnovit
</Button>
<Spacer />
<Button
colorScheme="red"
variant="outline"
@@ -203,7 +247,7 @@ const NewsletterPreferencesPage: React.FC = () => {
Zrušit odběr
</Button>
</HStack>
</Box>
</Container>
);
};
+2 -1
View File
@@ -1,6 +1,7 @@
import { Box, Container, Heading, Image, SimpleGrid, Spinner, Stack, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getPlayers } from '../services/public';
import type { Player } from '../services/public';
import { assetUrl } from '../utils/url';
import { Link as RouterLink } from 'react-router-dom';
import MainLayout from '../components/layout/MainLayout';
@@ -8,7 +9,7 @@ import SponsorsSection from '../components/common/SponsorsSection';
import NewsletterCTA from '../components/common/NewsletterCTA';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery({ queryKey: ['players'], queryFn: getPlayers });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: getPlayers });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
+5 -4
View File
@@ -106,6 +106,7 @@ const SetupPage: React.FC = () => {
const [smtpUser, setSmtpUser] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [showSmtpPass, setShowSmtpPass] = useState(false);
const [smtpUserEdited, setSmtpUserEdited] = useState(false);
// Sender display name only; actual email is derived from smtpUser
const [smtpFromName, setSmtpFromName] = useState('');
const [smtpTLS, setSmtpTLS] = useState(true);
@@ -192,10 +193,10 @@ const SetupPage: React.FC = () => {
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser && isValidEmail(contactEmail)) {
if (contactEmail && isValidEmail(contactEmail) && !smtpUserEdited) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
}, [contactEmail, smtpUserEdited]);
const handleSelectClub = async (item: SearchResult) => {
const clubIdValue = item.club_id || '';
@@ -791,7 +792,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl>
<FormLabel>E-mail</FormLabel>
<Input type="email" placeholder="[email protected]" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUser && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<Input type="email" placeholder="[email protected]" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} onBlur={() => { if (!smtpUserEdited && isValidEmail(contactEmail)) { setSmtpUser(contactEmail); } }} />
<FormHelperText>Hlavní kontaktní e-mail klubu</FormHelperText>
</FormControl>
</SimpleGrid>
@@ -816,7 +817,7 @@ const SetupPage: React.FC = () => {
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP uživatelské jméno</FormLabel>
<Input value={smtpUser} onChange={(e) => setSmtpUser(e.target.value)} />
<Input value={smtpUser} onChange={(e) => { setSmtpUser(e.target.value); setSmtpUserEdited(true); }} />
</FormControl>
<FormControl mb={3}>
<FormLabel>SMTP heslo</FormLabel>
+31
View File
@@ -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;
+10 -3
View File
@@ -141,15 +141,22 @@ const TablesPage: React.FC = () => {
)}
{!!competitions.length && (
<Tabs variant="enclosed">
<TabList>
<Tabs variant="enclosed" size="sm">
<TabList px={2} pt={2} overflowX="auto" overflowY="hidden" css={{
'&::-webkit-scrollbar': { height: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: 'var(--chakra-colors-gray-300)', borderRadius: '4px' },
}}>
{competitions.map((c) => (
<Tab
key={c.id}
_selected={{ bg: 'brand.primary', color: 'text.onPrimary', borderColor: 'brand.primary' }}
_hover={{ bg: 'rgba(0,0,0,0.04)' }}
flex="0 0 auto"
px={3}
py={2}
>
{c.name}
<Text as="span" noOfLines={1} maxW="300px" title={c.name}>{c.name}</Text>
</Tab>
))}
</TabList>
@@ -40,7 +40,7 @@ import {
useColorModeValue,
Image as ChakraImage,
} from '@chakra-ui/react';
import { FiEdit2, FiPlus, FiTrash2 } from 'react-icons/fi';
import { FiEdit2, FiPlus, FiTrash2, FiLink } from 'react-icons/fi';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Event } from '../../types/event';
import { uploadFile } from '../../services/articles';
@@ -60,9 +60,10 @@ import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
import { FiVideo, FiYoutube } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
import { createShortLink } from '../../services/shortlinks';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
@@ -124,6 +125,13 @@ const AdminActivitiesPage: React.FC = () => {
});
const events = data || [];
// Localized label for event type
const typeLabel = (t?: string) => {
const v = String(t || '').trim() as any;
const found = types.find((x) => x.value === v);
return found ? found.label : 'Jiné';
};
// Load club YouTube videos
useEffect(() => {
(async () => {
@@ -266,22 +274,18 @@ const AdminActivitiesPage: React.FC = () => {
const e = editing || {};
// Build a helpful Czech prompt including known fields
const lines: string[] = [];
const clubName = String(settingsQ?.data?.club_name || '').trim();
if (clubName) lines.push(`Klub: ${clubName}`);
if (e.type) lines.push(`Typ: ${e.type}`);
if (e.location) lines.push(`Místo: ${e.location}`);
if (e.start_time) {
try { lines.push(`Začátek: ${new Date(e.start_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.end_time) {
try { lines.push(`Konec: ${new Date(e.end_time as any).toLocaleString('cs-CZ')}`); } catch {}
}
if (e.description) lines.push(`Poznámky: ${e.description}`);
const base = lines.join('\n');
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti.\nDetaily:\n${base}`.trim();
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
const { data } = await api.post('/ai/blog/generate', {
prompt,
audience: 'Fanoušci klubu, oznámení/pozvánka',
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
min_words: 120,
});
@@ -485,7 +489,7 @@ const AdminActivitiesPage: React.FC = () => {
)}
</Td>
<Td>{ev.title}</Td>
<Td>{ev.type}</Td>
<Td>{typeLabel(ev.type as any)}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
@@ -494,6 +498,23 @@ const AdminActivitiesPage: React.FC = () => {
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => deleteMut.mutate(ev.id)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const target = `${origin}/aktivita/${ev.id}`;
const res = await createShortLink({ target_url: target, title: ev.title, source_type: 'event', source_id: ev.id as any });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -70,7 +70,8 @@ import {
FiZap,
FiTrendingUp,
FiCalendar,
FiSearch
FiSearch,
FiInfo
} from 'react-icons/fi';
// Register ChartJS components
@@ -188,6 +189,11 @@ const getEventTranslation = (eventName: string): { name: string; source: string;
name: 'Kliknutí na externí odkaz',
source: 'Různé stránky',
description: 'Uživatel klikl na odkaz vedoucí mimo web'
},
'Poll Vote': {
name: 'Hlasování v anketě',
source: 'Ankety',
description: 'Uživatel hlasoval v anketě'
}
};
@@ -213,6 +219,7 @@ const AnalyticsAdminPage: React.FC = () => {
const [timeRange, setTimeRange] = useState('0'); // Default to "today"
const [hasData, setHasData] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [noDataInfo, setNoDataInfo] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<{
code: string;
name: string;
@@ -230,6 +237,7 @@ const AnalyticsAdminPage: React.FC = () => {
const fetchAnalytics = async (days: string) => {
setLoading(true);
setErrorMessage(null);
setNoDataInfo(null);
try {
const daysNum = parseInt(days);
@@ -292,14 +300,19 @@ const AnalyticsAdminPage: React.FC = () => {
setPageviewsData(pageviewsDataArray);
// Determine if we have data
const hasPageviews = pageviewsDataArray.length > 0 && pageviewsDataArray.some(d => d.value > 0);
const hasMetrics = pages.data?.length > 0 || countries.data?.length > 0;
setHasData(hasAnyStats || hasPageviews || hasMetrics);
// Set error message if no data
if (!hasAnyStats && !hasPageviews && !hasMetrics) {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
const noData = !hasAnyStats && !hasPageviews && !hasMetrics;
if (noData) {
if (daysNum <= 1) {
setNoDataInfo('Pro vybrané denní období zatím nebyla zaznamenána žádná návštěvnost. Zkuste později nebo zvolte delší období.');
} else {
setErrorMessage('Umami není správně nakonfigurováno nebo ještě nebyly zaznamenány žádné návštěvy. Zkontrolujte UMAMI_WEBSITE_ID v .env souboru.');
}
} else {
setNoDataInfo(null);
}
} catch (error) {
console.error('Failed to fetch analytics:', error);
@@ -478,6 +491,21 @@ const AnalyticsAdminPage: React.FC = () => {
</HStack>
</HStack>
{/* No data info for daily view */}
{noDataInfo && (
<Card bg="yellow.50" borderColor="yellow.300" borderWidth={2}>
<CardBody>
<HStack spacing={3} align="start">
<Icon as={FiInfo} color="yellow.600" boxSize={6} mt={1} />
<VStack align="start" spacing={1}>
<Text fontWeight="bold" color="yellow.800">Zatím žádná data</Text>
<Text fontSize="sm" color="yellow.700">{noDataInfo}</Text>
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* Stats Overview */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 5 }} spacing={4}>
<Card bg={bgColor} borderColor={borderColor}>
+100 -151
View File
@@ -8,7 +8,7 @@ import {
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw } from 'react-icons/fi';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
@@ -30,6 +30,7 @@ import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { createShortLink } from '../../services/shortlinks';
// Inline small component to show match link badge in list (with short label)
const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
@@ -581,15 +582,31 @@ const ArticlesAdminPage = () => {
return dateStr.includes(matchDateFilter);
});
}
// Sort by proximity to current date (recent matches first)
const now = Date.now();
const parseTime = (s?: string): number => {
if (!s) return Number.MAX_SAFE_INTEGER;
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?/);
if (m) {
const d = parseInt(m[1], 10);
const mo = parseInt(m[2], 10) - 1;
const y = parseInt(m[3], 10);
const h = m[4] ? parseInt(m[4], 10) : 0;
const mi = m[5] ? parseInt(m[5], 10) : 0;
return new Date(y, mo, d, h, mi).getTime();
}
const t = Date.parse(s);
return isNaN(t) ? Number.MAX_SAFE_INTEGER : t;
};
opts = opts.sort((a, b) => {
const dateA = new Date(a.date || 0).getTime();
const dateB = new Date(b.date || 0).getTime();
const diffA = Math.abs(now - dateA);
const diffB = Math.abs(now - dateB);
return diffA - diffB; // Closest to today first
const ta = parseTime(a.date);
const tb = parseTime(b.date);
const da = ta - now;
const db = tb - now;
const aUpcoming = da >= 0;
const bUpcoming = db >= 0;
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
if (aUpcoming) return da - db;
return Math.abs(da) - Math.abs(db);
});
return opts;
@@ -1267,6 +1284,25 @@ const ArticlesAdminPage = () => {
<HStack spacing={1}>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(a)} />
<IconButton aria-label="Smazat" size="sm" colorScheme="red" icon={<FiTrash2 />} onClick={() => handleDeleteArticle(a)} />
<IconButton
aria-label="Zkrátit odkaz"
size="sm"
icon={<FiLink />}
title="Zkrátit odkaz pro sdílení"
onClick={async () => {
try {
const origin = window.location.origin;
const slug = (a as any)?.slug || (a as any)?.Slug;
const path = slug ? `/news/${slug}` : `/articles/${a.id}`;
const target = `${origin}${path}`;
const res = await createShortLink({ target_url: target, title: a.title, source_type: 'article', source_id: a.id });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Zkrácený odkaz zkopírován', description: res.short_url, status: 'success', duration: 4000 });
} catch (e: any) {
toast({ title: 'Vytvoření odkazu selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
}
}}
/>
</HStack>
</Td>
</Tr>
@@ -1299,8 +1335,8 @@ const ArticlesAdminPage = () => {
<Tab>Základní</Tab>
<Tab>Obsah</Tab>
<Tab>Média</Tab>
<Tab>SEO</Tab>
<Tab>Anketa</Tab>
<Tab>SEO</Tab>
</TabList>
<TabPanels>
{/* AI first */}
@@ -1880,6 +1916,60 @@ const ArticlesAdminPage = () => {
</VStack>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(4); // Poll tab is index 4 after reordering
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
{/* SEO last - minimized */}
<TabPanel>
<Text fontSize="sm" color="gray.600" mb={4}>
@@ -1923,60 +2013,6 @@ const ArticlesAdminPage = () => {
</AccordionItem>
</Accordion>
</TabPanel>
{/* Anketa (Poll) Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Box borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('purple.50', 'purple.900')}>
<Heading as="h3" size="sm" mb={2}>📊 Ankety k článku</Heading>
<Text fontSize="sm" color="gray.700" mb={3}>
Vytvořte nebo připojte ankety přímo k tomuto článku. Ankety se zobrazí automaticky na konci článku a čtenáři mohou hlasovat.
</Text>
</Box>
{editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
}} />
) : (
<Alert status="info" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={2}>
<Text fontWeight="semibold">
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
</Text>
<Text fontSize="sm">
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
</Text>
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
{saveStatus === 'idle' && (
<Text fontSize="xs" color="gray.600">
💡 Vyplňte název článku pro aktivaci automatického ukládání
</Text>
)}
<Button
size="sm"
colorScheme="blue"
onClick={async () => {
// Force save if needed
try {
await forceSave();
// Switch to poll tab after save
setActiveTabIndex(5); // Poll tab is index 5
} catch (error) {
// Error is handled by onSubmit
}
}}
isLoading={createMut.isLoading}
>
Uložit jako koncept a přidat ankety
</Button>
</VStack>
</Alert>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -2097,7 +2133,7 @@ const ArticlesAdminPage = () => {
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
@@ -2185,94 +2221,7 @@ const ArticlesAdminPage = () => {
</ModalContent>
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Loading State */}
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{/* Albums Grid */}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} • {album.photos.length} fotografií</Text>
</VStack>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
{album.photos.map((photo) => (
<Box
key={photo.id}
borderWidth="1px"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
onClick={() => {
pickZoneramaImage({
id: photo.id,
album_id: album.id,
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
page_url: photo.page_url,
image_url: photo.image_1500,
title: album.title
});
onGalleryPickerClose();
}}
>
<AspectRatio ratio={1}>
<Image
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
/>
</AspectRatio>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{/* Empty State */}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">
Žádná alba nebyla nalezena v cache.
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Zkontrolujte nastavení Zonerama nebo obnovte cache.
</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
Obnovit seznam
</Button>
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onGalleryPickerClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Draft Recovery Modal */}
<DraftRecoveryModal
+49 -27
View File
@@ -41,6 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parse } from 'date-fns';
@@ -546,28 +547,24 @@ const MatchesAdminPage = () => {
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [showScrollHint, setShowScrollHint] = useState(true);
const thBg = useColorModeValue('gray.50', 'gray.700');
// Drag-to-scroll state
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [lastX, setLastX] = useState(0);
const [lastTime, setLastTime] = useState(0);
const lastXRef = useRef(0);
const lastTimeRef = useRef(0);
const velocityRef = useRef(0);
const animationRef = useRef<number | null>(null);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
const scrollRaf = useRef<number | null>(null);
const updateScrollShadow = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 0);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
const left = el.scrollLeft > 0;
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
if (left !== canScrollLeft) setCanScrollLeft(left);
if (right !== canScrollRight) setCanScrollRight(right);
};
// Drag-to-scroll handlers
@@ -581,8 +578,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(e.pageX);
setLastTime(Date.now());
lastXRef.current = e.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
@@ -632,13 +629,13 @@ const MatchesAdminPage = () => {
// Calculate velocity for momentum
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = e.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -653,8 +650,8 @@ const MatchesAdminPage = () => {
setIsDragging(true);
setStartX(touch.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(touch.pageX);
setLastTime(Date.now());
lastXRef.current = touch.pageX;
lastTimeRef.current = Date.now();
velocityRef.current = 0;
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
};
@@ -667,13 +664,13 @@ const MatchesAdminPage = () => {
scrollRef.current.scrollLeft = scrollLeft - walk;
const now = Date.now();
const timeDelta = now - lastTime;
const timeDelta = now - lastTimeRef.current;
if (timeDelta > 0) {
const currentX = touch.pageX;
const distance = currentX - lastX;
const distance = currentX - lastXRef.current;
velocityRef.current = distance / timeDelta * 16;
setLastX(currentX);
setLastTime(now);
lastXRef.current = currentX;
lastTimeRef.current = now;
}
};
@@ -734,6 +731,12 @@ const MatchesAdminPage = () => {
const headerText = useColorModeValue('text.onPrimary', 'white');
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const edgeGradientLeft = useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)');
const edgeGradientRight = useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)');
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
const futureMatchBg = useColorModeValue('white', 'gray.800');
const pastMatchHoverBg = useColorModeValue('gray.200', 'gray.600');
const futureMatchHoverBg = useColorModeValue('gray.50', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
@@ -856,12 +859,24 @@ const MatchesAdminPage = () => {
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
if (scrollRaf.current == null) {
scrollRaf.current = requestAnimationFrame(() => {
const el = scrollRef.current;
if (el) {
updateScrollShadow();
if (el.scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}
scrollRaf.current = null;
});
}
}}
sx={{
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
transform: 'translateZ(0)',
willChange: 'transform',
overscrollBehaviorX: 'contain',
touchAction: 'pan-x',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '14px' },
'::-webkit-scrollbar-thumb': {
@@ -885,13 +900,13 @@ const MatchesAdminPage = () => {
{/* Gradient edges to indicate horizontal scroll */}
{canScrollLeft && (
<Box position="sticky" left={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-r, white, transparent)', 'linear(to-r, gray.800, transparent)')}
bgGradient={edgeGradientLeft}
zIndex={1}
/>
)}
{canScrollRight && (
<Box position="sticky" right={0} top={0} bottom={0} w="24px" pointerEvents="none"
bgGradient={useColorModeValue('linear(to-l, white, transparent)', 'linear(to-l, gray.800, transparent)')}
bgGradient={edgeGradientRight}
zIndex={1}
/>
)}
@@ -945,6 +960,9 @@ const MatchesAdminPage = () => {
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -962,6 +980,9 @@ const MatchesAdminPage = () => {
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
loading="lazy"
decoding="async"
draggable={false}
/>
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
@@ -1167,6 +1188,7 @@ const MatchesAdminPage = () => {
</DrawerFooter>
</DrawerContent>
</Drawer>
</AdminLayout>
);
};
@@ -133,7 +133,77 @@ export default function NewsletterAdminPage() {
const [previewSubject, setPreviewSubject] = useState<string>('');
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
type MailType = 'weekly' | 'matches' | 'scores' | 'blogs' | 'events';
const mailTypeLabel: Record<MailType, string> = { weekly: 'Týdenní přehled', matches: 'Zápasy', scores: 'Výsledky', blogs: 'Novinky', events: 'Akce' };
const [detailsOpen, setDetailsOpen] = useState(false);
const [activeType, setActiveType] = useState<MailType | null>(null);
const [typePreview, setTypePreview] = useState<Record<string, { subject: string; html: string } | undefined>>({});
const [detailsCompetitions, setDetailsCompetitions] = useState<string>('');
const [detailsLoading, setDetailsLoading] = useState<boolean>(false);
const [sendNowLoading, setSendNowLoading] = useState<boolean>(false);
const openDetails = (t: MailType) => { setActiveType(t); setDetailsOpen(true); };
const closeDetails = () => { setDetailsOpen(false); setActiveType(null); setDetailsCompetitions(''); };
const recipientsForType = (t: MailType): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
return subscribers
.filter((s: any) => s.is_active && s?.preferences && s.preferences[key] === true)
.map((s: any) => s.email);
};
const getRecipientsFor = (t: MailType, comps?: string): string[] => {
const key = t === 'weekly' ? 'weekly' : t;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[key] === true);
if (!comps || !comps.trim() || (t !== 'matches' && t !== 'scores')) {
return base.map((s: any) => s.email);
}
const list = comps.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
const filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions ? prefs.competitions : (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => list.includes(v));
});
return filtered.map((s: any) => s.email);
};
const exportRecipientsCSV = (t: MailType, comps?: string) => {
const list = getRecipientsFor(t, comps);
const safeCSV = (value: any) => {
const s = String(value ?? '');
return /^[=+\-@]/.test(s) ? `'${s}` : s;
};
const header = ['email','type','competitions'];
const lines = [
header.join(','),
...list.map(e => [safeCSV(e), t, (comps || '').trim()].join(','))
];
const blob = new Blob(["\ufeff" + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter_recipients_${t}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const loadPreviewForType = async (t: MailType, comps?: string) => {
const prefs: any = {};
if (t === 'weekly') { prefs.blogs = true; prefs.events = true; prefs.matches = true; prefs.scores = true; }
else { (prefs as any)[t] = true; }
if (comps && comps.trim()) { prefs.competitions = comps.trim(); }
const res = await previewNewsletter({ preferences: prefs });
setTypePreview(prev => ({ ...prev, [t]: { subject: res.subject, html: res.html } }));
};
useEffect(() => {
if (detailsOpen && activeType && !typePreview[activeType]) {
(async () => {
try {
setDetailsLoading(true);
await loadPreviewForType(activeType!, detailsCompetitions);
} finally {
setDetailsLoading(false);
}
})();
}
}, [detailsOpen, activeType]);
const { isOpen, onOpen, onClose } = useDisclosure();
const testModal = useDisclosure();
@@ -501,6 +571,29 @@ export default function NewsletterAdminPage() {
</Box>
) : null}
</Box>
<Box bg={cardBg} borderRadius="lg" boxShadow="sm" p={4} mb={6}>
<Heading size="md" mb={3}>Typy e‑mailů</Heading>
<VStack align="stretch" spacing={3}>
{(['weekly','matches','scores','blogs','events'] as MailType[]).map((t)=>{
const count = recipientsForType(t).length;
const enabled = t === 'weekly' ? !!settings?.enable_weekly : t === 'matches' ? !!settings?.enable_match_reminders : t === 'scores' ? !!settings?.enable_results : undefined;
return (
<Flex key={t} align="center" justify="space-between" p={3} borderWidth="1px" borderRadius="md" _hover={{ bg: hoverBg }}>
<HStack spacing={3}>
<Text fontWeight="600">{mailTypeLabel[t]}</Text>
{enabled !== undefined && (
<Badge colorScheme={enabled ? 'green' : 'gray'}>{enabled ? 'Zapnuto' : 'Vypnuto'}</Badge>
)}
</HStack>
<HStack spacing={4}>
<Text color={textSecondary}>Příjemci: <b>{count}</b></Text>
<Button size="sm" onClick={()=> openDetails(t)}>Detail</Button>
</HStack>
</Flex>
);
})}
</VStack>
</Box>
</TabPanel>
<TabPanel p={0}>
{/* Scheduling controls */}
@@ -519,13 +612,13 @@ export default function NewsletterAdminPage() {
<FormControl maxW="220px">
<FormLabel>Den v týdnu</FormLabel>
<Select value={weeklyDay} onChange={(e)=> setWeeklyDay(e.target.value as any)}>
<option value="sun">Neděle</option>
<option value="mon">Pondělí</option>
<option value="tue">Úterý</option>
<option value="wed">Středa</option>
<option value="thu">Čtvrtek</option>
<option value="fri">Pátek</option>
<option value="sat">Sobota</option>
<option value="sun">Neděle</option>
</Select>
</FormControl>
<FormControl maxW="160px">
@@ -534,6 +627,41 @@ export default function NewsletterAdminPage() {
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Připomínky zápasů</Text>
<Switch isChecked={enableMatchReminders} onChange={(e)=> setEnableMatchReminders(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="220px">
<FormLabel>Odeslat před (hodin)</FormLabel>
<Input type="number" min={1} max={168} value={reminderLead} onChange={(e)=> setReminderLead(Math.max(1, Math.min(168, Number(e.target.value)||0)))} />
<FormHelperText>Výchozí 48 h před výkopem. Systém posílá i upozornění v den zápasu.</FormHelperText>
</FormControl>
</HStack>
<Box h="1px" bg={useColorModeValue('gray.200', 'gray.700')} my={2} />
<HStack justify="space-between">
<Text fontWeight="600">Výsledky po zápase</Text>
<Switch isChecked={enableResults} onChange={(e)=> setEnableResults(e.target.checked)} />
</HStack>
<HStack spacing={3}>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny od</FormLabel>
<Input type="number" min={0} max={23} value={quietStart} onChange={(e)=> setQuietStart(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
</FormControl>
<FormControl maxW="160px">
<FormLabel>Tiché hodiny do</FormLabel>
<Input type="number" min={0} max={23} value={quietEnd} onChange={(e)=> setQuietEnd(Math.max(0, Math.min(23, Number(e.target.value)||0)))} />
<FormHelperText>E-maily s výsledky se neposílají v tomto intervalu.</FormHelperText>
</FormControl>
</HStack>
<HStack pt={2}>
<Button colorScheme="blue" onClick={()=> saveScheduleMutation.mutate()} isLoading={saveScheduleMutation.isLoading}>Uložit plánování</Button>
</HStack>
</VStack>
)}
</Box>
@@ -871,6 +999,159 @@ export default function NewsletterAdminPage() {
</ModalContent>
</Modal>
<Modal isOpen={detailsOpen} onClose={closeDetails} size="5xl">
<ModalOverlay />
<ModalContent maxW="95vw" maxH="90vh" overflowY="auto">
<ModalHeader>
{activeType ? `Detail: ${mailTypeLabel[activeType]}` : 'Detail'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<HStack spacing={4} align="flex-end">
<FormControl maxW="360px">
<FormLabel>Filtr soutěží (volitelné)</FormLabel>
<Input placeholder="NAPŘ. KP, I.A, I.B" value={detailsCompetitions} onChange={(e)=> setDetailsCompetitions(e.target.value)} />
</FormControl>
<Button onClick={async()=>{ if(!activeType) return; setDetailsLoading(true); try { await loadPreviewForType(activeType, detailsCompetitions); } finally { setDetailsLoading(false); } }} isLoading={detailsLoading}>Aktualizovat náhled</Button>
{activeType && typePreview[activeType]?.subject && (
<Badge colorScheme="blue">{typePreview[activeType]!.subject}</Badge>
)}
<Button colorScheme="blue" variant="solid" isLoading={sendNowLoading} onClick={async()=>{
if(!activeType) return;
const ok = window.confirm(`Odeslat "${mailTypeLabel[activeType]}" nyní? E‑mail bude odeslán všem aktivním odběratelům.`);
if(!ok) return;
try {
setSendNowLoading(true);
await sendNewsletterDigest(activeType as DigestType, (detailsCompetitions || '').trim() || undefined);
toast({ title: 'Digest odeslán', status: 'success' });
} catch (e: any) {
toast({ title: 'Chyba při odeslání', description: e?.response?.data?.error || e?.message, status: 'error' });
} finally {
setSendNowLoading(false);
}
}}>Odeslat nyní</Button>
<Button variant="outline" onClick={()=>{ if(!activeType) return; exportRecipientsCSV(activeType, detailsCompetitions); }}>Export CSV</Button>
</HStack>
<Box p={3} bg={useColorModeValue('gray.50', 'gray.900')} borderRadius="md" borderWidth="1px">
<Box bg={cardBg} p={3} borderRadius="md" borderWidth="1px" dangerouslySetInnerHTML={{ __html: sanitizeHtml(activeType ? (typePreview[activeType]?.html || '<em>Náhled se zobrazí zde</em>') : '<em>Náhled se zobrazí zde</em>') }} />
</Box>
<Box>
<Heading size="sm" mb={2}>Příjemci</Heading>
{(() => {
let list: string[] = [];
if (activeType) {
const typeKey = activeType === 'weekly' ? 'weekly' : activeType;
const base = (subscribers as any[]).filter((s: any) => s?.is_active && s?.preferences && s.preferences[typeKey] === true);
let filtered = base;
const compsInput = (detailsCompetitions || '').trim();
if (compsInput && (activeType === 'matches' || activeType === 'scores')) {
const comps = compsInput.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean);
if (comps.length > 0) {
filtered = base.filter((s: any) => {
const prefs: any = s?.preferences || {};
const raw: string = typeof prefs.competitions === 'string' && prefs.competitions
? prefs.competitions
: (typeof prefs.categories === 'string' ? prefs.categories : '');
const arr = raw.split(',').map((x: string) => x.trim().toLowerCase()).filter(Boolean);
if (arr.length === 0) return true;
return arr.some((v: string) => comps.includes(v));
});
}
}
list = filtered.map((s: any) => s.email);
}
const shown = list.slice(0, 50);
return (
<>
{shown.length === 0 ? (
<Text color="gray.600">Žádní příjemci pro tento typ.</Text>
) : (
<VStack align="stretch" spacing={1} maxH="240px" overflowY="auto" borderWidth="1px" borderRadius="md" p={3}>
{shown.map((e)=> (<Text key={e} fontFamily="mono">{e}</Text>))}
{list.length > shown.length && (
<Text color="gray.600">… a dalších {list.length - shown.length}</Text>
)}
</VStack>
)}
</>
);
})()}
</Box>
</VStack>
</ModalBody>
<ModalFooter>
<Button onClick={closeDetails}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* SMTP Test Modal */}
<Modal isOpen={smtpModal.isOpen} onClose={smtpModal.onClose} size="lg">
<ModalOverlay />
<ModalContent maxW="90vw" maxH="90vh" overflowY="auto">
<ModalHeader>Otestovat SMTP</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<HStack spacing={3} align="flex-end">
<FormControl>
<FormLabel>Host</FormLabel>
<Input placeholder="smtp.example.com" value={smtpHost} onChange={(e)=> setSmtpHost(e.target.value)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>Port</FormLabel>
<Input type="number" placeholder="465" value={smtpPort} onChange={(e)=> setSmtpPort(Number(e.target.value)||0)} />
</FormControl>
<FormControl maxW="140px">
<FormLabel>&nbsp;</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="[email protected]" value={smtpFrom} onChange={(e)=> setSmtpFrom(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>To (kam poslat test)</FormLabel>
<Input placeholder="[email protected]" value={smtpTo} onChange={(e)=> setSmtpTo(e.target.value)} />
</FormControl>
</HStack>
<FormControl>
<FormLabel>Předmět</FormLabel>
<Input value={smtpSubject} onChange={(e)=> setSmtpSubject(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Tělo zprávy (HTML)</FormLabel>
<Textarea rows={6} value={smtpBody} onChange={(e)=> setSmtpBody(e.target.value)} />
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={smtpModal.onClose}>Zavřít</Button>
<Button colorScheme="blue" onClick={()=> adminSmtpTestMutation.mutate()} isLoading={adminSmtpTestMutation.isLoading}>Odeslat test</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Test Email Modal */}
<Modal isOpen={testModal.isOpen} onClose={testModal.onClose} size="md">
<ModalOverlay />
+11 -6
View File
@@ -235,7 +235,7 @@ const PlayersAdminPage: React.FC = () => {
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
const WEIGHT_MAX = 400;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -325,6 +325,11 @@ const PlayersAdminPage: React.FC = () => {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Require date of birth: all three values must be selected
if (!dobParts.day || !dobParts.month || !dobParts.year) {
toast({ title: 'Datum narození je povinné', description: 'Vyberte den, měsíc i rok.', status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -428,19 +433,19 @@ const PlayersAdminPage: React.FC = () => {
</FormControl>
{/* Custom DOB picker: day / month / year (timezone-safe) */}
<FormControl>
<FormControl isRequired>
<FormLabel>Datum narození</FormLabel>
<HStack>
<Select value={dobParts.day} onChange={(e) => updateDobPart('day', e.target.value)}>
<option value="">Den</option>
<option value="" disabled>Den</option>
{Array.from({ length: 31 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.month} onChange={(e) => updateDobPart('month', e.target.value)}>
<option value="">Měsíc</option>
<option value="" disabled>Měsíc</option>
{Array.from({ length: 12 }).map((_, i) => <option key={i+1} value={(i+1).toString()}>{i+1}</option>)}
</Select>
<Select value={dobParts.year} onChange={(e) => updateDobPart('year', e.target.value)}>
<option value="">Rok</option>
<option value="" disabled>Rok</option>
{Array.from({ length: 80 }).map((_, i) => { const y = new Date().getFullYear() - i; return <option key={y} value={String(y)}>{y}</option>; })}
</Select>
</HStack>
@@ -542,7 +547,7 @@ const PlayersAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Fotka</FormLabel>
<HStack>
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" />
<Image src={normalizeImageUrl(editing?.image_url)} alt="photo" boxSize="56px" objectFit="cover" borderRadius="md" fallbackSrc="/dist/img/logo-club-empty.svg" />
<Button as="label" type="button" leftIcon={<FiUpload />}>Nahrát
<Input
type="file"
+126 -71
View File
@@ -309,16 +309,19 @@ const PollsAdminPage: React.FC = () => {
};
const handleSave = () => {
// Validate that all options have text
const invalidOptions = formData.options.filter(opt => !opt.text || opt.text.trim() === '');
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
if (formData.type !== 'rating') {
const invalidOptions = formData.options.filter(
(opt) => !opt.text || opt.text.trim() === ''
);
if (invalidOptions.length > 0) {
toast({
title: 'Chyba',
description: 'Všechny možnosti musí mít vyplněný text',
status: 'error',
duration: 3000,
});
return;
}
}
if (editingPoll) {
@@ -398,6 +401,35 @@ const PollsAdminPage: React.FC = () => {
}
}, [isOpen, clubVideos.length, toast]);
// Keep rating polls consistent: enforce style and auto-generate options
useEffect(() => {
if (formData.type !== 'rating') return;
const currentStyle = (formData as any).style || 'auto';
const desiredStyle = currentStyle === 'rating-scale' ? 'rating-scale' : 'rating-stars';
const count = desiredStyle === 'rating-scale' ? 10 : 5;
const optionsMatch =
formData.options.length === count &&
formData.options.every((opt, idx) => String(opt.text) === String(idx + 1));
if (
currentStyle !== desiredStyle ||
formData.allow_multiple ||
(formData.max_choices || 1) !== 1 ||
!optionsMatch
) {
setFormData({
...formData,
style: desiredStyle as any,
allow_multiple: false,
max_choices: 1,
options: Array.from({ length: count }).map((_, i) => ({
text: String(i + 1),
display_order: i,
})),
});
}
}, [formData.type, (formData as any).style, formData.options, formData.allow_multiple, formData.max_choices]);
const getStatusBadge = (status: string) => {
const colorMap: Record<string, string> = {
draft: 'gray',
@@ -542,7 +574,7 @@ const PollsAdminPage: React.FC = () => {
<Tabs>
<TabList>
<Tab>Základní</Tab>
<Tab>Možnosti</Tab>
{formData.type !== 'rating' && <Tab>Možnosti</Tab>}
<Tab>Nastavení</Tab>
</TabList>
@@ -550,6 +582,14 @@ const PollsAdminPage: React.FC = () => {
{/* Basic Info Tab */}
<TabPanel>
<VStack spacing={4}>
<HStack w="full" justify="space-between">
<Text fontWeight="semibold">Doporučené předvolby</Text>
<HStack>
<Button size="sm" onClick={() => applyPreset('rating5')}>Hodnocení (5 hvězd)</Button>
<Button size="sm" onClick={() => applyPreset('rating10')}>Hodnocení (1–10)</Button>
<Button size="sm" onClick={() => applyPreset('attendance')}>Docházka</Button>
</HStack>
</HStack>
<FormControl isRequired>
<FormLabel>Název ankety</FormLabel>
<Input
@@ -625,6 +665,17 @@ const PollsAdminPage: React.FC = () => {
</FormControl>
</SimpleGrid>
{formData.type === 'rating' && (
<Box w="full" borderWidth="1px" borderRadius="md" p={3} bg="gray.50">
<Text fontSize="sm" mb={2}>Možnosti se generují automaticky podle stylu:</Text>
<Text fontSize="sm">
{Array.from({ length: ((formData as any).style === 'rating-scale' ? 10 : 5) })
.map((_, i) => String(i + 1))
.join(', ')}
</Text>
</Box>
)}
<SimpleGrid columns={2} spacing={4} w="full">
<FormControl>
<FormLabel>Datum zahájení</FormLabel>
@@ -722,73 +773,77 @@ const PollsAdminPage: React.FC = () => {
</TabPanel>
{/* Options Tab */}
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
{formData.type !== 'rating' && (
<TabPanel>
<VStack spacing={4} align="stretch">
{formData.options.map((option, index) => (
<Card key={index}>
<CardBody>
<HStack align="start">
<VStack flex={1} spacing={3}>
<FormControl isRequired>
<FormLabel>Možnost {index + 1}</FormLabel>
<Input
value={option.text}
onChange={(e) =>
updateOption(index, 'text', e.target.value)
}
placeholder="Text možnosti"
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
</FormControl>
<FormControl>
<FormLabel>Popis (volitelné)</FormLabel>
<Input
value={option.description || ''}
onChange={(e) =>
updateOption(index, 'description', e.target.value)
}
placeholder="Doplňující informace"
/>
</FormControl>
</VStack>
{formData.options.length > 2 && (
<IconButton
aria-label="Odstranit možnost"
icon={<DeleteIcon />}
colorScheme="red"
variant="ghost"
onClick={() => removeOption(index)}
/>
)}
</HStack>
</CardBody>
</Card>
))}
)}
</HStack>
</CardBody>
</Card>
))}
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
<Button
leftIcon={<AddIcon />}
onClick={addOption}
variant="outline"
colorScheme="blue"
>
Přidat možnost
</Button>
</VStack>
</TabPanel>
)}
{/* Settings Tab */}
<TabPanel>
<VStack spacing={4}>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
{formData.type !== 'rating' && (
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Povolit více voleb</FormLabel>
<Switch
isChecked={formData.allow_multiple}
onChange={(e) =>
setFormData({ ...formData, allow_multiple: e.target.checked })
}
/>
</FormControl>
)}
{formData.allow_multiple && (
{formData.type !== 'rating' && formData.allow_multiple && (
<FormControl>
<FormLabel>Max. počet voleb</FormLabel>
<NumberInput
@@ -178,7 +178,7 @@ const SettingsAdminPage: React.FC = () => {
smtp_from: (settings as any).smtp_from,
smtp_from_name: (settings as any).smtp_from_name,
smtp_encryption: (settings as any).smtp_encryption as any,
smtp_auth: (settings as any).smtp_auth as any,
...(typeof (settings as any).smtp_auth === 'boolean' ? { smtp_auth: (settings as any).smtp_auth as any } : {}),
smtp_skip_verify: (settings as any).smtp_skip_verify as any,
// videos module
videos_module_enabled: (settings as any).videos_module_enabled as any,
@@ -193,8 +193,9 @@ const SettingsAdminPage: React.FC = () => {
location_latitude: (settings as any).location_latitude as any,
location_longitude: (settings as any).location_longitude as any,
map_zoom_level: (settings as any).map_zoom_level as any,
// Auto-enable map display if coordinates are set
show_map_on_homepage: ((settings as any).location_latitude && (settings as any).location_longitude) as any,
show_map_on_homepage:
(typeof (settings as any).location_latitude === 'number') &&
(typeof (settings as any).location_longitude === 'number'),
map_style: (settings as any).map_style,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
@@ -0,0 +1,198 @@
import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import {
Box,
Button,
HStack,
IconButton,
Input,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useToast,
Text,
VStack,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Link as ChakraLink,
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
const [code, setCode] = React.useState('');
const [creating, setCreating] = React.useState(false);
const statsModal = useDisclosure();
const [statsLink, setStatsLink] = React.useState<any>(null);
const [statsData, setStatsData] = React.useState<any>(null);
const linksQ = useQuery({
queryKey: ['admin-shortlinks'],
queryFn: listShortLinks,
staleTime: 60_000,
});
const handleCreate = async () => {
const t = targetUrl.trim();
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
try {
setCreating(true);
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
setTargetUrl(''); setTitle(''); setCode('');
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
} catch (e: any) {
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
} finally {
setCreating(false);
}
};
const openStats = async (item: any) => {
try {
setStatsLink(item);
setStatsData(null);
statsModal.onOpen();
const data = await getShortLinkStats(item.id);
setStatsData(data);
} catch (e: any) {
toast({ title: 'Načtení statistik selhalo', status: 'error' });
}
};
return (
<AdminLayout>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
<IconButton aria-label="Obnovit" icon={<FiRefreshCcw />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-shortlinks'] })} />
</HStack>
{/* Create form */}
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="bg.card">
<Text fontWeight="semibold" mb={2}>Vytvořit nový odkaz</Text>
<HStack spacing={2} flexWrap="wrap">
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
</HStack>
</Box>
{/* List */}
<Box borderWidth="1px" borderRadius="lg" overflowX="auto" bg="bg.card">
<Table size="sm">
<Thead>
<Tr>
<Th>Kód</Th>
<Th>Cíl</Th>
<Th>Titulek</Th>
<Th>Zdroj</Th>
<Th>Prokliky</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{linksQ.data?.items?.map((it: any) => {
const shortUrl = `${window.location.origin}/s/${it.code}`;
const source = it.source_type ? `${it.source_type}${it.source_id ? `#${it.source_id}` : ''}` : '-';
return (
<Tr key={it.id}>
<Td><Badge colorScheme="blue">{it.code}</Badge></Td>
<Td maxW="420px">
<ChakraLink href={it.target_url} isExternal color="blue.600">{it.target_url}</ChakraLink>
</Td>
<Td>{it.title || '-'}</Td>
<Td>{source}</Td>
<Td>{it.click_count ?? 0}</Td>
<Td>
<HStack>
<IconButton aria-label="Otevřít krátkou URL" icon={<FiExternalLink />} as={ChakraLink as any} href={shortUrl} isExternal />
<IconButton aria-label="Zkopírovat" icon={<FiClipboard />} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
</HStack>
</Td>
</Tr>
);
})}
{(!linksQ.data?.items || linksQ.data.items.length === 0) && (
<Tr><Td colSpan={6}><Text p={3}>Žádné odkazy</Text></Td></Tr>
)}
</Tbody>
</Table>
</Box>
{/* Stats modal */}
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Statistiky: {statsLink?.code}</ModalHeader>
<ModalCloseButton />
<ModalBody>
{!statsData ? (
<Text>Načítání…</Text>
) : (
<VStack align="stretch" spacing={3}>
<Box>
<Text fontWeight="semibold" mb={1}>Prokliky za posledních 30 dní</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Den</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{statsData.timeseries?.map((row: any, idx: number) => (
<Tr key={idx}><Td>{row.date}</Td><Td isNumeric>{row.count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>Referrers (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Referrer</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.referrers || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Referrer || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
<Box>
<Text fontWeight="semibold" mb={1}>UTM kombinace (Top)</Text>
<Table size="sm" variant="simple">
<Thead><Tr><Th>Source</Th><Th>Medium</Th><Th>Campaign</Th><Th isNumeric>Počet</Th></Tr></Thead>
<Tbody>
{(statsData.utms || []).map((r: any, i: number) => (
<Tr key={i}><Td>{r.Source || '-'}</Td><Td>{r.Medium || '-'}</Td><Td>{r.Campaign || '-'}</Td><Td isNumeric>{r.Count}</Td></Tr>
))}
</Tbody>
</Table>
</Box>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button onClick={statsModal.onClose}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default ShortlinksAdminPage;
+165 -62
View File
@@ -1,4 +1,5 @@
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
import { fetchLogoFromLogoAPI } from '../../utils/sportLogosAPI';
import {
Heading,
Text,
@@ -77,6 +78,8 @@ const TeamsAdminPage = () => {
});
const competitions: any[] = Array.isArray(data?.competitions) ? data!.competitions : [];
const mainClubId: string | undefined = (data?.club_id ? String(data.club_id).toLowerCase() : undefined);
const mainClubBase: string = useMemo(() => normalize(String(data?.name || '')), [data?.name]);
// Backend origin (used to resolve relative URLs like /uploads/...)
const backendOrigin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
@@ -86,6 +89,7 @@ const TeamsAdminPage = () => {
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Fetch logos from logoapi.sportcreative.eu for all teams
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
@@ -100,6 +104,10 @@ const TeamsAdminPage = () => {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
}
}
}
@@ -122,7 +130,7 @@ const TeamsAdminPage = () => {
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
const orgPhrases = [
'fotbalovy klub',
@@ -133,7 +141,7 @@ const TeamsAdminPage = () => {
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\b)${phrase}(\b|$)`, 'g');
const re = new RegExp('(^|\\b)'+ phrase + '(\\b|$)', 'g');
out = out.replace(re, ' ');
}
// Remove common short prefixes (tokens) like FC, FK, MFK, TJ, SK, SFC, AFK at word boundaries
@@ -152,32 +160,46 @@ const TeamsAdminPage = () => {
}
return idx;
}, [byName]);
// Derive FACR team UUID from the logo URL if team_id is missing in the row
// Example: https://is1.fotbal.cz/media/kluby/<UUID>/<UUID>_crop.jpg
const deriveTeamIdFromLogoUrl = (url?: string): string | undefined => {
try {
const u = String(url || '');
if (!u) return undefined;
const m = u.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
return m ? m[0].toLowerCase() : undefined;
} catch {
return undefined;
}
};
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 1: Try logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
// Priority 2: Try exact match from local overrides
// Priority 1: Local admin override (exact + normalized)
let overrideUrl = byName[teamName];
if (!overrideUrl) {
// Fallback: diacritics-insensitive + case-insensitive + trimmed match
const norm = normalize(teamName);
overrideUrl = byNameNormalized[norm];
}
// Priority 3: Use override if found
if (overrideUrl) {
// Resolve against backend for relative assets
if (typeof overrideUrl === 'string' && overrideUrl.startsWith('/')) {
return assetUrl(overrideUrl) as string;
}
return overrideUrl;
}
// Priority 4: Use FACR original
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
}
// Priority 3: FACR original
if (original) {
return original;
}
@@ -186,6 +208,13 @@ const TeamsAdminPage = () => {
return '/dist/img/logo-club-empty.svg';
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
}
return String(teamName || '');
};
// View mode: 'table' per competition, or 'grid' of unique teams across competitions
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
// Selected competition for quick switching (only applies in table mode)
@@ -204,32 +233,50 @@ const TeamsAdminPage = () => {
name: string; // representative name
logo: string;
variants: string[]; // all raw names found
teamId?: string;
};
const allTeamsUnique: TeamAggregate[] = useMemo(() => {
const map: Record<string, TeamAggregate> = {};
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
const teamName = (r.team || '').trim();
if (!teamName) continue;
const key = normalize(teamName);
const logo = getLogo(teamName, r.team_id, r.team_logo_url);
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
rn === mainClubBase ||
rn.endsWith(' ' + mainClubBase) ||
rn.startsWith(mainClubBase + ' ') ||
rn.includes(' ' + mainClubBase + ' ')
) {
teamId = mainClubId;
}
}
const canonicalName = getName(rawName, teamId);
if (!canonicalName) continue;
const key = teamId ? `id:${teamId}` : normalize(canonicalName);
const logo = getLogo(canonicalName, teamId, r.team_logo_url);
if (!map[key]) {
map[key] = { key, name: teamName, logo, variants: [teamName] };
map[key] = { key, name: canonicalName, logo, variants: [rawName, canonicalName], teamId };
} else {
map[key].variants.push(teamName);
map[key].variants.push(rawName);
map[key].variants.push(canonicalName);
// Update logo - prefer non-empty logos
const currentIsEmpty = !map[key].logo || /logo-club-empty\.svg$/.test(String(map[key].logo));
const newIsNotEmpty = logo && !/logo-club-empty\.svg$/.test(String(logo));
if (currentIsEmpty && newIsNotEmpty) {
map[key].logo = logo as string;
}
if (!map[key].teamId && teamId) {
map[key].teamId = teamId;
}
}
}
}
// Sort by representative name
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name, 'cs', { sensitivity: 'base' }));
}, [competitions, getLogo]);
}, [competitions, getLogo, mainClubBase, mainClubId]);
// Fast lookup from normalized name to variant list
const variantsByKey = useMemo(() => {
@@ -253,6 +300,25 @@ const TeamsAdminPage = () => {
const [externalUploadStatus, setExternalUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [externalUploadError, setExternalUploadError] = useState<string | null>(null);
const showExternalUploadInfo = useMemo(() => {
try {
if (uploadedFile) return true;
const raw = (form.logo_url || '').trim();
if (!raw) return false;
const abs = raw.startsWith('/') ? new URL(raw, backendOrigin).toString() : raw;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
return !isFacr && !isLogoAPI && isLocalUpload;
} catch {
return false;
}
}, [uploadedFile, form.logo_url, backendOrigin]);
// Club search
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
@@ -266,7 +332,7 @@ const TeamsAdminPage = () => {
enabled: debounced.trim().length >= 2,
});
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[]) => {
const onOpenEdit = (teamName: string, teamLogoUrl?: string, variantNames?: string[], teamId?: string) => {
// If variants not explicitly provided (e.g., from table view), compute from normalized key
let v = variantNames;
if (!v || v.length === 0) {
@@ -274,7 +340,7 @@ const TeamsAdminPage = () => {
v = variantsByKey[key] || [];
}
setSelected({ teamName, teamLogoUrl, variantNames: v });
setForm({ external_team_id: '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setForm({ external_team_id: teamId || '', team_name: teamName || '', logo_url: teamLogoUrl || '' });
setQuery(teamName || '');
setIsOpen(true);
};
@@ -285,53 +351,79 @@ const TeamsAdminPage = () => {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
const primaryName = (selected?.teamName || form.team_name || '').trim();
// Prefer the edited input over the pre-selected name
const primaryName = (form.team_name || selected?.teamName || '').trim();
// All variants to update (deduped), always include the primary name
const names = Array.from(new Set([primaryName, ...((selected?.variantNames || []) as string[])]))
.map((s) => s.trim())
.filter(Boolean);
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
if (!uploadedFile && form.external_team_id) {
const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} catch {}
if (logoUrl) {
let shouldUpload = Boolean(uploadedFile);
try {
const abs = logoUrl.startsWith('/') ? new URL(logoUrl, backendOrigin).toString() : logoUrl;
const u = new URL(abs);
const host = u.hostname.toLowerCase();
const path = u.pathname;
const backendHost = new URL(backendOrigin).hostname.toLowerCase();
const isFacr = host.endsWith('fotbal.cz') || host === 'is1.fotbal.cz';
const isLogoAPI = host === 'logoapi.sportcreative.eu';
const isLocalUpload = (host === backendHost && path.startsWith('/uploads/'));
if (!shouldUpload) {
shouldUpload = isLocalUpload;
}
if (isFacr || isLogoAPI) {
shouldUpload = false;
}
} catch {}
if (shouldUpload) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
if (logaResult.url) {
logoUrl = logaResult.url;
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
}
} else {
setExternalUploadStatus('error');
setExternalUploadError(logaResult.error || 'Nepodařilo se nahrát logo');
setExternalUploadError('Could not fetch logo file');
}
} else {
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError('Could not fetch logo file');
setExternalUploadError(error?.message || 'Upload failed');
}
} catch (error: any) {
setExternalUploadStatus('error');
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
return true;
},
@@ -489,12 +581,12 @@ const TeamsAdminPage = () => {
<Td py={1.5}>
<HStack spacing={2} align="center">
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
src={getLogo(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url), r.team_logo_url)}
alt={getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
<Text fontSize="xs" noOfLines={1}>{getName(r.team, ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url))}</Text>
</HStack>
</Td>
<Td isNumeric py={1.5} fontSize="xs">{r.played}</Td>
@@ -504,7 +596,12 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs">{r.score}</Td>
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(r.team || '', r.team_logo_url)}>Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const displayName = getName(r.team, tid);
const key = tid ? `id:${tid}` : normalize(displayName);
onOpenEdit(displayName || '', getLogo(r.team, tid, r.team_logo_url), variantsByKey[key], tid);
}}>Upravit</Button>
</Td>
</Tr>
))}
@@ -558,7 +655,7 @@ const TeamsAdminPage = () => {
</Tooltip>
)}
</HStack>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants)} w="full">Upravit</Button>
<Button size="xs" fontSize="xs" onClick={() => onOpenEdit(t.name, t.logo, t.variants, t.teamId)} w="full">Upravit</Button>
</VStack>
</Box>
))}
@@ -604,9 +701,15 @@ const TeamsAdminPage = () => {
px={3}
py={2}
_hover={{ bg: 'gray.50', cursor: 'pointer' }}
onClick={() => {
onClick={async () => {
setForm((f) => ({ ...f, external_team_id: r.id, team_name: r.name, logo_url: r.logo_url || f.logo_url }));
setQuery(r.name);
try {
const apiUrl = await fetchLogoFromLogoAPI(r.id, r.name);
if (apiUrl) {
setForm((f) => ({ ...f, logo_url: apiUrl }));
}
} catch {}
}}
>
<HStack justify="space-between" spacing={3}>
@@ -656,7 +759,7 @@ const TeamsAdminPage = () => {
Upravíte také duplicitní názvy: {Array.from(new Set(selected.variantNames)).join(', ')}
</Alert>
)}
{form.logo_url && (
{showExternalUploadInfo && (
<Alert status="info" variant="left-accent">
<AlertIcon />
<VStack align="start" spacing={1}>
+111 -67
View File
@@ -236,50 +236,48 @@ const UsersAdminPage = () => {
Správa uživatelských účtů a jejich oprávnění. <strong>Editor</strong> může vytvářet a upravovat články a aktivity. <strong>Admin</strong> má přístup ke všem funkcím.
</Text>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem
icon={<EditIcon />}
onClick={() => openEditModal(user)}
>
Edit
</MenuItem>
<Heading size="md" mb={2}>Admini a editoři</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto" mb={8}>
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.filter(u => u.role !== 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
<MenuItem onClick={async () => {
try {
await api.post(`/admin/users/${user.id}/reset-password`);
@@ -287,34 +285,80 @@ const UsersAdminPage = () => {
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.error || e?.message || 'Nelze odeslat reset hesla';
const errorDetails = e?.response?.data?.details;
toast({
title: 'Chyba při odesílání resetu hesla',
description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg,
status: 'error',
duration: 10000,
isClosable: true,
});
toast({ title: 'Chyba při odesílání resetu hesla', description: errorDetails ? `${errorMsg}\n\n${errorDetails}` : errorMsg, status: 'error', duration: 10000, isClosable: true });
}
}}>
Odeslat reset hesla
</MenuItem>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem
icon={<DeleteIcon />}
color="red.500"
onClick={() => handleDelete(user.id)}
>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
{user.role !== 'admin' && String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Heading size="md" mb={2}>Fanoušci</Heading>
<Box bg={bgColor} borderRadius="md" boxShadow="sm" overflowX="auto">
<Table variant="simple">
<Thead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Status</Th>
<Th>Created</Th>
<Th>Actions</Th>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Thead>
<Tbody>
{users.filter(u => u.role === 'fan').map((user) => (
<Tr key={user.id}>
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={'gray'}>
Fan
</Badge>
</Td>
<Td>
<Badge colorScheme={user.isActive ? 'green' : 'red'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</Td>
<Td>{new Date(user.createdAt).toLocaleDateString()}</Td>
<Td>
<Menu>
<MenuButton
as={IconButton}
aria-label="Options"
icon={<HamburgerIcon />}
size="sm"
variant="ghost"
/>
<MenuList>
<MenuItem icon={<EditIcon />} onClick={() => openEditModal(user)}>
Edit
</MenuItem>
{String(authUser?.id) !== String(user.id) && (
<MenuItem icon={<DeleteIcon />} color="red.500" onClick={() => handleDelete(user.id)}>
Delete
</MenuItem>
)}
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
{/* Add/Edit User Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="lg">
+85 -23
View File
@@ -1,31 +1,93 @@
import { Box, Heading, Text, List, ListItem, Link, Container } from '@chakra-ui/react';
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, Button, useColorModeValue } from '@chakra-ui/react';
import MainLayout from '../../components/layout/MainLayout';
const CookiePolicyPage: React.FC = () => {
const textColor = useColorModeValue('gray.700', 'gray.300');
const headingColor = useColorModeValue('gray.900', 'gray.100');
const boxBg = useColorModeValue('blue.50', 'blue.900');
const boxText = useColorModeValue('blue.900', 'blue.100');
const openCookieSettings = () => {
window.dispatchEvent(new Event('cookie-consent-open'));
};
return (
<MainLayout>
<Container maxW="3xl">
<Heading as="h1" size="lg" mb={4}>Pravidla používání souborů Cookies</Heading>
<Text mb={4}>
Tento web používá soubory cookies pro zajištění správného fungování webu, analýzu návštěvnosti a zlepšení nabízených služeb.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>Co jsou cookies?</Heading>
<Text mb={4}>
Cookies jsou malé textové soubory, které se ukládají do vašeho zařízení při návštěvě webových stránek. Umožňují webu rozpoznat vaše zařízení a přizpůsobit obsah.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>Jaké typy cookies používáme</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6}>
<ListItem>Nezbytné cookies – zajišťují základní funkce webu.</ListItem>
<ListItem>Preferenční cookies – pamatují si vaše volby (např. jazyk).</ListItem>
<ListItem>Analytické cookies – pomáhají nám porozumět, jak web používáte (anonymně).</ListItem>
</List>
<Heading as="h2" size="md" mt={6} mb={2}>Správa cookies</Heading>
<Text mb={4}>
Používání cookies můžete upravit nebo zakázat v nastavení vašeho prohlížeče. Upozorňujeme, že vypnutí některých cookies může ovlivnit funkčnost webu.
</Text>
<Text fontSize="sm" color="gray.600">
V případě dotazů nás kontaktujte na adrese <Link href="/kontakt">/kontakt</Link>.
</Text>
<Container maxW="3xl" py={8}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size="xl" mb={2} color={headingColor}>Pravidla používání souborů cookies</Heading>
<Text fontSize="sm" color={textColor}>
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
</Text>
<Box bg={boxBg} p={4} borderRadius="md">
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
<Text fontSize="sm" color={boxText}>
Cookies používáme pro zajištění nezbytných funkcí webu, zlepšení uživatelského zážitku a měření návštěvnosti. Nepovinné cookies ukládáme pouze na základě vašeho souhlasu.
</Text>
</Box>
<Divider />
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Co jsou cookies?</Heading>
<Text color={textColor} mb={4}>
Cookies jsou malé textové soubory ukládané do vašeho zařízení při návštěvě webu. Umožňují rozpoznat prohlížeč, pamatovat si vaše volby a zajistit fungování webu.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Jaké cookies používáme</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné:</strong> zajišťují bezpečnost a základní funkce webu. Vždy aktivní.</ListItem>
<ListItem><strong>Preferenční:</strong> pamatují si vaše volby (např. jazyk, zobrazení).</ListItem>
<ListItem><strong>Analytické:</strong> anonymní měření návštěvnosti a výkonu stránek.</ListItem>
<ListItem><strong>Marketingové:</strong> přizpůsobení obsahu a případného marketingu.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Právní základy a účely</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné cookies:</strong> oprávněný zájem na provozu a zabezpečení webu (GDPR čl. 6 odst. 1 písm. f).</ListItem>
<ListItem><strong>Preferenční, analytické, marketingové:</strong> pouze na základě vašeho souhlasu (GDPR čl. 6 odst. 1 písm. a), který můžete kdykoli odvolat.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Správa souhlasu</Heading>
<Text color={textColor} mb={3}>
Nastavení cookies můžete kdykoli změnit pomocí tlačítka níže nebo v nastavení prohlížeče. Odvolání souhlasu nemá vliv na zákonnost předchozího zpracování.
</Text>
<Button onClick={openCookieSettings} colorScheme="blue" size="sm">Otevřít nastavení cookies</Button>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Doba uchovávání</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Nezbytné cookies:</strong> po dobu nezbytnou pro fungování relace či bezpečnost.</ListItem>
<ListItem><strong>Preferenční a analytické:</strong> typicky 6–26 měsíců, nebo do odvolání souhlasu.</ListItem>
<ListItem><strong>Marketingové:</strong> dle konkrétní kategorie, nejdéle do odvolání souhlasu.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Třetí strany</Heading>
<Text color={textColor} mb={4}>
Pro analytiku nebo měření můžeme využívat nástroje třetích stran. Tyto služby zpracovávají údaje pouze v rozsahu nezbytném pro poskytování měření a jsou vázány smluvními závazky ochrany údajů.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. Další informace</Heading>
<Text color={textColor} mb={2}>
Podrobnosti o zpracování osobních údajů najdete v <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásadách ochrany osobních údajů</Link>.
</Text>
<Text color={textColor}>
V případě dotazů nás kontaktujte prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
</Text>
</Box>
</VStack>
</Container>
</MainLayout>
);
@@ -39,7 +39,7 @@ const PrivacyPolicyPage: React.FC = () => {
<ListItem><strong>Kontaktní údaje:</strong> jméno, příjmení, e-mailová adresa, telefonní číslo - poskytnuté dobrovolně prostřednictvím kontaktního formuláře nebo při registraci k newsletteru</ListItem>
<ListItem><strong>IP adresa:</strong> automaticky zaznamenávaná při návštěvě webu pro účely bezpečnosti, analýzy návštěvnosti a prevence zneužití</ListItem>
<ListItem><strong>Technické údaje:</strong> informace o zařízení, prohlížeči, operačním systému, datum a čas návštěvy, navštívené stránky</ListItem>
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
<ListItem><strong>Cookies:</strong> soubory ukládané do vašeho zařízení (viz samostatná <Link href="/pravidla-cookies" color="brand.primary">Pravidla používání cookies</Link>)</ListItem>
<ListItem><strong>Analytické údaje:</strong> anonymizovaná data o chování na webu prostřednictvím analytických nástrojů</ListItem>
</List>
</Box>
+100 -22
View File
@@ -1,30 +1,108 @@
import { Container, Heading, Text, List, ListItem } from '@chakra-ui/react';
import { Container, Heading, Text, List, ListItem, Link, Box, VStack, Divider, useColorModeValue } from '@chakra-ui/react';
import MainLayout from '../../components/layout/MainLayout';
const TermsPage: React.FC = () => {
const textColor = useColorModeValue('gray.700', 'gray.300');
const headingColor = useColorModeValue('gray.900', 'gray.100');
const boxBg = useColorModeValue('blue.50', 'blue.900');
const boxText = useColorModeValue('blue.900', 'blue.100');
return (
<MainLayout>
<Container maxW="3xl">
<Heading as="h1" size="lg" mb={4}>Obchodní podmínky</Heading>
<Text mb={4}>
Tyto obchodní podmínky upravují používání webových stránek a poskytované služby.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>1. Obecná ustanovení</Heading>
<Text mb={4}>
Provozovatelem webu je subjekt uvedený v kontaktech. Používáním webu vyjadřujete souhlas s těmito podmínkami.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>2. Obsah webu</Heading>
<Text mb={4}>
Veškerý obsah je poskytován pro informační účely. Provozovatel si vyhrazuje právo na změny bez předchozího upozornění.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>3. Odpovědnost</Heading>
<Text mb={4}>
Provozovatel nenese odpovědnost za škody vzniklé v souvislosti s používáním webu, ledaže je stanoveno jinak právními předpisy.
</Text>
<Heading as="h2" size="md" mt={6} mb={2}>4. Kontakt</Heading>
<Text mb={4}>
V případě dotazů nás kontaktujte na stránce Kontakt.
</Text>
<Container maxW="3xl" py={8}>
<VStack align="stretch" spacing={6}>
<Heading as="h1" size="xl" mb={2} color={headingColor}>Obchodní podmínky</Heading>
<Text fontSize="sm" color={textColor}>
Poslední aktualizace: {new Date().toLocaleDateString('cs-CZ')}
</Text>
<Box bg={boxBg} p={4} borderRadius="md">
<Text fontWeight="bold" mb={2} color={boxText}>Shrnutí</Text>
<Text fontSize="sm" color={boxText}>
Používáním tohoto webu souhlasíte s níže uvedenými podmínkami. Obsah slouží pro informační účely, bez záruky úplnosti a správnosti. Provozovatel může podmínky a obsah kdykoli měnit.
</Text>
</Box>
<Divider />
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>1. Provozovatel a kontakt</Heading>
<Text color={textColor} mb={4}>
Provozovatelem webových stránek je subjekt uvedený v sekci <Link href="/kontakt" color="brand.primary">Kontakt</Link>. Pro veškeré dotazy nebo podněty prosím využijte uvedené kontaktní údaje.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>2. Rozsah a přijetí podmínek</Heading>
<Text color={textColor} mb={4}>
Tyto podmínky upravují používání tohoto webu a souvisejících služeb (např. newsletter). Vstupem na web a jeho používáním vyjadřujete souhlas s těmito podmínkami.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>3. Obsah, zdroje dat a práva k duševnímu vlastnictví</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem><strong>Autorská práva:</strong> Texty, grafika a prvky webu podléhají ochraně autorského práva. Kopírování či šíření je možné pouze s předchozím souhlasem.</ListItem>
<ListItem><strong>Externí data FAČR:</strong> Informace o zápasech, soutěžích a tabulkách pocházejí z veřejných zdrojů FAČR (<Link href="https://www.fotbal.cz" isExternal color="brand.primary">www.fotbal.cz</Link>) a jsou majetkem FAČR. Zobrazujeme je výhradně pro informační účely.</ListItem>
<ListItem><strong>Loga klubů a týmů:</strong> Loga náleží příslušným klubům a organizacím a slouží pouze k identifikaci.</ListItem>
<ListItem><strong>Fotografie a videa:</strong> Mediální obsah může pocházet z různých zdrojů, včetně YouTube a Zonerama. Veškerá práva náleží původním autorům.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>4. Pravidla chování uživatelů</Heading>
<List spacing={2} mb={4} styleType="disc" pl={6} color={textColor}>
<ListItem>Neporušujte právní předpisy ani práva třetích osob.</ListItem>
<ListItem>Nevkládejte škodlivý kód a nepokoušejte se narušit bezpečnost webu.</ListItem>
<ListItem>Nevyužívejte web k nevyžádané reklamě ani automatizovanému sběru dat (scraping) v rozporu s podmínkami.</ListItem>
</List>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>5. Newsletter a účty</Heading>
<Text color={textColor} mb={4}>
Přihlášení k odběru newsletteru je dobrovolné a lze jej kdykoli odhlásit prostřednictvím odkazu v e‑mailu nebo na stránce <Link href="/newsletter/preferences" color="brand.primary">nastavení newsletteru</Link>.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>6. Odpovědnost a záruky</Heading>
<Text color={textColor} mb={4}>
Obsah webu je poskytován „tak jak je“, bez jakýchkoli záruk. Provozovatel neodpovídá za případné škody vzniklé užíváním webu, v nejširším rozsahu povoleném právem. Neodpovídáme za dostupnost a obsah externích odkazů (např. FAČR, YouTube, Zonerama).
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>7. E‑shop a externí služby</Heading>
<Text color={textColor} mb={4}>
Pokud web odkazuje na externí e‑shop nebo platební službu, nákup se řídí podmínkami daného poskytovatele. Provozovatel tohoto webu není stranou takového smluvního vztahu, pokud není výslovně uvedeno jinak.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>8. Ochrana osobních údajů a cookies</Heading>
<Text color={textColor} mb={4}>
Zpracování osobních údajů se řídí dokumentem <Link href="/zasady-ochrany-osobnich-udaju" color="brand.primary">Zásady ochrany osobních údajů</Link>. Informace o cookies naleznete v <Link href="/pravidla-cookies" color="brand.primary">Pravidlech používání cookies</Link>.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>9. Změny podmínek</Heading>
<Text color={textColor} mb={4}>
Provozovatel si vyhrazuje právo tyto podmínky kdykoli upravit nebo doplnit. Změny jsou účinné zveřejněním na tomto webu.
</Text>
</Box>
<Box>
<Heading as="h2" size="md" mt={4} mb={3} color={headingColor}>10. Rozhodné právo</Heading>
<Text color={textColor} mb={2}>
Tyto podmínky se řídí právním řádem České republiky. Případné spory budou řešeny u příslušných soudů České republiky.
</Text>
<Text color={textColor}>
Máte‑li dotazy, kontaktujte nás prostřednictvím stránky <Link href="/kontakt" color="brand.primary">Kontakt</Link>.
</Text>
</Box>
</VStack>
</Container>
</MainLayout>
);
+10 -1
View File
@@ -178,7 +178,16 @@ export async function fetchLogoAsBlob(logoUrl: string): Promise<Blob | null> {
fullUrl = `${apiOrigin}${logoUrl}`;
}
const response = await fetch(fullUrl);
const apiOrigin = new URL(API_URL).origin;
let fetchUrl = fullUrl;
try {
const u = new URL(fullUrl);
if (u.origin !== apiOrigin) {
fetchUrl = `${apiOrigin}/api/v1/proxy/image?url=${encodeURIComponent(fullUrl)}`;
}
} catch {}
const response = await fetch(fetchUrl);
if (!response.ok) return null;
return await response.blob();
+2
View File
@@ -101,6 +101,8 @@ export async function getArticles(params: {
featured?: boolean;
q?: string;
slug?: string;
match_id?: string | number;
month?: string; // YYYY-MM
} = {}) {
// Backend returns shape: { items, total, page, page_size }
// Normalize to { data, total, page, page_size } expected by the frontend.
+69 -12
View File
@@ -49,10 +49,14 @@ const resolveBackendUrl = (path: string) => {
// Lazy-load public overrides with lightweight cache
let overridesCache: { data: any; ts: number } | null = null;
const loadOverrides = async (): Promise<Record<string, string>> => {
type OverridesPayload = {
by_name?: Record<string, string>;
by_id?: Record<string, { name?: string; logo_url?: string }>;
};
const loadOverrides = async (): Promise<OverridesPayload> => {
const now = Date.now();
if (overridesCache && now - overridesCache.ts < 60_000) {
return (overridesCache.data?.by_name || {}) as Record<string, string>;
return (overridesCache.data || {}) as OverridesPayload;
}
try {
const res = await fetch(resolveBackendUrl(`/api/v1/public/team-logo-overrides?t=${now}`), { cache: 'no-cache' });
@@ -65,7 +69,7 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
// Invalidate internal FACR GET cache so consumers refetch with new logos
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
// Fallback to cached file if API failed
@@ -79,11 +83,11 @@ const loadOverrides = async (): Promise<Record<string, string>> => {
if (prev !== next) {
cache.clear();
}
return (json?.by_name || {}) as Record<string, string>;
return (json || {}) as OverridesPayload;
}
} catch {}
overridesCache = { data: { by_name: {} }, ts: now };
return {};
return { by_name: {} };
};
// Name normalization helpers
@@ -99,14 +103,21 @@ const stripPrefixes = (s: string) => {
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) => {
const applyOverridesToClub = (club: ClubInfo, overrides: OverridesPayload) => {
if (!club?.competitions?.length) return club;
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => {
acc[norm(k)] = byName[k];
return acc;
}, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (teamName?: string, original?: string) => {
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
@@ -122,12 +133,18 @@ const applyOverridesToClub = (club: ClubInfo, byName: Record<string, string>) =>
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
club.competitions = (club.competitions || []).map((c) => ({
...c,
matches: (c.matches || []).map((m: any) => ({
...m,
home_logo_url: pick(m.home, m.home_logo_url),
away_logo_url: pick(m.away, m.away_logo_url),
home: pickName(m.home_id, m.home),
away: pickName(m.away_id, m.away),
home_logo_url: pickLogo(m.home_id, m.home, m.home_logo_url),
away_logo_url: pickLogo(m.away_id, m.away, m.away_logo_url),
})),
}));
return club;
@@ -232,8 +249,8 @@ export const facrApi = {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}`);
// Load overrides and apply before returning/caching consumers
const byName = await loadOverrides();
const patched = applyOverridesToClub(response.data, byName);
const overrides = await loadOverrides();
const patched = applyOverridesToClub(response.data, overrides);
return patched;
} catch (error) {
return handleApiError(error);
@@ -244,7 +261,47 @@ export const facrApi = {
getClubTable: async (clubId: string, clubType: 'football' | 'futsal' = 'football'): Promise<ClubInfo> => {
try {
const response = await apiClient.get<ClubInfo>(`/club/${clubType}/${clubId}/table`);
return response.data;
const data = response.data as any;
const overrides = await loadOverrides();
const byName = overrides?.by_name || {};
const byId = overrides?.by_id || {} as Record<string, { name?: string; logo_url?: string }>;
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pickLogo = (teamId?: string, teamName?: string, original?: string) => {
if (teamId && byId[teamId]?.logo_url) {
const v = byId[teamId]!.logo_url!;
if (typeof v === 'string' && v.startsWith('/')) return resolveBackendUrl(v);
return v;
}
if (!teamName) return original;
const exact = (byName || {})[teamName];
const n = norm(teamName);
let candidate = exact || byNameNorm[n];
if (!candidate) {
const s = stripPrefixes(teamName);
for (const { key, url } of strippedPairs) { if (!key) continue; if (s.endsWith(key) || key.endsWith(s)) { candidate = url; break; } }
}
const chosen = candidate || original;
if (typeof chosen === 'string' && chosen.startsWith('/')) return resolveBackendUrl(chosen);
return chosen;
};
const pickName = (teamId?: string, original?: string) => {
const v = (teamId && byId[teamId]?.name) ? byId[teamId]!.name : undefined;
return (v && v.trim().length > 0) ? v : original;
};
if (Array.isArray(data?.competitions)) {
data.competitions = data.competitions.map((c: any) => ({
...c,
table: {
overall: (c.table?.overall || []).map((r: any) => ({
...r,
team: pickName(r.team_id, r.team),
team_logo_url: pickLogo(r.team_id, r.team, r.team_logo_url),
})),
},
}));
}
return data;
} catch (error) {
return handleApiError(error);
}
+13
View File
@@ -114,7 +114,9 @@ export interface PredefinedElement {
export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
// Layout - Rozvržení
{ name: 'style-pack', label: 'Styl balíček', description: 'Globální vizuální balíček pro celou stránku', icon: FaCube, category: 'layout', defaultVariant: 'default' },
{ name: 'header', label: 'Hlavička', description: 'Hlavička stránky s logem a navigací', icon: FaRegClipboard, category: 'layout', defaultVariant: 'unified' },
{ name: 'hero-topbar', label: 'Klub lišta nad hero', description: 'Pruh nad hero s logem klubu, názvem a akcemi', icon: FaCube, category: 'layout', defaultVariant: 'brand' },
{ name: 'hero', label: 'Hlavní Sekce', description: 'Hlavní obsahová oblast s úvodním obsahem', icon: FaBullseye, category: 'layout', defaultVariant: 'grid' },
{ name: 'footer', label: 'Patička', description: 'Spodní část stránky s odkazy a kontakty', icon: FaMapSigns, category: 'layout', defaultVariant: 'standard' },
{ name: 'sidebar', label: 'Boční Panel', description: 'Boční sloupec s doplňkovým obsahem', icon: FaColumns, category: 'layout', defaultVariant: 'right' },
@@ -155,6 +157,12 @@ export const PREDEFINED_ELEMENTS: PredefinedElement[] = [
];
export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
'style-pack': [
{ value: 'default', label: 'Výchozí', description: 'Základní sjednocený styl' },
{ value: 'modern', label: 'Moderní', description: 'Zaoblené rohy, lehké stíny, více prostoru' },
{ value: 'minimal', label: 'Minimal', description: 'Čisté, bez stínů, tenké rámečky' },
{ value: 'sparta', label: 'Sparta', description: 'Přiblížení k Sparta packu' },
],
header: [
{ value: 'unified', label: 'Jednotný', description: 'Klasická hlavička s logem a navigací' },
{ value: 'edge', label: 'Okrajový', description: 'Moderní hlavička s gradientem' },
@@ -166,6 +174,11 @@ export const ELEMENT_VARIANTS: Record<string, ElementVariant[]> = {
{ value: 'current', label: 'Současný', description: 'Stávající navigace' },
{ value: 'fullwidth', label: 'Šířka 100%', description: 'Navigace přes celou šířku obrazovky' },
],
'hero-topbar': [
{ value: 'brand', label: 'Brand', description: 'Barevná lišta s klubovými barvami a akcemi' },
{ value: 'minimal', label: 'Minimal', description: 'Průhledná/nenápadná lišta' },
{ value: 'badge', label: 'Badge', description: 'Pill styl s klubovou barvou' },
],
hero: [
{ value: 'grid', label: 'Mřížka', description: 'Rozložení ve formě mřížky' },
{ value: 'swiper', label: 'Karusel', description: 'Posuvný karusel' },
+18 -4
View File
@@ -170,12 +170,18 @@ export const getPolls = async (params?: {
event_id?: number;
video_url?: string;
}): Promise<Poll[]> => {
const response = await api.get('/polls', { params });
const response = await api.get('/polls', {
params,
headers: { 'X-Session-Token': generateSessionToken() },
});
return response.data;
};
export const getPoll = async (id: number): Promise<PollResponse> => {
const response = await api.get(`/polls/${id}`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
@@ -183,12 +189,20 @@ export const votePoll = async (
id: number,
data: PollVoteRequest
): Promise<{ message: string; poll: Poll }> => {
const response = await api.post(`/polls/${id}/vote`, data);
const token = data.session_token || generateSessionToken();
const response = await api.post(
`/polls/${id}/vote`,
{ ...data, session_token: token },
{ headers: { 'X-Session-Token': token } }
);
return response.data;
};
export const getPollResults = async (id: number): Promise<PollResultsResponse> => {
const response = await api.get(`/polls/${id}/results`);
const token = generateSessionToken();
const response = await api.get(`/polls/${id}/results`, {
headers: { 'X-Session-Token': token },
});
return response.data;
};
+28 -6
View File
@@ -22,6 +22,27 @@ export type Player = {
export type Sponsor = { id: number; name: string; logo_url?: string; website_url?: string; tier?: string; display_order?: number };
export type Category = { id?: number; name: string; slug?: string; url?: string; children?: Category[] };
function normalizePlayer(p: any): Player {
if (!p) return p as any;
const id = p.id ?? p.ID;
return {
id: typeof id === 'string' ? Number(id) : id,
first_name: p.first_name ?? p.FirstName ?? '',
last_name: p.last_name ?? p.LastName ?? '',
position: p.position ?? p.Position ?? undefined,
jersey_number: p.jersey_number ?? p.JerseyNumber ?? undefined,
image_url: p.image_url ?? p.ImageURL ?? undefined,
is_active: Boolean(p.is_active ?? p.IsActive ?? true),
nationality: p.nationality ?? p.Nationality ?? undefined,
date_of_birth: p.date_of_birth ?? p.DateOfBirth ?? undefined,
height: p.height ?? p.Height ?? undefined,
weight: p.weight ?? p.Weight ?? undefined,
email: p.email ?? p.Email ?? undefined,
phone: p.phone ?? p.Phone ?? undefined,
team_id: p.team_id ?? p.TeamID ?? undefined,
} as Player;
}
export async function getMatches() {
const res = await api.get<Match[] | { data: Match[] }>('/matches');
return Array.isArray(res.data) ? res.data : res.data.data;
@@ -33,15 +54,16 @@ export async function getStandings() {
}
export async function getPlayers() {
const res = await api.get<Player[] | { data?: Player[]; items?: Player[] }>('/players');
if (Array.isArray(res.data)) return res.data as Player[];
const d = res.data as any;
return (d?.data || d?.items || []) as Player[];
const res = await api.get<any[] | { data?: any[]; items?: any[] }>('/players');
const raw = Array.isArray(res.data)
? res.data
: ((res.data as any).data || (res.data as any).items);
return (raw || []).map(normalizePlayer);
}
export async function getPlayer(id: number | string) {
const res = await api.get<Player>(`/players/${id}`);
return res.data;
const res = await api.get<any>(`/players/${id}`);
return normalizePlayer(res.data);
}
export async function getSponsors() {
+146 -86
View File
@@ -35,60 +35,50 @@ export interface SearchResults {
total: number;
}
// Enhanced scoring function for relevance with keyword support
// Enhanced scoring function for relevance with keyword support (accent-insensitive)
const scoreMatch = (text: string, query: string): number => {
const t = (text || '').toLowerCase();
const q = (query || '').toLowerCase();
if (!t || !q) return 0;
// Exact match - highest score
if (t === q) return 100;
// Starts with query - very high score
if (t.startsWith(q)) return 80;
// Contains query as whole substring
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
// Keyword matching - split query into words and check each
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
// Score based on position and keyword match
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
const base = (t: string, q: string): number => {
if (!t || !q) return 0;
if (t === q) return 100;
if (t.startsWith(q)) return 80;
const idx = t.indexOf(q);
if (idx >= 0) return 60 - Math.min(idx, 30);
const keywords = q.split(/\s+/).filter(k => k.length > 1);
if (keywords.length > 1) {
let matchedKeywords = 0;
let totalScore = 0;
for (const keyword of keywords) {
if (t.includes(keyword)) {
matchedKeywords++;
const keywordIdx = t.indexOf(keyword);
totalScore += (keywordIdx === 0 ? 25 : 15 - Math.min(keywordIdx, 10));
}
}
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
}
}
// If at least half the keywords match, return proportional score
if (matchedKeywords >= keywords.length / 2) {
return Math.min(55, totalScore * (matchedKeywords / keywords.length));
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
}
}
}
// Partial character matching for typos/fuzzy search
const chars = q.split('');
let lastIdx = -1;
let matched = 0;
for (const char of chars) {
const charIdx = t.indexOf(char, lastIdx + 1);
if (charIdx > lastIdx) {
matched++;
lastIdx = charIdx;
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
}
if (matched >= chars.length * 0.8) {
return Math.min(25, Math.floor((matched / chars.length) * 25));
}
return 0;
return 0;
};
const strip = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const t0 = (text || '').toLowerCase();
const q0 = (query || '').toLowerCase();
const t1 = strip(t0);
const q1 = strip(q0);
return Math.max(base(t0, q0), base(t1, q1));
};
// Resolve backend URLs for assets
@@ -105,6 +95,18 @@ const resolveBackendUrl = (path: string): string => {
}
};
// Small helper to fetch JSON from backend or cache with safe failure handling
const fetchJSON = async <T>(path: string): Promise<T | null> => {
try {
const url = resolveBackendUrl(path);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return null;
return (await res.json()) as T;
} catch {
return null;
}
};
const normalizeName = (value: string) =>
String(value || '')
.normalize('NFD')
@@ -149,49 +151,77 @@ export async function searchAll(query: string): Promise<SearchResults> {
galleryRes,
] = await Promise.allSettled([
relatedClubsPromise,
// Clubs from FACR
facrApi.searchClubs(query).catch(() => ({ results: [] })),
// Matches (upcoming)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (!Array.isArray(arr) || arr.length === 0) {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
return arr;
} catch {
const fallback = await fetchJSON<any[]>(`/cache/prefetch/matches.json`);
return Array.isArray(fallback) ? fallback : [];
}
})(),
// Matches (past)
(async () => {
const url = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) return [];
return await res.json();
const apiUrl = resolveBackendUrl(`/api/v1/matches/history?q=${encodeURIComponent(query)}`);
try {
const r = await fetch(apiUrl, { cache: 'no-cache' });
let arr: any = [];
if (r.ok) arr = await r.json();
if (Array.isArray(arr) && arr.length > 0) return arr;
} catch {}
// Build from FACR club cache as fallback
const facr = await fetchJSON<any>(`/cache/prefetch/facr_club_info.json`);
if (!facr || !Array.isArray(facr?.competitions)) return [];
const now = new Date();
const out: any[] = [];
for (const c of facr.competitions) {
const compName = String(c?.name || c?.code || '').trim();
const matches = Array.isArray(c?.matches) ? c.matches : [];
for (const m of matches) {
const dt = String(m?.date_time || '').trim();
if (!dt) continue;
const [datePart, timePart = '00:00'] = dt.split(' ');
const [day, month, year] = String(datePart || '').split('.');
if (!day || !month || !year) continue;
const isoDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const timeStr = String(timePart).slice(0, 5);
const ts = new Date(`${isoDate}T${timeStr || '00:00'}:00`);
if (!(ts instanceof Date) || isNaN(ts.getTime())) continue;
// Past only
if (ts.getTime() >= now.getTime()) continue;
out.push({
id: m?.match_id || m?.matchId,
home: m?.home,
away: m?.away,
competition: compName,
date: isoDate,
time: timeStr || undefined,
venue: m?.venue,
home_logo_url: m?.home_logo_url,
away_logo_url: m?.away_logo_url,
});
}
}
return out;
})(),
// Articles
getArticles({ q: query, published: true, page: 1, page_size: 50 }),
// Players
getPlayers(),
// Events
getUpcomingEvents(),
// Sponsors
getSponsors(),
// Teams
(async () => {
const res = await api.get('/teams');
return Array.isArray(res.data) ? res.data : res.data?.data || [];
})(),
// Contacts
(async () => {
try {
const res = await api.get('/contacts');
// Backend returns { categories: {...}, uncategorized: [...] }
// Flatten into a single array
const grouped = res.data?.categories || {};
const uncategorized = res.data?.uncategorized || [];
const allContacts = [...uncategorized];
@@ -205,8 +235,6 @@ export async function searchAll(query: string): Promise<SearchResults> {
return [];
}
})(),
// Gallery albums
(async () => {
try {
const res = await api.get('/gallery/albums');
@@ -232,14 +260,9 @@ export async function searchAll(query: string): Promise<SearchResults> {
const clubsData = clubsRes.status === 'fulfilled' ? (clubsRes.value as any)?.results || [] : [];
const clubs: SearchResult[] = clubsData
.filter((c: any) => {
// Filter out clubs with no name or empty name
const name = String(c.name || '').trim();
if (!name) return false;
if (!hasRelatedFilter) return true;
const idKey = String(c.club_id || c.id || '').toLowerCase();
const nameKey = normalizeName(c.name);
return (idKey && relatedById.has(idKey)) || (nameKey && relatedByName.has(nameKey));
return true;
})
.map((c: any) => {
const idKey = String(c.club_id || c.id || '').toLowerCase();
@@ -280,7 +303,26 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (upcoming)
const matchesData = matchesRes.status === 'fulfilled' ? matchesRes.value : [];
const matches: SearchResult[] = (Array.isArray(matchesData) ? matchesData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
// If related filter is active, allow either related match OR explicit query match
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match' as const,
id: m.id || idx,
@@ -306,7 +348,25 @@ export async function searchAll(query: string): Promise<SearchResults> {
// Process matches (past)
const matchesPastData = matchesPastRes.status === 'fulfilled' ? matchesPastRes.value : [];
const matchesPast: SearchResult[] = (Array.isArray(matchesPastData) ? matchesPastData : [])
.filter((m: any) => matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId))
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
const queryMatches = (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
return matchPassesFilter(m.home, m.away, m.home_id || m.homeId, m.away_id || m.awayId) || queryMatches;
})
.filter((m: any) => {
const comp = m.competition || m.competition_name || m.league || '';
return (
scoreMatch(m.home || '', q) > 0 ||
scoreMatch(m.away || '', q) > 0 ||
scoreMatch(m.venue || '', q) > 0 ||
scoreMatch(comp, q) > 0
);
})
.map((m: any, idx: number) => ({
type: 'match_past' as const,
id: `past-${m.id || idx}`,
+40
View File
@@ -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;
}
+65 -2
View File
@@ -1,13 +1,17 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
function resolveBackendOrigin() {
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://127.0.0.1:8080/api/v1';
const raw = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
const fallback = 'http://127.0.0.1:8080';
try {
if (!raw || raw.startsWith('/')) {
return fallback;
}
const u = new URL(raw);
u.pathname = '/';
return u.toString();
} catch (e) {
return 'http://127.0.0.1:8080';
return fallback;
}
}
@@ -50,6 +54,19 @@ module.exports = function(app) {
})
);
// Proxy /dist requests to backend (assets served by Go under /dist)
app.use(
'/dist',
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for /dist:', err);
},
})
);
// Proxy /cache requests to backend (for FACR cache files, etc.)
app.use(
'/cache',
@@ -62,4 +79,50 @@ module.exports = function(app) {
},
})
);
// Additional common static/image paths that may be referenced directly
app.use(
[
'/images',
'/img',
'/media',
'/files',
'/logos',
'/avatars',
'/downloads',
'/public',
'/favicon.ico',
],
createProxyMiddleware({
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image/static path:', err);
},
})
);
// Fallback: proxy any direct image file requests to the backend
// This will not affect Webpack Dev Server assets since they are handled earlier in the middleware chain
app.use(
createProxyMiddleware(
(pathname, req) => {
try {
if (pathname.startsWith('/sockjs-node') || pathname.startsWith('/ws')) return false;
return /\.(?:png|jpe?g|gif|svg|webp|ico|avif)$/i.test(pathname);
} catch {
return false;
}
},
{
target: resolveBackendOrigin(),
changeOrigin: true,
logLevel: 'debug',
onError: (err, req, res) => {
console.error('Proxy error for image extension fallback:', err);
},
}
)
);
};
+85
View File
@@ -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; }
}
+17 -1
View File
@@ -20,6 +20,9 @@
.ql-toolbar.ql-snow {
min-height: 42px !important;
height: auto !important;
position: relative !important;
z-index: 5000 !important;
overflow: visible !important;
}
.ql-container.ql-snow {
@@ -148,6 +151,8 @@
background: white;
max-height: 300px;
overflow-y: auto;
position: absolute !important;
z-index: 6000 !important;
}
.ql-toolbar.ql-snow .ql-picker-options .ql-picker-item {
@@ -452,10 +457,21 @@
border-radius: 4px;
white-space: nowrap;
margin-bottom: 4px;
z-index: 1000;
z-index: 6000;
pointer-events: none;
}
/* Ensure Quill built-in tooltip (e.g., link editor) is above modals and containers */
.ql-tooltip,
.ql-snow .ql-tooltip {
z-index: 6000 !important;
}
/* Ensure custom image resize overlay sits above content */
.custom-image-resize-container {
z-index: 6000 !important;
}
/* Loading State for Images */
.ql-editor img[src=""] {
opacity: 0.3;
+261
View File
@@ -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); }
+8 -4
View File
@@ -10,14 +10,18 @@ export function assetUrl(pathOrUrl?: string | null): string | undefined {
}
// Known backend-served asset paths (/uploads, optionally /dist)
if (pathOrUrl.startsWith('/uploads') || pathOrUrl.startsWith('/dist')) {
// 1) Explicit override wins
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || '';
if (explicit) {
const explicit = process.env.REACT_APP_ASSET_BASE_URL || process.env.REACT_APP_API_BASE_URL || '';
if (explicit && !explicit.startsWith('/')) {
const baseUrl = new URL(explicit, typeof window !== 'undefined' ? window.location.origin : undefined);
baseUrl.pathname = '/';
return new URL(pathOrUrl, baseUrl).toString();
}
// 2) Keep relative so frontend dev proxy or edge proxy forwards to backend
if (process.env.NODE_ENV !== 'production') {
try {
const devOrigin = 'http://127.0.0.1:8080';
return new URL(pathOrUrl, devOrigin).toString();
} catch {}
}
return pathOrUrl;
}
// Otherwise return as-is (relative or other paths)
+3
View File
@@ -129,6 +129,9 @@ func (ac *AuthController) Register(c *gin.Context) {
return
}
// Auto-subscribe newly registered fans to the newsletter
_ = models.SubscribeToNewsletter(ac.DB, user.Email)
// For first user, ensure setup info exists
if isFirstUser {
_, err := ac.setupService.GetSetupStatus()
File diff suppressed because it is too large. Load diff
+146 -510
View File
@@ -20,6 +20,7 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
"gopkg.in/mail.v2"
)
type ContactController struct {
@@ -27,7 +28,61 @@ type ContactController struct {
emailService email.EmailService
}
// GetNewsletterTokenForUser returns a short-lived newsletter preferences token for the authenticated user's email
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
UseTLS bool `json:"use_tls"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "Invalid payload"})
return
}
if strings.TrimSpace(input.Host) == "" || input.Port <= 0 || strings.TrimSpace(input.From) == "" || strings.TrimSpace(input.To) == "" {
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host, port, from and to are required"})
return
}
d := mail.NewDialer(strings.TrimSpace(input.Host), input.Port, strings.TrimSpace(input.Username), input.Password)
d.SSL = input.UseTLS
d.Timeout = 30 * time.Second
subj := strings.TrimSpace(input.Subject)
if subj == "" {
subj = "SMTP Test"
}
body := strings.TrimSpace(input.Body)
if body == "" {
body = "<p>Toto je testovací e‑mail SMTP z administrace.</p>"
}
m := mail.NewMessage()
from := strings.TrimSpace(input.From)
to := strings.TrimSpace(input.To)
m.SetHeader("From", from)
m.SetHeader("To", to)
m.SetHeader("Subject", subj)
m.SetDateHeader("Date", time.Now())
m.SetBody("text/plain", "SMTP test email")
m.AddAlternative("text/html", body)
if err := d.DialAndSend(m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Test email sent"})
}
// GET /api/v1/newsletter/token/me (auth required)
func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
u, ok := c.Get("user")
@@ -43,24 +98,27 @@ func (cc *ContactController) GetNewsletterTokenForUser(c *gin.Context) {
return
}
// Generate a 24h token for managing newsletter preferences
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", email).First(&sub).Error; err != nil {
_ = cc.DB.Create(&models.NewsletterSubscription{Email: email, IsActive: true}).Error
} else if !sub.IsActive {
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", email).Update("is_active", true).Error
}
token, err := utils.GenerateSubscriberToken(email, 60*24)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
c.JSON(http.StatusOK, gin.H{"token": token})
}
// SendNewsletterDigest builds and sends a digest newsletter based on a template type (admin only)
// POST /api/v1/admin/newsletter/send-digest { type: "blogs|events|matches|scores|weekly", competitions?: "ABC, DEF" }
// POST /api/v1/admin/newsletter/send-digest
func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Type string `json:"type" binding:"required"`
Competitions string `json:"competitions"`
@@ -69,7 +127,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
return
}
t := strings.ToLower(strings.TrimSpace(input.Type))
allowed := map[string]bool{"blogs": true, "events": true, "matches": true, "scores": true, "weekly": true}
if !allowed[t] {
@@ -77,7 +134,6 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
return
}
// Fetch active subscribers
var subscribers []models.NewsletterSubscription
if err := cc.DB.Where("is_active = ?", true).Find(&subscribers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
@@ -88,13 +144,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
return
}
// Build digest content once based on selected type
prefs := services.NewsletterPrefs{
Email: "digest@local",
ContentTypes: []string{},
Competitions: []string{},
Frequency: "daily",
}
prefs := services.NewsletterPrefs{Email: "digest@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
if t == "weekly" {
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
prefs.Frequency = "weekly"
@@ -103,9 +153,7 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
}
if strings.TrimSpace(input.Competitions) != "" {
for _, p := range strings.Split(input.Competitions, ",") {
if v := strings.TrimSpace(p); v != "" {
prefs.Competitions = append(prefs.Competitions, v)
}
if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) }
}
}
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
@@ -113,227 +161,103 @@ func (cc *ContactController) SendNewsletterDigest(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "No content for selected digest"})
return
}
// Recipients list
recipients := make([]string, 0, len(subscribers))
for _, s := range subscribers {
if s.Email != "" {
recipients = append(recipients, s.Email)
}
}
if len(recipients) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"})
return
}
if subj == "" {
subj = strings.Title(t) + " digest"
}
for _, s := range subscribers { if s.Email != "" { recipients = append(recipients, s.Email) } }
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No valid recipient emails"}); return }
if subj == "" { subj = strings.Title(t) + " digest" }
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send digest newsletter: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest newsletter"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Digest newsletter sent", "recipients": len(recipients), "type": t})
}
// UpdateNewsletterAutomation toggles the automated newsletter scheduler at runtime (non-persistent)
// PATCH /api/v1/admin/newsletter/enable { enabled: boolean }
// PATCH /api/v1/admin/newsletter/enable
func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Enabled bool `json:"enabled"`
}
var input struct { Enabled bool `json:"enabled"` }
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
// Persist to Settings (singleton row)
var s models.Settings
_ = cc.DB.First(&s).Error // ignore not found
if s.ID == 0 {
s = models.Settings{}
}
_ = cc.DB.First(&s).Error
if s.ID == 0 { s = models.Settings{} }
s.NewsletterEnabled = input.Enabled
if s.ID == 0 {
if err := cc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
return
}
} else if err := cc.DB.Save(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
return
}
// Flip the in-memory config flag; effective immediately for next tick
if config.AppConfig != nil {
config.AppConfig.NewsletterEnabled = input.Enabled
}
if err := cc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
} else if err := cc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"}); return }
if config.AppConfig != nil { config.AppConfig.NewsletterEnabled = input.Enabled }
c.JSON(http.StatusOK, gin.H{"newsletter_enabled": input.Enabled})
}
// GetNewsletterStatus returns basic scheduling/status info for newsletters (admin only)
// @Summary Newsletter status
// @Description Returns subscriber stats and next approximate run time based on interval
// @Tags admin
// @Security Bearer
// @Produce json
// @Success 200 {object} map[string]interface{}
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /api/v1/admin/newsletter/status [get]
// GET /api/v1/admin/newsletter/status
func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var total int64
var active int64
var total, active int64
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
var subs []models.NewsletterSubscription
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
sample := make([]string, 0, len(subs))
for _, s := range subs {
if s.Email != "" {
sample = append(sample, s.Email)
}
}
for _, s := range subs { if s.Email != "" { sample = append(sample, s.Email) } }
interval := 24 * time.Hour
if v := strings.TrimSpace(os.Getenv("NEWSLETTER_INTERVAL_HOURS")); v != "" {
if d, err := time.ParseDuration(v + "h"); err == nil {
interval = d
}
if d, err := time.ParseDuration(v + "h"); err == nil { interval = d }
}
next := time.Now().Add(interval)
c.JSON(http.StatusOK, gin.H{
"total_subscribers": total,
"active_subscribers": active,
"sample_recipients": sample,
"interval_minutes": int(interval.Minutes()),
"next_approximate": next,
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
})
c.JSON(http.StatusOK, gin.H{"total_subscribers": total, "active_subscribers": active, "sample_recipients": sample, "interval_minutes": int(interval.Minutes()), "next_approximate": next, "newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled})
}
// PreviewNewsletter builds a digest preview (subject + html) for admin without sending
// @Summary Preview newsletter digest (admin)
// @Description Returns subject and HTML for a digest newsletter using current cache and optional preferences
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param prefs body map[string]interface{} false "Optional { preferences: { blogs, matches, events, scores, competitions } }"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /api/v1/admin/newsletter/preview [post]
// POST /api/v1/admin/newsletter/preview
func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Preferences map[string]interface{} `json:"preferences"`
}
var input struct { Preferences map[string]interface{} `json:"preferences"` }
_ = c.ShouldBindJSON(&input)
// Normalize preferences to NewsletterPrefs
prefs := services.NewsletterPrefs{
Email: "preview@local",
ContentTypes: []string{},
Competitions: []string{},
Frequency: "daily",
}
prefs := services.NewsletterPrefs{Email: "preview@local", ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
if m := input.Preferences; m != nil {
if b, ok := m["blogs"].(bool); ok && b {
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
}
if b, ok := m["events"].(bool); ok && b {
prefs.ContentTypes = append(prefs.ContentTypes, "events")
}
if b, ok := m["matches"].(bool); ok && b {
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
}
if b, ok := m["scores"].(bool); ok && b {
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
}
if b, ok := m["blogs"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "blogs") }
if b, ok := m["events"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "events") }
if b, ok := m["matches"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "matches") }
if b, ok := m["scores"].(bool); ok && b { prefs.ContentTypes = append(prefs.ContentTypes, "scores") }
if cs, ok := m["competitions"].(string); ok && strings.TrimSpace(cs) != "" {
parts := strings.Split(cs, ",")
for _, p := range parts {
if v := strings.TrimSpace(p); v != "" {
prefs.Competitions = append(prefs.Competitions, v)
}
}
for _, p := range strings.Split(cs, ",") { if v := strings.TrimSpace(p); v != "" { prefs.Competitions = append(prefs.Competitions, v) } }
}
}
cacheDir := "cache/prefetch"
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
}
// GetNewsletterPreferencesByToken returns subscriber preferences using a token (no auth required)
// GET /api/v1/newsletter/preferences?token=...
// GET /api/v1/newsletter/preferences
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}); return }
emailStr, err := utils.ParseSubscriberToken(token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"email": sub.Email,
"is_active": sub.IsActive,
"preferences": sub.Preferences,
})
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
c.JSON(http.StatusOK, gin.H{"email": sub.Email, "is_active": sub.IsActive, "preferences": sub.Preferences})
}
// SaveNewsletterPreferencesByToken saves subscriber preferences using a token (no auth required)
// POST /api/v1/newsletter/preferences { token, preferences }
// POST /api/v1/newsletter/preferences
func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
Preferences map[string]interface{} `json:"preferences" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var input struct { Token string `json:"token" binding:"required"`; Preferences map[string]interface{} `json:"preferences" binding:"required"` }
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
emailStr, err := utils.ParseSubscriberToken(input.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
var sub models.NewsletterSubscription
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"})
return
}
if err := cc.DB.Where("email = ?", emailStr).First(&sub).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Subscription not found"}); return }
jm := datatypes.JSONMap{}
for key, raw := range input.Preferences {
switch v := raw.(type) {
@@ -343,13 +267,7 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
jm[key] = strings.TrimSpace(v)
case []interface{}:
compiled := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
if trimmed := strings.TrimSpace(s); trimmed != "" {
compiled = append(compiled, trimmed)
}
}
}
for _, item := range v { if s, ok := item.(string); ok { if trimmed := strings.TrimSpace(s); trimmed != "" { compiled = append(compiled, trimmed) } } }
jm[key] = strings.Join(compiled, ", ")
case float64, int, int64:
jm[key] = v
@@ -360,7 +278,6 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
return
}
}
if compVal, ok := jm["competitions"]; ok {
if compStr, ok := compVal.(string); ok {
comp := strings.TrimSpace(compStr)
@@ -374,32 +291,18 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
}
}
}
sub.Preferences = jm
sub.UpdatedAt = time.Now()
if err := cc.DB.Save(&sub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"})
return
}
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save preferences"}); return }
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
}
// UnsubscribeByToken disables newsletter using a token (no auth required)
// POST /api/v1/newsletter/unsubscribe-token { token }
// POST /api/v1/newsletter/unsubscribe-token
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
var input struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var input struct { Token string `json:"token" binding:"required"` }
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
emailStr, err := utils.ParseSubscriberToken(input.Token)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired token"}); return }
if err := cc.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", emailStr).Update("is_active", false).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
return
@@ -407,213 +310,67 @@ func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "You have been unsubscribed"})
}
// DeleteNewsletterSubscriber deletes a newsletter subscriber (admin only)
// @Summary Delete newsletter subscriber
// @Description Deletes a newsletter subscriber by ID (admin only)
// @Tags admin
// @Security Bearer
// @Produce json
// @Param id path int true "Subscriber ID"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/newsletter/subscribers/{id} [delete]
// DELETE /api/v1/admin/newsletter/subscribers/:id
func (cc *ContactController) DeleteNewsletterSubscriber(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
result := cc.DB.Delete(&models.NewsletterSubscription{}, id)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
return
}
if result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete subscriber"}); return }
if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}); return }
c.JSON(http.StatusOK, gin.H{"message": "Subscriber deleted successfully"})
}
// UpdateNewsletterSubscriberStatus toggles a subscriber's active status (admin only)
// @Summary Update newsletter subscriber status
// @Description Updates the is_active status of a newsletter subscriber (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param id path int true "Subscriber ID"
// @Param input body map[string]bool true "{ is_active: boolean }"
// @Success 200 {object} models.NewsletterSubscription
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/newsletter/subscribers/{id}/status [patch]
// PATCH /api/v1/admin/newsletter/subscribers/:id/status
func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
return
}
var input struct {
IsActive bool `json:"is_active" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
var input struct { IsActive bool `json:"is_active" binding:"required"` }
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}); return }
var sub models.NewsletterSubscription
if err := cc.DB.First(&sub, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
}
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
return
}
sub.IsActive = input.IsActive
sub.UpdatedAt = time.Now()
if err := cc.DB.Save(&sub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"})
return
}
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update subscriber"}); return }
c.JSON(http.StatusOK, sub)
}
// UpdateNewsletterSubscriberPreferences updates subscriber preferences (admin only)
// @Summary Update newsletter subscriber preferences
// @Description Updates the preferences JSON for a subscriber (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param id path int true "Subscriber ID"
// @Param input body map[string]bool true "Preferences map"
// @Success 200 {object} models.NewsletterSubscription
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /api/v1/admin/newsletter/subscribers/{id}/preferences [patch]
// PATCH /api/v1/admin/newsletter/subscribers/:id/preferences
func (cc *ContactController) UpdateNewsletterSubscriberPreferences(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"})
return
}
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid subscriber ID"}); return }
var prefs map[string]bool
if err := c.ShouldBindJSON(&prefs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"})
return
}
if err := c.ShouldBindJSON(&prefs); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid preferences payload"}); return }
var sub models.NewsletterSubscription
if err := cc.DB.First(&sub, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"})
}
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Subscriber not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch subscriber"}) }
return
}
// convert map[string]bool to datatypes.JSONMap
jm := datatypes.JSONMap{}
for k, v := range prefs {
jm[k] = v
}
for k, v := range prefs { jm[k] = v }
sub.Preferences = jm
sub.UpdatedAt = time.Now()
if err := cc.DB.Save(&sub).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
return
}
if err := cc.DB.Save(&sub).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}); return }
c.JSON(http.StatusOK, sub)
}
// SendNewsletterTest sends a test newsletter email to a single recipient (admin only)
// @Summary Send test newsletter email
// @Description Sends a test newsletter email to a single recipient (admin only)
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param input body map[string]string false "Optional {email} to send test to"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /api/v1/admin/newsletter/test [post]
// POST /api/v1/admin/newsletter/test
func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input struct {
Email string `json:"email"`
Emails []string `json:"emails"`
Type string `json:"type"`
}
if c.GetString("userRole") != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}); return }
var input struct { Email string `json:"email"`; Emails []string `json:"emails"`; Type string `json:"type"` }
_ = c.ShouldBindJSON(&input)
// Resolve recipients (emails > email > admin)
recipients := make([]string, 0)
if len(input.Emails) > 0 {
for _, e := range input.Emails {
if v := strings.TrimSpace(e); v != "" {
recipients = append(recipients, v)
}
}
}
if len(recipients) == 0 {
if v := strings.TrimSpace(input.Email); v != "" {
recipients = append(recipients, v)
}
}
if len(recipients) == 0 {
if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" {
recipients = append(recipients, v)
}
}
if len(recipients) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"})
return
}
t := strings.ToLower(strings.TrimSpace(input.Type))
if t == "" {
t = "newsletter"
}
if len(input.Emails) > 0 { for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } } }
if len(recipients) == 0 { if v := strings.TrimSpace(input.Email); v != "" { recipients = append(recipients, v) } }
if len(recipients) == 0 { if v := strings.TrimSpace(config.AppConfig.AdminEmail); v != "" { recipients = append(recipients, v) } }
if len(recipients) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient email provided"}); return }
t := strings.ToLower(strings.TrimSpace(input.Type)); if t == "" { t = "newsletter" }
logger.Info("[SendNewsletterTest] type=%s recipients=%v", t, recipients)
switch t {
case "newsletter":
testHTML := `<p>Toto je testovací newsletter z Fotbal Club. Nastavení SMTP funguje.</p>`
@@ -621,155 +378,34 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
logger.Debug("[SendNewsletterTest] invoking emailService.SendNewsletter for %d recipient(s)", len(recipients))
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send test newsletter: %v", err)
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"})
}
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter", "details": err.Error()}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test newsletter"}) }
return
}
case "welcome":
for _, r := range recipients {
w := &email.NewsletterWelcomeData{Email: r, UnsubscribeLink: ""}
if err := cc.emailService.SendNewsletterWelcome(w); err != nil {
logger.Error("Failed to send welcome test to %s: %v", r, err)
}
}
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: r}) }
case "welcome_back":
for _, r := range recipients {
w := &email.NewsletterWelcomeBackData{Email: r}
if err := cc.emailService.SendNewsletterWelcomeBack(w); err != nil {
logger.Error("Failed to send welcome back test to %s: %v", r, err)
}
}
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
case "setup":
// Test subscription setup email with token
for _, r := range recipients {
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
if tErr != nil {
logger.Error("Failed to generate token for setup test: %v", tErr)
continue
}
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
setupEmail := &email.EmailData{
Subject: "Test: Nastavte svůj newsletter",
To: []string{r},
Template: "newsletter_setup",
Data: struct{ SetupURL string }{SetupURL: setupURL},
}
if err := cc.emailService.SendEmail(setupEmail); err != nil {
logger.Error("Failed to send setup test to %s: %v", r, err)
}
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
_ = cc.emailService.SendEmail(setupEmail)
}
case "match_reminder_48h":
// Test 48h match reminder
testHTML := `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Připomínáme nadcházející zápas:</h2>
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> 2025-10-02</p>
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
</div>
<p style="color: #4a5568; margin-top: 20px;">Zápas začíná za 48 hodin. Nezapomeňte!</p>
</div>`
data := &email.NewsletterData{Subject: "Test: Nadcházející zápas za 48 hodin", Content: testHTML, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send match reminder test: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
return
}
case "match_reminder_today":
// Test day-of match reminder
testHTML := `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Zápas je dnes!</h2>
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test vs SK Example</h3>
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> Dnes</p>
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> 17:00</p>
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
<p style="color: #276749; margin: 5px 0;"><strong>Místo:</strong> Sportovní areál Test</p>
</div>
<p style="color: #4a5568; margin-top: 20px;">Přijďte fandit!</p>
</div>`
data := &email.NewsletterData{Subject: "Test: Zápas dnes", Content: testHTML, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send match today test: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
return
}
case "blog_notification":
// Test blog release notification
testHTML := `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">Testovací článek: Zajímavosti ze sezóny</h3>
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">Toto je ukázkový výňatek z nového článku na našem webu. Přečtěte si celý příběh a dozvíte se více zajímavostí ze sezóny.</p>
<a href="https://example.com/news/test" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
</div>`
data := &email.NewsletterData{Subject: "Test: Nový článek - Zajímavosti ze sezóny", Content: testHTML, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send blog notification test: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
return
}
case "match_result":
// Test match result notification
testHTML := `
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Výsledek zápasu</h2>
<div style="border-left: 4px solid #d69e2e; padding: 20px; background: #fffbeb; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">FC Test <span style="color: #d69e2e;">3:2</span> SK Example</h3>
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> 2025-09-30</p>
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> MFS A</p>
<p style="color: #975a16; margin: 10px 0 0 0;">Gratulujeme týmu k vítězství!</p>
</div>
</div>`
data := &email.NewsletterData{Subject: "Test: Výsledek - FC Test 3:2 SK Example", Content: testHTML, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send match result test: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test"})
return
}
// Predefined digest test types mapped to content sections
case "blogs", "events", "matches", "scores", "weekly":
prefs := services.NewsletterPrefs{
Email: recipients[0],
ContentTypes: []string{},
Competitions: []string{},
Frequency: "daily",
}
if t == "weekly" {
prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}
prefs.Frequency = "weekly"
} else {
prefs.ContentTypes = []string{t}
}
cacheDir := "cache/prefetch"
subj, html := services.BuildNewsletterDigest(cacheDir, prefs)
if subj == "" {
subj = "Test digest"
}
if html == "" {
html = "<p>Momentálně žádný obsah pro zvolený typ.</p>"
}
prefs := services.NewsletterPrefs{Email: recipients[0], ContentTypes: []string{}, Competitions: []string{}, Frequency: "daily"}
if t == "weekly" { prefs.ContentTypes = []string{"blogs", "events", "matches", "scores"}; prefs.Frequency = "weekly" } else { prefs.ContentTypes = []string{t} }
subj, html := services.BuildNewsletterDigest("cache/prefetch", prefs)
if subj == "" { subj = "Test digest" }
if html == "" { html = "<p>Momentálně žádný obsah pro zvolený typ.</p>" }
data := &email.NewsletterData{Subject: subj, Content: html, Recipients: recipients}
if err := cc.emailService.SendNewsletter(data); err != nil {
logger.Error("Failed to send digest test: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"})
return
}
if err := cc.emailService.SendNewsletter(data); err != nil { logger.Error("Failed to send digest test: %v", err); c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send digest test"}); return }
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown test type"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
}
@@ -928,7 +564,7 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
// Create new subscription. Default: enable everything if preferences omitted
prefs := input.Preferences
if prefs == nil {
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true}
prefs = map[string]bool{"weekly": true, "matches": true, "blogs": true, "events": true, "scores": true}
}
// convert to datatypes.JSONMap
jm := datatypes.JSONMap{}
+13 -2
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
@@ -233,7 +234,11 @@ func (fc *FilesController) GetFileUsages(c *gin.Context) {
// ScanAndSyncFiles scans the uploads directory and syncs with database
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
uploadsDir := "uploads"
// Use configured uploads directory (normalized to absolute in main startup)
uploadsDir := config.AppConfig.UploadDir
if strings.TrimSpace(uploadsDir) == "" {
uploadsDir = "./uploads"
}
var filesInDB []models.UploadedFile
if err := fc.DB.Find(&filesInDB).Error; err != nil {
@@ -287,7 +292,13 @@ func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
if !existsOriginal && !existsNormalized {
// File exists on disk but not in database - add it
mimeType := detectMimeType(path)
fileURL := "/" + filepath.ToSlash(path)
// Compute public URL under /uploads by making path relative to uploadsDir
rel, rerr := filepath.Rel(uploadsDir, path)
if rerr != nil {
// If for some reason file is outside base (symlink), skip
return nil
}
fileURL := "/uploads/" + filepath.ToSlash(rel)
newFile := models.UploadedFile{
Filename: filename,
@@ -12,8 +12,11 @@ import (
"strings"
"time"
"fotbal-club/internal/config"
"github.com/disintegration/imaging"
"github.com/gin-gonic/gin"
_ "golang.org/x/image/webp"
)
type ImageProcessingController struct{}
@@ -143,9 +146,20 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
return
}
// Return the new URL
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
xfl := strings.ToLower(xf)
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
host = xf
}
}
absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{
"url": outputPath,
"url": absolute,
"format": format,
})
}
@@ -154,8 +168,14 @@ func (ctrl *ImageProcessingController) ProcessImage(c *gin.Context) {
func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image, string, error) {
// Check if it's a local file path
if strings.HasPrefix(imageURL, "/uploads/") || strings.HasPrefix(imageURL, "uploads/") {
// Local file
localPath := filepath.Join(".", imageURL)
// Local file under configured uploads dir
base := config.AppConfig.UploadDir
if strings.TrimSpace(base) == "" {
base = "./uploads"
}
rel := strings.TrimPrefix(imageURL, "/")
rel = strings.TrimPrefix(rel, "uploads/")
localPath := filepath.Join(base, filepath.FromSlash(rel))
file, err := os.Open(localPath)
if err != nil {
return nil, "", fmt.Errorf("failed to open local file: %w", err)
@@ -169,14 +189,27 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
return img, format, nil
}
// HTTP URL
resp, err := http.Get(imageURL)
// HTTP URL - use custom client and headers, some CDNs block default Go UA
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequest("GET", imageURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create request: %w", err)
}
// Set a recognizable UA; align with other proxy endpoints
req.Header.Set("User-Agent", "fotbal-club/1.0")
req.Header.Set("Accept", "image/*")
// Some providers (e.g. Zonerama) may require a referer
if strings.Contains(strings.ToLower(imageURL), "zonerama.com") {
req.Header.Set("Referer", "https://zonerama.com/")
}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("failed to fetch image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("failed to fetch image: status %d", resp.StatusCode)
}
@@ -190,8 +223,11 @@ func (ctrl *ImageProcessingController) loadImage(imageURL string) (image.Image,
// saveProcessedImage saves the processed image and returns the path
func (ctrl *ImageProcessingController) saveProcessedImage(img image.Image, format string, quality int) (string, error) {
// Create uploads directory if it doesn't exist
uploadsDir := "./uploads"
// Create uploads directory if it doesn't exist (use configured UploadDir)
uploadsDir := config.AppConfig.UploadDir
if strings.TrimSpace(uploadsDir) == "" {
uploadsDir = "./uploads"
}
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
return "", fmt.Errorf("failed to create uploads directory: %w", err)
}
@@ -297,8 +333,20 @@ func (ctrl *ImageProcessingController) CropAndUpload(c *gin.Context) {
return
}
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
xfl := strings.ToLower(xf)
if !strings.Contains(xfl, ":3000") && !strings.HasPrefix(xfl, "localhost:") && !strings.HasPrefix(xfl, "127.0.0.1:") {
host = xf
}
}
absolute := scheme + "://" + host + outputPath
c.JSON(http.StatusOK, gin.H{
"url": outputPath,
"url": absolute,
})
}
@@ -513,10 +513,11 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
{Label: "Prefetch & Cache", Type: models.NavTypeInternal, PageType: "prefetch", DisplayOrder: 22, Visible: true, RequiresAdmin: true},
{Label: "Uživatelé", Type: models.NavTypeInternal, PageType: "users", DisplayOrder: 23, Visible: true, RequiresAdmin: true},
{Label: "Nastavení", Type: models.NavTypeInternal, PageType: "settings", DisplayOrder: 24, Visible: true, RequiresAdmin: true},
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
{Label: "Zkrácené odkazy", Type: models.NavTypeInternal, PageType: "shortlinks", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
// Help section
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 27, Visible: true, RequiresAdmin: true},
}
// Combine all items
@@ -15,9 +15,19 @@ import (
"strings"
"time"
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
)
func uploadsBaseDir() string {
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
return dir
}
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
func sanitizeAndWriteLogo(data []byte, outPath string) error {
img, _, err := image.Decode(bytes.NewReader(data))
@@ -102,7 +112,8 @@ func ensureUniqueFilename(dir, name string) string {
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
entries, err := os.ReadDir(filepath.Join("uploads", "sponsors"))
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
entries, err := os.ReadDir(sponsorDir)
if err != nil {
ctx.JSON(http.StatusOK, []string{})
return
@@ -125,7 +136,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
return
}
_ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755)
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
saved := 0
if ctx.Request.MultipartForm != nil {
@@ -145,8 +157,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
base := name
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
outName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), base+".png")
outPath := filepath.Join("uploads", "sponsors", outName)
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
var buf bytes.Buffer
if _, err := io.Copy(&buf, src); err == nil {
@@ -154,8 +166,8 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
saved++
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name)
rawPath := filepath.Join("uploads", "sponsors", rawName)
rawName := ensureUniqueFilename(sponsorDir, name)
rawPath := filepath.Join(sponsorDir, rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
}
@@ -173,7 +185,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
return
}
p := filepath.Join("uploads", "sponsors", name)
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
if _, err := os.Stat(p); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -187,7 +199,7 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
// GetQR returns the current QR image URL if present
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
path := filepath.Join("uploads", "qr.png")
path := filepath.Join(uploadsBaseDir(), "qr.png")
if _, err := os.Stat(path); err == nil {
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
return
@@ -203,8 +215,9 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
return
}
defer file.Close()
_ = os.MkdirAll("uploads", 0o755)
out, err := os.Create(filepath.Join("uploads", "qr.png"))
dir := uploadsBaseDir()
_ = os.MkdirAll(dir, 0o755)
out, err := os.Create(filepath.Join(dir, "qr.png"))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
return
@@ -0,0 +1,246 @@
package controllers
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ShortLinkController struct {
DB *gorm.DB
}
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
return &ShortLinkController{DB: db}
}
func randCode(n int) (string, error) {
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return string(b), nil
}
func clientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
if xr := c.GetHeader("X-Real-IP"); xr != "" {
return xr
}
return c.ClientIP()
}
func hashIPShort(ip string) string {
salted := ip + "_fotbal_club_2025"
h := sha256.Sum256([]byte(salted))
return hex.EncodeToString(h[:])
}
func getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
}
if c.Request.TLS != nil {
return "https"
}
return "http"
}
func parseTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("empty url")
}
// allow base64-encoded form as well
if !(strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://")) {
if dec, err := base64.URLEncoding.DecodeString(raw); err == nil {
raw = string(dec)
}
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", errors.New("invalid url")
}
return u.String(), nil
}
func (s *ShortLinkController) RedirectShort(c *gin.Context) {
code := strings.TrimSpace(c.Param("code"))
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing code"})
return
}
var link models.ShortLink
err := s.DB.Where("code = ? AND active = ?", code, true).First(&link).Error
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) {
c.JSON(http.StatusNotFound, gin.H{"error": "expired"})
return
}
_ = s.DB.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")).Error
// Record click
u, _ := url.Parse(link.TargetURL)
click := models.LinkClick{
ShortLinkID: &link.ID,
TargetURL: link.TargetURL,
IPHash: hashIPShort(clientIP(c)),
UserAgent: c.GetHeader("User-Agent"),
Referrer: c.GetHeader("Referer"),
UTMSource: u.Query().Get("utm_source"),
UTMMedium: u.Query().Get("utm_medium"),
UTMCampaign: u.Query().Get("utm_campaign"),
UTMContent: u.Query().Get("utm_content"),
UTMTerm: u.Query().Get("utm_term"),
}
_ = s.DB.Create(&click).Error
// Umami event (best-effort)
cfg := config.AppConfig
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
svc := services.NewUmamiService()
_ = svc.SendEvent(cfg.UmamiWebsiteID, "ShortLink Click", "/s/"+code, link.Title, map[string]any{"code": code, "target": link.TargetURL}, "web")
}
c.Redirect(http.StatusFound, link.TargetURL)
}
func (s *ShortLinkController) RedirectAndTrack(c *gin.Context) {
raw := strings.TrimSpace(c.Query("u"))
target, err := parseTarget(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
u, _ := url.Parse(target)
click := models.LinkClick{
TargetURL: target,
IPHash: hashIPShort(clientIP(c)),
UserAgent: c.GetHeader("User-Agent"),
Referrer: c.GetHeader("Referer"),
UTMSource: u.Query().Get("utm_source"),
UTMMedium: u.Query().Get("utm_medium"),
UTMCampaign: u.Query().Get("utm_campaign"),
UTMContent: u.Query().Get("utm_content"),
UTMTerm: u.Query().Get("utm_term"),
}
_ = s.DB.Create(&click).Error
cfg := config.AppConfig
if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" {
svc := services.NewUmamiService()
_ = svc.SendEvent(cfg.UmamiWebsiteID, "Link Redirect", "/r", "Link Redirect", map[string]any{"target": target}, "web")
}
c.Redirect(http.StatusFound, target)
}
func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
var body struct {
TargetURL string `json:"target_url"`
Title string `json:"title"`
SourceType string `json:"source_type"`
SourceID *uint `json:"source_id"`
ExpiresAt *time.Time `json:"expires_at"`
Code string `json:"code"`
Active *bool `json:"active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
target, err := parseTarget(body.TargetURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
code := strings.TrimSpace(body.Code)
if code == "" {
for i := 0; i < 5; i++ {
cnd, _ := randCode(7)
var cnt int64
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
if cnt == 0 {
code = cnd
break
}
}
}
if code == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
return
}
active := true
if body.Active != nil { active = *body.Active }
link := models.ShortLink{
Code: code,
TargetURL: target,
Title: strings.TrimSpace(body.Title),
SourceType: strings.TrimSpace(body.SourceType),
SourceID: body.SourceID,
Active: active,
ExpiresAt: body.ExpiresAt,
}
if err := s.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
return
}
scheme := getScheme(c)
host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code)
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
}
func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
var items []models.ShortLink
_ = s.DB.Order("created_at DESC").Limit(200).Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return }
var link models.ShortLink
if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return }
start := time.Now().AddDate(0,0,-30)
type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` }
var rows []Row
s.DB.Model(&models.LinkClick{}).
Select("DATE(created_at) as date, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("DATE(created_at)").Order("date ASC").Scan(&rows)
var refRows []struct{ Referrer string; Count int64 }
s.DB.Model(&models.LinkClick{}).
Select("referrer, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("referrer").Order("count DESC").Limit(20).Scan(&refRows)
var utmRows []struct{ Source, Medium, Campaign string; Count int64 }
s.DB.Model(&models.LinkClick{}).
Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count").
Where("short_link_id = ? AND created_at >= ?", link.ID, start).
Group("utm_source, utm_medium, utm_campaign").Order("count DESC").Limit(50).Scan(&utmRows)
c.JSON(http.StatusOK, gin.H{"timeseries": rows, "referrers": refRows, "utms": utmRows})
}
+7
View File
@@ -84,6 +84,13 @@ func SubscribeToNewsletter(db *gorm.DB, email string) error {
subscription := NewsletterSubscription{
Email: email,
IsActive: true,
Preferences: datatypes.JSONMap{
"blogs": true,
"matches": true,
"events": true,
"scores": true,
"weekly": true,
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
+24
View File
@@ -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" }
+1
View File
@@ -95,6 +95,7 @@ func (n *NavigationItem) GetURL() string {
"prefetch": "/admin/prefetch",
"users": "/admin/uzivatele",
"settings": "/admin/nastaveni",
"shortlinks": "/admin/shortlinks",
"files": "/admin/soubory",
"docs": "/admin/docs",
}
+24
View File
@@ -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" }
+22
View File
@@ -53,6 +53,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
imageProcessingController := &controllers.ImageProcessingController{}
articleController := controllers.NewArticleController(db)
myuibrixController := &controllers.MyUIbrixController{DB: db}
shortLinkController := controllers.NewShortLinkController(db)
// API v1 group
{
@@ -151,6 +152,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
}
// Shortlinks (protected for editors) - create/list
protectedShortlinks := protected.Group("/shortlinks")
protectedShortlinks.Use(middleware.RoleAuth("editor"))
{
protectedShortlinks.POST("", shortLinkController.CreateShortLink)
protectedShortlinks.GET("", shortLinkController.ListShortLinks)
}
// Articles (protected - accessible by editors and admins)
articles := protected.Group("/articles")
articles.Use(middleware.RoleAuth("editor"))
@@ -284,6 +293,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
// New: send prebuilt digest by type and toggle automation
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
admin.POST("/newsletter/smtp-test", contactController.AdminSmtpTest)
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
// Removed deprecated SMTP test route (use /newsletter/test instead)
admin.GET("/newsletter/status", contactController.GetNewsletterStatus)
@@ -405,6 +415,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
myuibrix.GET("/preview", myuibrixController.GetElementPreview)
myuibrix.GET("/optimize-layout", myuibrixController.OptimizePageLayout)
}
// Short links (admin)
shortlinks := admin.Group("/shortlinks")
{
shortlinks.POST("", shortLinkController.CreateShortLink)
shortlinks.GET("", shortLinkController.ListShortLinks)
shortlinks.GET("/:id/stats", shortLinkController.GetShortLinkStats)
}
}
}
@@ -517,6 +535,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// SetupRootRoutes registers endpoints at the root (no /api prefix)
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
seoController := controllers.NewSEOController(db)
shortLinkController := controllers.NewShortLinkController(db)
r.GET("/robots.txt", seoController.GetRobotsTXT)
r.GET("/sitemap.xml", seoController.GetSitemapXML)
// Public short-link redirects and generic tracked redirect
r.GET("/s/:code", shortLinkController.RedirectShort)
r.GET("/r", shortLinkController.RedirectAndTrack)
}
+155
View File
@@ -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
}
+46 -31
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"log"
"net/url"
"path/filepath"
"strings"
"time"
@@ -475,46 +474,62 @@ func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string,
return nil
}
// HTML builders
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
// Short description: prefer excerpt; otherwise derive from content
desc := strings.TrimSpace(article.Excerpt)
if desc == "" {
plain := utils.SanitizeString(article.Content)
if len(plain) > 260 {
cut := 240
if cut < len(plain) {
for cut < len(plain) && plain[cut] != ' ' {
cut++
}
}
if cut > len(plain) { cut = len(plain) }
plain = strings.TrimSpace(plain[:cut]) + "…"
}
desc = plain
}
// Build tracked link
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
url.QueryEscape(articleURL),
url.QueryEscape(token))
// Category badge (if available)
cat := strings.TrimSpace(article.CategoryName)
var catHTML string
if cat != "" {
catHTML = fmt.Sprintf(`<div style="margin-bottom:10px;"><span style="display:inline-block;background:#e3f2fd;color:#1e3a8a;border:1px solid #90cdf4;border-radius:999px;padding:4px 10px;font-size:12px;font-weight:600;">%s</span></div>`, htmlEsc(cat))
}
html := fmt.Sprintf(`
// Cover image (optional)
var imgHTML string
if strings.TrimSpace(article.ImageURL) != "" {
imgHTML = fmt.Sprintf(`<div style="margin:0 0 15px 0;"><img src="%s" alt="cover" style="width:100%%;height:auto;border-radius:6px;"/></div>`, htmlEsc(article.ImageURL))
}
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
</p>
<h2 style="color: #1e3a8a; margin-bottom: 12px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 18px; background: #f8fafc; margin: 16px 0; border-radius:6px;">
%s
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size:22px;">%s</h3>
%s
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 12px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 20px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
</div>
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
`, catHTML, htmlEsc(article.Title), imgHTML, htmlEsc(desc), articleURL)
return html
return html
}
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
var intro string
if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:"
} else {
intro = "Zápas je dnes!"
}
var intro string
if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:"
} else {
intro = "Zápas je dnes!"
}
html := fmt.Sprintf(`
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
+43 -13
View File
@@ -37,6 +37,11 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
art := readJSON(filepath.Join(cacheDir, "articles.json"))
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json"))
// Club name for subject personalization (fallback to default)
set := readJSON(filepath.Join(cacheDir, "settings.json"))
sm := asMap(set)
clubName := strings.TrimSpace(str(sm["club_name"], ""))
if clubName == "" { clubName = "Fotbal Club" }
sections := make([]string, 0, 4)
@@ -73,10 +78,10 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
}
if len(sections) == 0 {
return "Fotbal Club – přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
return fmt.Sprintf("%s – přehled", clubName), "<p>Pro vybrané preference nyní nemáme novinky.</p>"
}
subject = "Fotbal Club – novinky a zápasy"
subject = fmt.Sprintf("%s – novinky a zápasy", clubName)
html = strings.Join(sections, "\n\n")
return subject, html
}
@@ -125,12 +130,22 @@ func pickUpcomingEvents(v any, n int) []Event {
Time: str(m["time"], ""),
Url: str(m["url"], ""),
}
if e.Date == "" {
st := str(m["start_time"], "")
if st != "" {
if tm, err := time.Parse(time.RFC3339, st); err == nil {
lt := tm.In(time.Local)
e.Date = lt.Format("2006-01-02")
e.Time = lt.Format("15:04")
}
}
}
out = append(out, e)
}
return out
}
type Match struct { Home, Away, Date, Time, Competition, Link, Score string }
type Match struct { Home, Away, Date, Time, Competition, CompCode, Link, Score string }
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
compSet := make(map[string]bool)
@@ -142,7 +157,9 @@ func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
ts := parseDateTimeISO(m.Date, m.Time)
if ts.IsZero() || ts.Before(now) { continue }
if len(compSet) > 0 {
if !compSet[strings.ToLower(m.Competition)] { continue }
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
}
out = append(out, m)
if len(out) >= n { break }
@@ -163,7 +180,9 @@ func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.
// treat as result if score like "2:1" exists
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
if len(compSet) > 0 {
if !compSet[strings.ToLower(m.Competition)] { continue }
nameKey := strings.ToLower(strings.TrimSpace(m.Competition))
codeKey := strings.ToLower(strings.TrimSpace(m.CompCode))
if !(compSet[nameKey] || (codeKey != "" && compSet[codeKey])) { continue }
}
out = append(out, m)
}
@@ -188,19 +207,20 @@ func facrAllMatches(v any) []Match {
for _, c := range asList(comps) {
cm := asMap(c)
compName := str(cm["name"], str(cm["code"], ""))
compCode := str(cm["code"], "")
for _, mm := range asList(cm["matches"]) {
out = append(out, toMatch(asMap(mm), compName))
out = append(out, toMatch(asMap(mm), compName, compCode))
}
}
}
// flat matches fallback
for _, mm := range asList(m["matches"]) {
out = append(out, toMatch(asMap(mm), ""))
out = append(out, toMatch(asMap(mm), "", ""))
}
return out
}
func toMatch(m map[string]any, comp string) Match {
func toMatch(m map[string]any, compName, compCode string) Match {
dt := str(m["date_time"], "")
var date, tm string
if dt != "" && strings.Contains(dt, " ") {
@@ -215,7 +235,8 @@ func toMatch(m map[string]any, comp string) Match {
Away: str(m["away"], ""),
Date: date,
Time: tm,
Competition: str(m["competition"], str(m["competition_name"], comp)),
Competition: str(m["competition"], str(m["competition_name"], compName)),
CompCode: str(m["competition_code"], compCode),
Link: str(m["facr_link"], str(m["report_url"], "#")),
Score: str(m["score"], ""),
}
@@ -224,10 +245,19 @@ func toMatch(m map[string]any, comp string) Match {
func parseDateTimeISO(d, t string) time.Time {
if d == "" { return time.Time{} }
if t == "" { t = "00:00" }
layout := "2006-01-02T15:04:05"
// try shorter HH:MM format
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
return parseTime(layout, d+"T"+t)
if strings.Contains(d, ".") {
if len(t) == 5 {
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
}
if tm := parseTime("02.01.2006 15:04:05", d+" "+t); !tm.IsZero() { return tm }
if tm := parseTime("02.01.2006 15:04", d+" "+t); !tm.IsZero() { return tm }
}
if len(t) == 5 {
if tm := parseTime("2006-01-02T15:04", d+"T"+t); !tm.IsZero() { return tm }
return parseTime("2006-01-02 15:04", d+" "+t)
}
if tm := parseTime("2006-01-02T15:04:05", d+"T"+t); !tm.IsZero() { return tm }
return parseTime("2006-01-02 15:04:05", d+" "+t)
}
func parseTime(layout, s string) time.Time {
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more