dev day #90 🥳

This commit is contained in:
Tomas Dvorak committed 2025-11-12 20:31:37 +01:00
1 parent 8762bde4bf
commit f3db65d350
103 files changed
+3265 -1401

No files matched your search

@@ -1 +1 @@
{"data":"eyJuYW1lIjoiRm90YmFsb3bDvSBrbHViIEtybm92IiwiY2x1Yl9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImNsdWJfdHlwZSI6ImZvb3RiYWxsIiwiY2x1Yl9pbnRlcm5hbF9pZCI6IjgwMTAyMTEiLCJ1cmwiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS9jbHViL2NsdWIvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4IiwibG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsImNhdGVnb3J5IjoiRm90YmFsIiwiY29tcGV0aXRpb25zIjpbeyJpZCI6ImUzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsImNvZGUiOiJBMUEiLCJuYW1lIjoiU0FUVU0gNS4gbGlnYSBtdcW+xa8iLCJ0ZWFtX2NvdW50IjoiMTYiLCJtYXRjaGVzX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS90dXJuYWplL3RhYmxlL2UzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsIm1hdGNoZXMiOlt7ImRhdGVfdGltZSI6IjEwLjA4LjIwMjUgMTc6MDAiLCJob21lIjoiS3JhdmHFmWUiLCJob21lX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzM3N2JmMGFhLTMzZTktNDk4Ny1hYjQyLTM5NzRiYTU4OGQ2Zi8zNzdiZjBhYS0zM2U5LTQ5ODctYWI0Mi0zOTc0YmE1ODhkNmZfY3JvcC5qcGciLCJhd2F5IjoiRksgS29mb2xhIEtybm92IiwiYXdheV9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImF3YXlfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsInNjb3JlIjoiNDowIiwidmVudWUiOiJLcmF2YcWZZSAtIHRyw6F2YSIsIm1hdGNoX2lkIjoiOTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzIiwicmVwb3J0X3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwaXMtby11dGthbmktcmVwb3J0LmFzcHglM0Z6YXBhcz05MTc2ZDhlZC1mMWYxLTQwOTMtYTYxNS03MTI3YWU1Y2NhODNcdTAwMjZ6YXBpcz0xXHUwMDI2bm9wcmludD0xXHUwMDI2YnRucHJpbnQ9MVx1MDAyNi5odG0iLCJmYWNyX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS96YXBhc3kvemFwYXMvOTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzIiwiZGVsZWdhdGlvbl91cmwiOiJodHRwczovL2lzLmZvdGJhbC5jei9wdWJsaWMvemFwYXN5L3phcGFzLWRlbGVnYWNlLXJlcG9ydC5hc3B4JTNGemFwYXM9OTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzXHUwMDI2emFwaXM9MVx1MDAyNmhpZGVtZW51PTFcdTAwMjYuaHRtIn0seyJkYXRlX3RpbWUiOiIxNy4wOC4yMDI1IDE1OjAwIiwiaG9tZSI6IkZLIEtvZm9sYSBLcm5vdiIsImhvbWVfaWQiOiI3ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgiLCJob21lX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OC83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNThfY3JvcC5qcGciLCJhd2F5IjoiQnJ1xaFwZXJrIiwiYXdheV9sb2dvX3VybCI6Ii9kaXN0L2ltZy9sb2dvLWNsdWItZW1wdHkuc3ZnIiwic2NvcmUiOiIxOjMiLCJ2ZW51ZSI6Iktybm92LXRyw6F2YSIsIm1hdGNoX2lkIjoiODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmIiwicmVwb3J0X3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwaXMtby11dGthbmktcmVwb3J0LmFzcHglM0Z6YXBhcz04MTVmZmQ3MC1mMDNhLTQ5ZDAtYjdiNC1iNWNmYTg5Y2UxMmZcdTAwMjZ6YXBpcz0xXHUwMDI2bm9wcmludD0xXHUwMDI2YnRucHJpbnQ9MVx1MDAyNi5odG0iLCJmYWNyX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS96YXBhc3kvemFwYXMvODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmIiwiZGVsZWdhdGlvbl91cmwiOiJodHRwczovL2lzLmZvdGJhbC5jei9wdWJsaWMvemFwYXN5L3phcGFzLWRlbGVnYWNlLXJlcG9ydC5hc3B4JTNGemFwYXM9ODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmXHUwMDI2emFwaXM9MVx1MDAyNmhpZGVtZW51PTFcdTAwMjYuaHRtIn0seyJkYXRlX3RpbWUiOiIyNC4wOC4yMDI1IDE2OjMwIiwiaG9tZSI6IkZDIERvbG7DrSBCZW5lxaFvdiIsImhvbWVfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvMDgwZTNhZTEtMmJjNC00ZDkzLWJlYTktZGIyNmRhNzY4ZmE1LzA4MGUzYWUxLTJiYzQtNGQ5My1iZWE5LWRiMjZkYTc2OGZhNV9jcm9wLmpwZyIsImF3YXkiOiJGSyBLb2ZvbGEgS3Jub3YiLCJhd2F5X2lkIjoiN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4IiwiYXdheV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwic2NvcmUiOiIyOjEiLCJ2ZW51ZSI6IkQuIEJlbmXFoW92IC0gdHLDoXZhIiwibWF0Y2hfaWQiOiJmZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDIiLCJyZXBvcnRfdXJsIjoiaHR0cHM6Ly9pcy5mb3RiYWwuY3ovcHVibGljL3phcGFzeS96YXBpcy1vLXV0a2FuaS1yZXBvcnQuYXNweCUzRnphcGFzPWZlNTE2MTc3LWQ0ODQtNDkwNS04YzFjLTIyMTk0NmM1MzkwMlx1MDAyNnphcGlzPTFcdTAwMjZub3ByaW50PTFcdTAwMjZidG5wcmludD0xXHUwMDI2Lmh0bSIsImZhY3JfbGluayI6Imh0dHBzOi8vd3d3LmZvdGJhbC5jei9zb3V0ZXplL3phcGFzeS96YXBhcy9mZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDIiLCJkZWxlZ2F0aW9uX3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwYXMtZGVsZWdhY2UtcmVwb3J0LmFzcHglM0Z6YXBhcz1mZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDJcdTAwMjZ6YXBpcz0xXHUwMDI2aGlkZW1lbnU9MVx1MDAyNi5odG0ifSx7ImRhdGVfdGltZSI6IjI3LjA4LjIwMjUgMTY6MzAiLCJob21lIjoiRksgS29mb2xhIEtybm92IiwiaG9tZV9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImhvbWVfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsImF3YXkiOiJLb2JlxZlpY2UiLCJhd2F5X2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzU1Zjk2MzA3LWM5MTYtNDgwMS05NDhiLWJjODRmNDZmMjFiZC81NWY5NjMwNy1jOTE2LTQ4MDEtOTQ4Yi1iYzg0ZjQ2ZjIxYmRfY3JvcC5qcGciLCJzY29yZSI6IjM6MSIsInZlbnVlIjoiS3Jub3YtdHLDoXZhIiwibWF0Y2hfaWQiOiI5YWZhZTQzMS1lMDLine truncated
{"data":"eyJuYW1lIjoiRm90YmFsb3bDvSBrbHViIEtybm92IiwiY2x1Yl9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImNsdWJfdHlwZSI6ImZvb3RiYWxsIiwiY2x1Yl9pbnRlcm5hbF9pZCI6IjgwMTAyMTEiLCJ1cmwiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS9jbHViL2NsdWIvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4IiwibG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsImNhdGVnb3J5IjoiRm90YmFsIiwiY29tcGV0aXRpb25zIjpbeyJpZCI6ImUzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsImNvZGUiOiJBMUEiLCJuYW1lIjoiU0FUVU0gNS4gbGlnYSBtdcW+xa8iLCJ0ZWFtX2NvdW50IjoiMTYiLCJtYXRjaGVzX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS90dXJuYWplL3RhYmxlL2UzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsIm1hdGNoZXMiOlt7ImRhdGVfdGltZSI6IjEwLjA4LjIwMjUgMTc6MDAiLCJob21lIjoiS3JhdmHFmWUiLCJob21lX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzM3N2JmMGFhLTMzZTktNDk4Ny1hYjQyLTM5NzRiYTU4OGQ2Zi8zNzdiZjBhYS0zM2U5LTQ5ODctYWI0Mi0zOTc0YmE1ODhkNmZfY3JvcC5qcGciLCJhd2F5IjoiRksgS29mb2xhIEtybm92IiwiYXdheV9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImF3YXlfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsInNjb3JlIjoiNDowIiwidmVudWUiOiJLcmF2YcWZZSAtIHRyw6F2YSIsIm1hdGNoX2lkIjoiOTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzIiwicmVwb3J0X3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwaXMtby11dGthbmktcmVwb3J0LmFzcHglM0Z6YXBhcz05MTc2ZDhlZC1mMWYxLTQwOTMtYTYxNS03MTI3YWU1Y2NhODNcdTAwMjZ6YXBpcz0xXHUwMDI2bm9wcmludD0xXHUwMDI2YnRucHJpbnQ9MVx1MDAyNi5odG0iLCJmYWNyX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS96YXBhc3kvemFwYXMvOTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzIiwiZGVsZWdhdGlvbl91cmwiOiJodHRwczovL2lzLmZvdGJhbC5jei9wdWJsaWMvemFwYXN5L3phcGFzLWRlbGVnYWNlLXJlcG9ydC5hc3B4JTNGemFwYXM9OTE3NmQ4ZWQtZjFmMS00MDkzLWE2MTUtNzEyN2FlNWNjYTgzXHUwMDI2emFwaXM9MVx1MDAyNmhpZGVtZW51PTFcdTAwMjYuaHRtIn0seyJkYXRlX3RpbWUiOiIxNy4wOC4yMDI1IDE1OjAwIiwiaG9tZSI6IkZLIEtvZm9sYSBLcm5vdiIsImhvbWVfaWQiOiI3ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgiLCJob21lX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OC83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNThfY3JvcC5qcGciLCJhd2F5IjoiQnJ1xaFwZXJrIiwiYXdheV9sb2dvX3VybCI6Ii9kaXN0L2ltZy9sb2dvLWNsdWItZW1wdHkuc3ZnIiwic2NvcmUiOiIxOjMiLCJ2ZW51ZSI6Iktybm92LXRyw6F2YSIsIm1hdGNoX2lkIjoiODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmIiwicmVwb3J0X3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwaXMtby11dGthbmktcmVwb3J0LmFzcHglM0Z6YXBhcz04MTVmZmQ3MC1mMDNhLTQ5ZDAtYjdiNC1iNWNmYTg5Y2UxMmZcdTAwMjZ6YXBpcz0xXHUwMDI2bm9wcmludD0xXHUwMDI2YnRucHJpbnQ9MVx1MDAyNi5odG0iLCJmYWNyX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS96YXBhc3kvemFwYXMvODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmIiwiZGVsZWdhdGlvbl91cmwiOiJodHRwczovL2lzLmZvdGJhbC5jei9wdWJsaWMvemFwYXN5L3phcGFzLWRlbGVnYWNlLXJlcG9ydC5hc3B4JTNGemFwYXM9ODE1ZmZkNzAtZjAzYS00OWQwLWI3YjQtYjVjZmE4OWNlMTJmXHUwMDI2emFwaXM9MVx1MDAyNmhpZGVtZW51PTFcdTAwMjYuaHRtIn0seyJkYXRlX3RpbWUiOiIyNC4wOC4yMDI1IDE2OjMwIiwiaG9tZSI6IkZDIERvbG7DrSBCZW5lxaFvdiIsImhvbWVfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvMDgwZTNhZTEtMmJjNC00ZDkzLWJlYTktZGIyNmRhNzY4ZmE1LzA4MGUzYWUxLTJiYzQtNGQ5My1iZWE5LWRiMjZkYTc2OGZhNV9jcm9wLmpwZyIsImF3YXkiOiJGSyBLb2ZvbGEgS3Jub3YiLCJhd2F5X2lkIjoiN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4IiwiYXdheV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwic2NvcmUiOiIyOjEiLCJ2ZW51ZSI6IkQuIEJlbmXFoW92IC0gdHLDoXZhIiwibWF0Y2hfaWQiOiJmZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDIiLCJyZXBvcnRfdXJsIjoiaHR0cHM6Ly9pcy5mb3RiYWwuY3ovcHVibGljL3phcGFzeS96YXBpcy1vLXV0a2FuaS1yZXBvcnQuYXNweCUzRnphcGFzPWZlNTE2MTc3LWQ0ODQtNDkwNS04YzFjLTIyMTk0NmM1MzkwMlx1MDAyNnphcGlzPTFcdTAwMjZub3ByaW50PTFcdTAwMjZidG5wcmludD0xXHUwMDI2Lmh0bSIsImZhY3JfbGluayI6Imh0dHBzOi8vd3d3LmZvdGJhbC5jei9zb3V0ZXplL3phcGFzeS96YXBhcy9mZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDIiLCJkZWxlZ2F0aW9uX3VybCI6Imh0dHBzOi8vaXMuZm90YmFsLmN6L3B1YmxpYy96YXBhc3kvemFwYXMtZGVsZWdhY2UtcmVwb3J0LmFzcHglM0Z6YXBhcz1mZTUxNjE3Ny1kNDg0LTQ5MDUtOGMxYy0yMjE5NDZjNTM5MDJcdTAwMjZ6YXBpcz0xXHUwMDI2aGlkZW1lbnU9MVx1MDAyNi5odG0ifSx7ImRhdGVfdGltZSI6IjI3LjA4LjIwMjUgMTY6MzAiLCJob21lIjoiRksgS29mb2xhIEtybm92IiwiaG9tZV9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImhvbWVfbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4LzdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OF9jcm9wLmpwZyIsImF3YXkiOiJLb2JlxZlpY2UiLCJhd2F5X2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzU1Zjk2MzA3LWM5MTYtNDgwMS05NDhiLWJjODRmNDZmMjFiZC81NWY5NjMwNy1jOTE2LTQ4MDEtOTQ4Yi1iYzg0ZjQ2ZjIxYmRfY3JvcC5qcGciLCJzY29yZSI6IjM6MSIsInZlbnVlIjoiS3Jub3YtdHLDoXZhIiwibWF0Y2hfaWQiOiI5YWZhZTQzMS1lMDLine truncated
@@ -1 +1 @@
{"data":"eyJuYW1lIjoiRm90YmFsb3bDvSBrbHViIEtybm92IiwiY2x1Yl9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImNsdWJfdHlwZSI6ImZvb3RiYWxsIiwiY2x1Yl9pbnRlcm5hbF9pZCI6IjgwMTAyMTEiLCJsb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwiYWRkcmVzcyI6IjgwMTAyMTEiLCJjYXRlZ29yeSI6IklEIGtsdWJ1IiwiY29tcGV0aXRpb25zIjpbeyJpZCI6ImUzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsImNvZGUiOiJBMUEiLCJuYW1lIjoiU0FUVU0gNS4gbGlnYSBtdcW+xa8iLCJ0ZWFtX2NvdW50IjoiMTYiLCJtYXRjaGVzX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS90dXJuYWplL3RhYmxlL2UzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsInRhYmxlIjp7Im92ZXJhbGwiOlt7InJhbmsiOiIxIiwidGVhbSI6IktyYXZhxZllIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS8zNzdiZjBhYS0zM2U5LTQ5ODctYWI0Mi0zOTc0YmE1ODhkNmYvMzc3YmYwYWEtMzNlOS00OTg3LWFiNDItMzk3NGJhNTg4ZDZmX2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiMTEiLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiIzIiwic2NvcmUiOiI0NToxNSIsInBvaW50cyI6IjM0In0seyJyYW5rIjoiMiIsInRlYW0iOiJIw6FqIHZlIFNsZXpza3UiLCJ0ZWFtX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzI1NzhiOWZmLTkzOGUtNDYxYi05MDkwLWQ5Njk3ZWI5MzcxZi8yNTc4YjlmZi05MzhlLTQ2MWItOTA5MC1kOTY5N2ViOTM3MWZfY3JvcC5qcGciLCJwbGF5ZWQiOiIxNSIsIndpbnMiOiI4IiwiZHJhd3MiOiI0IiwibG9zc2VzIjoiMyIsInNjb3JlIjoiMzE6MTkiLCJwb2ludHMiOiIyOCJ9LHsicmFuayI6IjMiLCJ0ZWFtIjoiRksgSFx1MDAyNlAgU3RhcsOpIE3Em3N0byIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvZWMzYjhmN2YtNTc2NC00YTRlLWIzN2YtNTZkZWE3MDY5NmNiL2VjM2I4ZjdmLTU3NjQtNGE0ZS1iMzdmLTU2ZGVhNzA2OTZjYl9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjgiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI1Iiwic2NvcmUiOiIyNzoyMSIsInBvaW50cyI6IjI2In0seyJyYW5rIjoiNCIsInRlYW0iOiJGSyBNxJtzdG8gQWxicmVjaHRpY2UiLCJ0ZWFtX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5Lzc1MGI4ZDgxLTU0MmItNDg1Yy04YTE4LWZjMGM0OTRmZjQxMS83NTBiOGQ4MS01NDJiLTQ4NWMtOGExOC1mYzBjNDk0ZmY0MTFfY3JvcC5qcGciLCJwbGF5ZWQiOiIxNSIsIndpbnMiOiI3IiwiZHJhd3MiOiI0IiwibG9zc2VzIjoiNCIsInNjb3JlIjoiMjQ6MjEiLCJwb2ludHMiOiIyNSJ9LHsicmFuayI6IjUiLCJ0ZWFtIjoiQmFuw61rIEFsYnJlY2h0aWNlIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS84ODdhNjdkNi1jNjA3LTRlODAtOTFiZS1kMWFmZjk0MDY2OTgvODg3YTY3ZDYtYzYwNy00ZTgwLTkxYmUtZDFhZmY5NDA2Njk4X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiOCIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjYiLCJzY29yZSI6IjI4OjMwIiwicG9pbnRzIjoiMjUifSx7InJhbmsiOiI2IiwidGVhbSI6IktvYmXFmWljZSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvNTVmOTYzMDctYzkxNi00ODAxLTk0OGItYmM4NGY0NmYyMWJkLzU1Zjk2MzA3LWM5MTYtNDgwMS05NDhiLWJjODRmNDZmMjFiZF9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzMToyMyIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiNyIsInRlYW0iOiJNRksgVsOtdGtvdmljZSBCIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS9hZjg4MGQwNi02ZmZjLTQ5M2EtOTRiYi05MGUyYmRhYjcxMTkvYWY4ODBkMDYtNmZmYy00OTNhLTk0YmItOTBlMmJkYWI3MTE5X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNiIsImRyYXdzIjoiNCIsImxvc3NlcyI6IjUiLCJzY29yZSI6IjMzOjI2IiwicG9pbnRzIjoiMjIifSx7InJhbmsiOiI4IiwidGVhbSI6IkJydcWhcGVyayIsInRlYW1fbG9nb191cmwiOiIvZGlzdC9pbWcvbG9nby1jbHViLWVtcHR5LnN2ZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzNDozMSIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiOSIsInRlYW0iOiJGQyBWxZllc2luYSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvZGMwNWY5YzUtYTQzNi00ZmNlLWI5Y2ItMDZjN2ZmODVkMDE5L2RjMDVmOWM1LWE0MzYtNGZjZS1iOWNiLTA2YzdmZjg1ZDAxOV9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzMzozMiIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiMTAiLCJ0ZWFtIjoiSmFrdWLEjW92aWNlIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS80ZTBiNWYyZi00YTI3LTQ0NGMtYmY3Ny1lMzcyNWI4OTgwODYvNGUwYjVmMmYtNGEyNy00NDRjLWJmNzctZTM3MjViODk4MDg2X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNyIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjciLCJzY29yZSI6IjIyOjMxIiwicG9pbnRzIjoiMjIifSx7InJhbmsiOiIxMSIsInRlYW0iOiJTbGF2aWEgT3Jsb3bDoSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvNmVhMDZkM2EtYTdiZi00ZWUyLTk5ZmYtMWJhMWVkYzYyYzhmLzZlYTA2ZDNhLWE3YmYtNGVlMi05OWZmLTFiYTFlZGM2MmM4Zl9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjYiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIyOTozMiIsInBvaW50cyI6IjIwIn0seyJyYW5rIjoiMTIiLCJ0ZWFtIjoiRksgS29mb2xhIEtybm92IiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNiIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjgiLCJzY29yZSI6IjIzOjMwIiwicG9pbnRzIjoiMTkifSx7InJhbmsiOiIxMyIsInRlYW0iOiJTSyBCRVNLWUQgRnJlbsWhdMOhdCBwLiBSLiIsInRlYW1fbG9nb191cmwiOiIvZGlzdC9pbWcvbG9nby1jbHViLWVtcHR5LnN2ZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjUiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI4Iiwic2NvcmUiOiIyMjozMSIsInBvaW50cyI6IjE3In0seyJyYW5rIjoiMTQiLCJ0ZWFtIjoiSGXFmW1hbmljZSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWLine truncated
{"data":"eyJuYW1lIjoiRm90YmFsb3bDvSBrbHViIEtybm92IiwiY2x1Yl9pZCI6IjdlYWNkOWYwLWJmYTAtNDkyOC1hOWI2LTkzNjE0MDE2OGY1OCIsImNsdWJfdHlwZSI6ImZvb3RiYWxsIiwiY2x1Yl9pbnRlcm5hbF9pZCI6IjgwMTAyMTEiLCJsb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwiYWRkcmVzcyI6IjgwMTAyMTEiLCJjYXRlZ29yeSI6IklEIGtsdWJ1IiwiY29tcGV0aXRpb25zIjpbeyJpZCI6ImUzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsImNvZGUiOiJBMUEiLCJuYW1lIjoiU0FUVU0gNS4gbGlnYSBtdcW+xa8iLCJ0ZWFtX2NvdW50IjoiMTYiLCJtYXRjaGVzX2xpbmsiOiJodHRwczovL3d3dy5mb3RiYWwuY3ovc291dGV6ZS90dXJuYWplL3RhYmxlL2UzMTI3ODY1LWExMDktNDVjZC05MDQ4LTNlNjQyOWUyZWIxMSIsInRhYmxlIjp7Im92ZXJhbGwiOlt7InJhbmsiOiIxIiwidGVhbSI6IktyYXZhxZllIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS8zNzdiZjBhYS0zM2U5LTQ5ODctYWI0Mi0zOTc0YmE1ODhkNmYvMzc3YmYwYWEtMzNlOS00OTg3LWFiNDItMzk3NGJhNTg4ZDZmX2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiMTEiLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiIzIiwic2NvcmUiOiI0NToxNSIsInBvaW50cyI6IjM0In0seyJyYW5rIjoiMiIsInRlYW0iOiJIw6FqIHZlIFNsZXpza3UiLCJ0ZWFtX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5LzI1NzhiOWZmLTkzOGUtNDYxYi05MDkwLWQ5Njk3ZWI5MzcxZi8yNTc4YjlmZi05MzhlLTQ2MWItOTA5MC1kOTY5N2ViOTM3MWZfY3JvcC5qcGciLCJwbGF5ZWQiOiIxNSIsIndpbnMiOiI4IiwiZHJhd3MiOiI0IiwibG9zc2VzIjoiMyIsInNjb3JlIjoiMzE6MTkiLCJwb2ludHMiOiIyOCJ9LHsicmFuayI6IjMiLCJ0ZWFtIjoiRksgSFx1MDAyNlAgU3RhcsOpIE3Em3N0byIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvZWMzYjhmN2YtNTc2NC00YTRlLWIzN2YtNTZkZWE3MDY5NmNiL2VjM2I4ZjdmLTU3NjQtNGE0ZS1iMzdmLTU2ZGVhNzA2OTZjYl9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjgiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI1Iiwic2NvcmUiOiIyNzoyMSIsInBvaW50cyI6IjI2In0seyJyYW5rIjoiNCIsInRlYW0iOiJGSyBNxJtzdG8gQWxicmVjaHRpY2UiLCJ0ZWFtX2xvZ29fdXJsIjoiaHR0cHM6Ly9pczEuZm90YmFsLmN6L21lZGlhL2tsdWJ5Lzc1MGI4ZDgxLTU0MmItNDg1Yy04YTE4LWZjMGM0OTRmZjQxMS83NTBiOGQ4MS01NDJiLTQ4NWMtOGExOC1mYzBjNDk0ZmY0MTFfY3JvcC5qcGciLCJwbGF5ZWQiOiIxNSIsIndpbnMiOiI3IiwiZHJhd3MiOiI0IiwibG9zc2VzIjoiNCIsInNjb3JlIjoiMjQ6MjEiLCJwb2ludHMiOiIyNSJ9LHsicmFuayI6IjUiLCJ0ZWFtIjoiQmFuw61rIEFsYnJlY2h0aWNlIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS84ODdhNjdkNi1jNjA3LTRlODAtOTFiZS1kMWFmZjk0MDY2OTgvODg3YTY3ZDYtYzYwNy00ZTgwLTkxYmUtZDFhZmY5NDA2Njk4X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiOCIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjYiLCJzY29yZSI6IjI4OjMwIiwicG9pbnRzIjoiMjUifSx7InJhbmsiOiI2IiwidGVhbSI6IktvYmXFmWljZSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvNTVmOTYzMDctYzkxNi00ODAxLTk0OGItYmM4NGY0NmYyMWJkLzU1Zjk2MzA3LWM5MTYtNDgwMS05NDhiLWJjODRmNDZmMjFiZF9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzMToyMyIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiNyIsInRlYW0iOiJNRksgVsOtdGtvdmljZSBCIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS9hZjg4MGQwNi02ZmZjLTQ5M2EtOTRiYi05MGUyYmRhYjcxMTkvYWY4ODBkMDYtNmZmYy00OTNhLTk0YmItOTBlMmJkYWI3MTE5X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNiIsImRyYXdzIjoiNCIsImxvc3NlcyI6IjUiLCJzY29yZSI6IjMzOjI2IiwicG9pbnRzIjoiMjIifSx7InJhbmsiOiI4IiwidGVhbSI6IkJydcWhcGVyayIsInRlYW1fbG9nb191cmwiOiIvZGlzdC9pbWcvbG9nby1jbHViLWVtcHR5LnN2ZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzNDozMSIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiOSIsInRlYW0iOiJGQyBWxZllc2luYSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvZGMwNWY5YzUtYTQzNi00ZmNlLWI5Y2ItMDZjN2ZmODVkMDE5L2RjMDVmOWM1LWE0MzYtNGZjZS1iOWNiLTA2YzdmZjg1ZDAxOV9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjciLCJkcmF3cyI6IjEiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIzMzozMiIsInBvaW50cyI6IjIyIn0seyJyYW5rIjoiMTAiLCJ0ZWFtIjoiSmFrdWLEjW92aWNlIiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS80ZTBiNWYyZi00YTI3LTQ0NGMtYmY3Ny1lMzcyNWI4OTgwODYvNGUwYjVmMmYtNGEyNy00NDRjLWJmNzctZTM3MjViODk4MDg2X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNyIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjciLCJzY29yZSI6IjIyOjMxIiwicG9pbnRzIjoiMjIifSx7InJhbmsiOiIxMSIsInRlYW0iOiJTbGF2aWEgT3Jsb3bDoSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWVkaWEva2x1YnkvNmVhMDZkM2EtYTdiZi00ZWUyLTk5ZmYtMWJhMWVkYzYyYzhmLzZlYTA2ZDNhLWE3YmYtNGVlMi05OWZmLTFiYTFlZGM2MmM4Zl9jcm9wLmpwZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjYiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI3Iiwic2NvcmUiOiIyOTozMiIsInBvaW50cyI6IjIwIn0seyJyYW5rIjoiMTIiLCJ0ZWFtIjoiRksgS29mb2xhIEtybm92IiwidGVhbV9sb2dvX3VybCI6Imh0dHBzOi8vaXMxLmZvdGJhbC5jei9tZWRpYS9rbHVieS83ZWFjZDlmMC1iZmEwLTQ5MjgtYTliNi05MzYxNDAxNjhmNTgvN2VhY2Q5ZjAtYmZhMC00OTI4LWE5YjYtOTM2MTQwMTY4ZjU4X2Nyb3AuanBnIiwicGxheWVkIjoiMTUiLCJ3aW5zIjoiNiIsImRyYXdzIjoiMSIsImxvc3NlcyI6IjgiLCJzY29yZSI6IjIzOjMwIiwicG9pbnRzIjoiMTkifSx7InJhbmsiOiIxMyIsInRlYW0iOiJTSyBCRVNLWUQgRnJlbsWhdMOhdCBwLiBSLiIsInRlYW1fbG9nb191cmwiOiIvZGlzdC9pbWcvbG9nby1jbHViLWVtcHR5LnN2ZyIsInBsYXllZCI6IjE1Iiwid2lucyI6IjUiLCJkcmF3cyI6IjIiLCJsb3NzZXMiOiI4Iiwic2NvcmUiOiIyMjozMSIsInBvaW50cyI6IjE3In0seyJyYW5rIjoiMTQiLCJ0ZWFtIjoiSGXFmW1hbmljZSIsInRlYW1fbG9nb191cmwiOiJodHRwczovL2lzMS5mb3RiYWwuY3ovbWLine truncated
+1 -1
View File
@@ -1 +1 @@
{"items":[{"ID":1,"CreatedAt":"2025-11-09T12:01:17.497052Z","UpdatedAt":"2025-11-09T12:06:11.904927Z","DeletedAt":null,"title":"Historie a úspěchy FC Baník Ostrava","content":"\u003ch2\u003e\u003cspan style=\"color: rgb(230, 0, 0);\"\u003eHistorie a úspěchy FC Baník Ostrava\u003c/span\u003e\u003c/h2\u003e\u003cp\u003eFC Baník Ostrava je jeden z nejznámějších a nejúspěšnějších fotbalových klubů v České republice. Klub byl založen v roce 1922 a od té doby prošel mnoha změnami a úspěchy, které ho postavily na mapu českého fotbalu.\u003c/p\u003e\u003ch3\u003eZaložení a rané roky\u003c/h3\u003e\u003cp\u003eFC Baník Ostrava byl založen v roce 1922 pod názvem SK Slezská Ostrava. V roce 1945 se klub přejmenoval na Sokol Slezská Ostrava a v roce 1952 na Baník Ostrava. Tato změna názvu odrážela původ klubových hráčů, kteří byli zaměstnanci dolů v Ostravské pánvi.\u003c/p\u003e\u003ch3\u003eÚspěchy v československé éře\u003c/h3\u003e\u003cp\u003eV době Československa byl Baník Ostrava jedním z nejúspěšnějších klubů. Klub vyhrál ligový titul v sezóně 1975/76 a 1977/78. Tyto vítězství jsou dodnes považována za vrcholné momenty v historii klubu. Baník Ostrava také dvakrát vyhrál Československý pohár v letech 1973 a 1978.\u003c/p\u003e\u003ch3\u003eModerní éra\u003c/h3\u003e\u003cp\u003ePo rozpadě Československa pokračoval Baník Ostrava ve své úspěšné dráze. V sezóně 1990/91 vyhrál klub československý pohár, což bylo jeho poslední velké vítězství v samostatné éře. V české lize se Baník Ostrava několikrát umístil na pódiu, ale titul zůstal nedosahatelným cílem.\u003c/p\u003e\u003ch3\u003eHráči a trenéři\u003c/h3\u003e\u003cp\u003eMezi nejvýznamnější hráče v historii klubu patří Petr Johana, Libor Kozák a Petr Gabriel. V současnosti je trenérem klubu Radim Kučera, který se snaží vrátit klub zpět do vrcholové ligy.\u003c/p\u003e\u003ch3\u003eBudoucnost klubu\u003c/h3\u003e\u003cp\u003eFC Baník Ostrava se i nadále snaží o návrat do elitní ligy a získání dalších trofejí. Klub má silnou podporu fanoušků a ambiciózní plány na budoucnost. S novými investicemi a talentovanými hráči může být Baník Ostrava opět jedním z nejlepších týmů v České republice.\u003c/p\u003e","author_id":1,"author":{"ID":1,"CreatedAt":"2025-11-09T10:47:29.393135Z","UpdatedAt":"2025-11-09T10:47:48.678204Z","DeletedAt":null,"email":"[email protected]","first_name":"Tomas","last_name":"Dvorak","role":"admin","IsActive":true,"last_login":"2025-11-09T10:47:48.677897Z"},"category_id":2,"category":{"ID":2,"CreatedAt":"2025-11-09T12:02:46.113166Z","UpdatedAt":"2025-11-09T12:02:46.113166Z","DeletedAt":null,"name":"PC U1E U-10 Šumperk","description":"","slug":"pc-u1e-u-10-sumperk"},"image_url":"https://eu.zonerama.com/photos/576619562_1500x1000.jpg","published":true,"published_at":"2025-11-09T12:06:11.90135Z","slug":"historie-uspechy-fc-banik-ostrava","excerpt":"","featured":true,"seo_title":"Historie a úspěchy FC Baník Ostrava | Fotbalový klub Krnov","seo_description":"Historie a úspěchy FC Baník Ostrava FC Baník Ostrava je jeden z nejznámějších a nejúspěšnějších fotbalových klubů v České republice. Klub byl založen v roce...","og_image_url":"https://eu.zonerama.com/photos/576619562_1500x1000.jpg","external_link":"","view_count":0,"read_time":2,"unique_views":0,"category_name":"","attachments":"[{\"name\":\"5JjxvmK.jpg\",\"url\":\"/uploads/upload_1762689934_82a47c3d8d60f247.jpg\",\"mime_type\":\"image/jpeg\",\"size\":28203}]","gallery_album_id":"","gallery_album_url":"","gallery_photo_ids":"","youtube_video_id":"nGv61kag-9I","youtube_video_title":"Bizoni UH-Helas Brno\\","youtube_video_url":"https://www.youtube.com/watch?v=nGv61kag-9I","youtube_video_thumbnail":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","match_link":null,"category_slug":"pc-u1e-u-10-sumperk","competition_alias":"PC U1E U-10 Šumperk","normalized_category":"pc u1e u-10 sumperk","url":"/news/historie-uspechy-fc-banik-ostrava"}],"page":1,"page_size":10,"total":1}
{"items":[],"page":1,"page_size":10,"total":0}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"A1A","alias":"Muži A","original_name":"SATUM 5. liga mužů","display_order":1},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"C1A","alias":"U19","original_name":"KALMAN TRADE Krajský přebor starší dorost","display_order":2},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"D1A","alias":"U17","original_name":"KALMAN TRADE Krajský přebor mladší dorost","display_order":3},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E1S","alias":"2.MSŽL-U 15 sk. E","original_name":"2.MSŽL-U 15 sk. E","display_order":4},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E2S","alias":"2.MSŽL-U 14 sk. E","original_name":"2.MSŽL-U 14 sk. E","display_order":5},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F1S","alias":"1. liga SpSM-U 13 SEVER","original_name":"1. liga SpSM-U 13 SEVER","display_order":6},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F2S","alias":"1. liga SpSM-U 12 SEVER","original_name":"1. liga SpSM-U 12 SEVER","display_order":7},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"G1D","alias":"Starší přípravka 1+5 sk.D","original_name":"Starší přípravka 1+5 sk.D","display_order":8},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1A","alias":"Okresní přebor mladší přípravky (4+1)","original_name":"Okresní přebor mladší přípravky (4+1)","display_order":9},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1C","alias":"Mladší přípravka 1+4 sk.C","original_name":"Mladší přípravka 1+4 sk.C","display_order":10},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"U1E","alias":"PC U1E U-10 Šumperk","original_name":"PC U1E U-10 Šumperk","display_order":11},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V5B","alias":"PC V5B U-9 Hlučín","original_name":"PC V5B U-9 Hlučín","display_order":12},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V1C","alias":"PC V1C U-8 Nový Jičín","original_name":"PC V1C U-8 Nový Jičín","display_order":13},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V2B","alias":"PC V2B U-8 Uničov","original_name":"PC V2B U-8 Uničov","display_order":14}]
[{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"A1A","alias":"SATUM 5. liga mužů","original_name":"SATUM 5. liga mužů","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"C1A","alias":"KALMAN TRADE Krajský přebor starší dorost","original_name":"KALMAN TRADE Krajský přebor starší dorost","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"D1A","alias":"KALMAN TRADE Krajský přebor mladší dorost","original_name":"KALMAN TRADE Krajský přebor mladší dorost","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E1S","alias":"2.MSŽL-U 15 sk. E","original_name":"2.MSŽL-U 15 sk. E","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"E2S","alias":"2.MSŽL-U 14 sk. E","original_name":"2.MSŽL-U 14 sk. E","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F1S","alias":"1. liga SpSM-U 13 SEVER","original_name":"1. liga SpSM-U 13 SEVER","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"F2S","alias":"1. liga SpSM-U 12 SEVER","original_name":"1. liga SpSM-U 12 SEVER","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"G1D","alias":"Starší přípravka 1+5 sk.D","original_name":"Starší přípravka 1+5 sk.D","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1A","alias":"Okresní přebor mladší přípravky (4+1)","original_name":"Okresní přebor mladší přípravky (4+1)","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"H1C","alias":"Mladší přípravka 1+4 sk.C","original_name":"Mladší přípravka 1+4 sk.C","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"U1E","alias":"PC U1E U-10 Šumperk","original_name":"PC U1E U-10 Šumperk","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V1C","alias":"PC V1C U-8 Nový Jičín","original_name":"PC V1C U-8 Nový Jičín","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V2B","alias":"PC V2B U-8 Uničov","original_name":"PC V2B U-8 Uničov","display_order":0},{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"code":"V5B","alias":"PC V5B U-9 Hlučín","original_name":"PC V5B U-9 Hlučín","display_order":0}]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"id":1,"created_at":"2025-11-09T12:10:25.543277Z","updated_at":"2025-11-09T12:10:45.039163Z","title":"Přátelský zápas Fotbalového klubu Krnov","description":"\u003cp\u003eFanoušci Fotbalového klubu Krnov mají možnost se zúčastnit přátelského zápasu, který připravil klub pro své příznivce. Tato událost představuje skvělou příležitost, jak podpořit tým a zažít atmosféru fotbalového utkání.Přátelské zápasy jsou důležitou součástí přípravy týmu, během nichž hráči mají možnost procvičit si nové taktiky a zlepšit svou formu. Pro fanoušky je to také příležitost vidět své oblíbené hráče v akci a podpořit je v neformálním prostředí.Přidejte se k nám a užijte si den plný fotbalového nadšení a dobré nálady. Více informací o termínu a místě konání bude zveřejněno v blízké době.\u003c/p\u003e","start_time":"2025-11-10T08:00:00Z","end_time":"2025-11-10T10:00:00Z","location":"Petrovická, Krnov, 794 01","type":"training","category_name":"U17","is_public":true,"created_by_id":1,"created_by":{"ID":0,"CreatedAt":"0001-01-01T00:00:00Z","UpdatedAt":"0001-01-01T00:00:00Z","DeletedAt":null,"email":"","first_name":"","last_name":"","role":"","IsActive":false},"image_url":"/uploads/upload_1762690234_9b05a55a8e838606.png","file_url":"","attachments":[{"id":2,"created_at":"2025-11-09T12:10:45.048076Z","updated_at":"2025-11-09T12:10:45.048076Z","event_id":1,"name":"club_logo_filename_20221010_083916.png","url":"/uploads/upload_1762690236_dcd845524f468432.png","mime_type":"image/png","size":39233}],"youtube_url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs","latitude":50.0948669,"longitude":17.7001456}]
[]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"name":"Fotbalový klub Krnov","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_type":"football","club_internal_id":"8010211","url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","category":"Fotbal","competitions":[{"id":"e3127865-a109-45cd-9048-3e6429e2eb11","code":"A1A","name":"SATUM 5. liga mužů","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/e3127865-a109-45cd-9048-3e6429e2eb11","matches":[{"date_time":"10.08.2025 17:00","home":"Kravaře","home_logo_url":"https://is1.fotbal.cz/media/kluby/377bf0aa-33e9-4987-ab42-3974ba588d6f/377bf0aa-33e9-4987-ab42-3974ba588d6f_crop.jpg","away":"FK Kofola Krnov","away_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","away_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","score":"4:0","venue":"Kravaře - tráva","match_id":"9176d8ed-f1f1-4093-a615-7127ae5cca83","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=9176d8ed-f1f1-4093-a615-7127ae5cca83\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/9176d8ed-f1f1-4093-a615-7127ae5cca83","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=9176d8ed-f1f1-4093-a615-7127ae5cca83\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"17.08.2025 15:00","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"Brušperk","away_logo_url":"/dist/img/logo-club-empty.svg","score":"1:3","venue":"Krnov-tráva","match_id":"815ffd70-f03a-49d0-b7b4-b5cfa89ce12f","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=815ffd70-f03a-49d0-b7b4-b5cfa89ce12f\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/815ffd70-f03a-49d0-b7b4-b5cfa89ce12f","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=815ffd70-f03a-49d0-b7b4-b5cfa89ce12f\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"24.08.2025 16:30","home":"FC Dolní Benešov","home_logo_url":"https://is1.fotbal.cz/media/kluby/080e3ae1-2bc4-4d93-bea9-db26da768fa5/080e3ae1-2bc4-4d93-bea9-db26da768fa5_crop.jpg","away":"FK Kofola Krnov","away_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","away_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","score":"2:1","venue":"D. Benešov - tráva","match_id":"fe516177-d484-4905-8c1c-221946c53902","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=fe516177-d484-4905-8c1c-221946c53902\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/fe516177-d484-4905-8c1c-221946c53902","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=fe516177-d484-4905-8c1c-221946c53902\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"27.08.2025 16:30","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"Kobeřice","away_logo_url":"https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg","score":"3:1","venue":"Krnov-tráva","match_id":"9afae431-e091-48b8-a023-4cc633cc6f86","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=9afae431-e091-48b8-a023-4cc633cc6f86\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/9afae431-e091-48b8-a023-4cc633cc6f86","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=9afae431-e091-48b8-a023-4cc633cc6f86\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"31.08.2025 15:00","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"FK H\u0026P Staré Město","away_logo_url":"https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg","score":"2:0","venue":"Krnov-tráva","match_id":"b4d518a5-e2e7-42d1-8455-817ccd78a225","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=b4d518a5-e2e7-42d1-8455-817ccd78a225\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/b4d518a5-e2e7-42d1-8455-817ccd78a225","delegation_url":"https://Line truncated
{"name":"Fotbalový klub Krnov","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_type":"football","club_internal_id":"8010211","url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","category":"Fotbal","competitions":[{"id":"e3127865-a109-45cd-9048-3e6429e2eb11","code":"A1A","name":"SATUM 5. liga mužů","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/e3127865-a109-45cd-9048-3e6429e2eb11","matches":[{"date_time":"10.08.2025 17:00","home":"Kravaře","home_logo_url":"https://is1.fotbal.cz/media/kluby/377bf0aa-33e9-4987-ab42-3974ba588d6f/377bf0aa-33e9-4987-ab42-3974ba588d6f_crop.jpg","away":"FK Kofola Krnov","away_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","away_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","score":"4:0","venue":"Kravaře - tráva","match_id":"9176d8ed-f1f1-4093-a615-7127ae5cca83","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=9176d8ed-f1f1-4093-a615-7127ae5cca83\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/9176d8ed-f1f1-4093-a615-7127ae5cca83","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=9176d8ed-f1f1-4093-a615-7127ae5cca83\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"17.08.2025 15:00","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"Brušperk","away_logo_url":"/dist/img/logo-club-empty.svg","score":"1:3","venue":"Krnov-tráva","match_id":"815ffd70-f03a-49d0-b7b4-b5cfa89ce12f","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=815ffd70-f03a-49d0-b7b4-b5cfa89ce12f\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/815ffd70-f03a-49d0-b7b4-b5cfa89ce12f","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=815ffd70-f03a-49d0-b7b4-b5cfa89ce12f\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"24.08.2025 16:30","home":"FC Dolní Benešov","home_logo_url":"https://is1.fotbal.cz/media/kluby/080e3ae1-2bc4-4d93-bea9-db26da768fa5/080e3ae1-2bc4-4d93-bea9-db26da768fa5_crop.jpg","away":"FK Kofola Krnov","away_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","away_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","score":"2:1","venue":"D. Benešov - tráva","match_id":"fe516177-d484-4905-8c1c-221946c53902","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=fe516177-d484-4905-8c1c-221946c53902\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/fe516177-d484-4905-8c1c-221946c53902","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=fe516177-d484-4905-8c1c-221946c53902\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"27.08.2025 16:30","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"Kobeřice","away_logo_url":"https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg","score":"3:1","venue":"Krnov-tráva","match_id":"9afae431-e091-48b8-a023-4cc633cc6f86","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=9afae431-e091-48b8-a023-4cc633cc6f86\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/9afae431-e091-48b8-a023-4cc633cc6f86","delegation_url":"https://is.fotbal.cz/public/zapasy/zapas-delegace-report.aspx%3Fzapas=9afae431-e091-48b8-a023-4cc633cc6f86\u0026zapis=1\u0026hidemenu=1\u0026.htm"},{"date_time":"31.08.2025 15:00","home":"FK Kofola Krnov","home_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","home_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","away":"FK H\u0026P Staré Město","away_logo_url":"https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg","score":"2:0","venue":"Krnov-tráva","match_id":"b4d518a5-e2e7-42d1-8455-817ccd78a225","report_url":"https://is.fotbal.cz/public/zapasy/zapis-o-utkani-report.aspx%3Fzapas=b4d518a5-e2e7-42d1-8455-817ccd78a225\u0026zapis=1\u0026noprint=1\u0026btnprint=1\u0026.htm","facr_link":"https://www.fotbal.cz/souteze/zapasy/zapas/b4d518a5-e2e7-42d1-8455-817ccd78a225","delegation_url":"https://Line truncated
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-09T13:55:44Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:19Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"name":"Fotbalový klub Krnov","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_type":"football","club_internal_id":"8010211","logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","address":"8010211","category":"ID klubu","competitions":[{"id":"e3127865-a109-45cd-9048-3e6429e2eb11","code":"A1A","name":"SATUM 5. liga mužů","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/e3127865-a109-45cd-9048-3e6429e2eb11","table":{"overall":[{"rank":"1","team":"Kravaře","team_logo_url":"https://is1.fotbal.cz/media/kluby/377bf0aa-33e9-4987-ab42-3974ba588d6f/377bf0aa-33e9-4987-ab42-3974ba588d6f_crop.jpg","played":"15","wins":"11","draws":"1","losses":"3","score":"45:15","points":"34"},{"rank":"2","team":"Háj ve Slezsku","team_logo_url":"https://is1.fotbal.cz/media/kluby/2578b9ff-938e-461b-9090-d9697eb9371f/2578b9ff-938e-461b-9090-d9697eb9371f_crop.jpg","played":"15","wins":"8","draws":"4","losses":"3","score":"31:19","points":"28"},{"rank":"3","team":"FK H\u0026P Staré Město","team_logo_url":"https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg","played":"15","wins":"8","draws":"2","losses":"5","score":"27:21","points":"26"},{"rank":"4","team":"FK Město Albrechtice","team_logo_url":"https://is1.fotbal.cz/media/kluby/750b8d81-542b-485c-8a18-fc0c494ff411/750b8d81-542b-485c-8a18-fc0c494ff411_crop.jpg","played":"15","wins":"7","draws":"4","losses":"4","score":"24:21","points":"25"},{"rank":"5","team":"Baník Albrechtice","team_logo_url":"https://is1.fotbal.cz/media/kluby/887a67d6-c607-4e80-91be-d1aff9406698/887a67d6-c607-4e80-91be-d1aff9406698_crop.jpg","played":"15","wins":"8","draws":"1","losses":"6","score":"28:30","points":"25"},{"rank":"6","team":"Kobeřice","team_logo_url":"https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"31:23","points":"22"},{"rank":"7","team":"MFK Vítkovice B","team_logo_url":"https://is1.fotbal.cz/media/kluby/af880d06-6ffc-493a-94bb-90e2bdab7119/af880d06-6ffc-493a-94bb-90e2bdab7119_crop.jpg","played":"15","wins":"6","draws":"4","losses":"5","score":"33:26","points":"22"},{"rank":"8","team":"Brušperk","team_logo_url":"/dist/img/logo-club-empty.svg","played":"15","wins":"7","draws":"1","losses":"7","score":"34:31","points":"22"},{"rank":"9","team":"FC Vřesina","team_logo_url":"https://is1.fotbal.cz/media/kluby/dc05f9c5-a436-4fce-b9cb-06c7ff85d019/dc05f9c5-a436-4fce-b9cb-06c7ff85d019_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"33:32","points":"22"},{"rank":"10","team":"Jakubčovice","team_logo_url":"https://is1.fotbal.cz/media/kluby/4e0b5f2f-4a27-444c-bf77-e3725b898086/4e0b5f2f-4a27-444c-bf77-e3725b898086_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"22:31","points":"22"},{"rank":"11","team":"Slavia Orlová","team_logo_url":"https://is1.fotbal.cz/media/kluby/6ea06d3a-a7bf-4ee2-99ff-1ba1edc62c8f/6ea06d3a-a7bf-4ee2-99ff-1ba1edc62c8f_crop.jpg","played":"15","wins":"6","draws":"2","losses":"7","score":"29:32","points":"20"},{"rank":"12","team":"FK Kofola Krnov","team_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","played":"15","wins":"6","draws":"1","losses":"8","score":"23:30","points":"19"},{"rank":"13","team":"SK BESKYD Frenštát p. R.","team_logo_url":"/dist/img/logo-club-empty.svg","played":"15","wins":"5","draws":"2","losses":"8","score":"22:31","points":"17"},{"rank":"14","team":"Heřmanice","team_logo_url":"https://is1.fotbal.cz/media/kluby/c32dc305-6b78-41c7-8053-d8644eef96f1/c32dc305-6b78-41c7-8053-d8644eef96f1_crop.jpg","played":"15","wins":"3","draws":"5","losses":"7","score":"20:34","points":"14"},{"rank":"15","team":"Darkovičky","team_logo_url":"https://is1.fotbal.cz/media/kluby/8e207b30-7b68-44bb-ad08-bc25495dd094/8e207b30-7b68-44bb-ad08-bc25495dd094_crop.jpg","played":"15","wins":"3","draws":"4","losses":"8","score":"20:26","points":"13"},{"rank":"16","team":"FC Dolní Benešov","team_logo_url":"https://is1.fotbal.cz/media/kluby/080e3ae1-2bc4-4d93-bea9-db26da768fa5/080e3ae1-2bc4-4d93-bea9-db26da768fa5_crop.jpg","played":"15","wins":"3","draws":"2","losses":"10","score":"22:42","points":"11"}]}},{"id":"7ae7e3d0-ab3c-4afe-af6d-4a26d74ea554","code":"C1A","name":"KALMAN TRADE Krajský přebor starší dorost","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/7ae7e3d0-ab3c-4afe-af6d-4a26d74ea554","table":{"overall":[{"rank":"1","team":"Hlubina","team_logo_url":"https://is1.fotbal.cz/media/kluby/da62c705-ca73-4561-9ea4-ab93e6afce88/da62c705-ca73-4561-9ea4-ab93e6afce88_crop.jpg","played":"15","wins":"14","draws":"0","losses":"1","score":"74:14","points":"42"},{"rank":"2","team":"Polanka nad Odrou","team_logo_url":"https://is1.fotbal.Line truncated
{"name":"Fotbalový klub Krnov","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_type":"football","club_internal_id":"8010211","logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","address":"8010211","category":"ID klubu","competitions":[{"id":"e3127865-a109-45cd-9048-3e6429e2eb11","code":"A1A","name":"SATUM 5. liga mužů","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/e3127865-a109-45cd-9048-3e6429e2eb11","table":{"overall":[{"rank":"1","team":"Kravaře","team_logo_url":"https://is1.fotbal.cz/media/kluby/377bf0aa-33e9-4987-ab42-3974ba588d6f/377bf0aa-33e9-4987-ab42-3974ba588d6f_crop.jpg","played":"15","wins":"11","draws":"1","losses":"3","score":"45:15","points":"34"},{"rank":"2","team":"Háj ve Slezsku","team_logo_url":"https://is1.fotbal.cz/media/kluby/2578b9ff-938e-461b-9090-d9697eb9371f/2578b9ff-938e-461b-9090-d9697eb9371f_crop.jpg","played":"15","wins":"8","draws":"4","losses":"3","score":"31:19","points":"28"},{"rank":"3","team":"FK H\u0026P Staré Město","team_logo_url":"https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg","played":"15","wins":"8","draws":"2","losses":"5","score":"27:21","points":"26"},{"rank":"4","team":"FK Město Albrechtice","team_logo_url":"https://is1.fotbal.cz/media/kluby/750b8d81-542b-485c-8a18-fc0c494ff411/750b8d81-542b-485c-8a18-fc0c494ff411_crop.jpg","played":"15","wins":"7","draws":"4","losses":"4","score":"24:21","points":"25"},{"rank":"5","team":"Baník Albrechtice","team_logo_url":"https://is1.fotbal.cz/media/kluby/887a67d6-c607-4e80-91be-d1aff9406698/887a67d6-c607-4e80-91be-d1aff9406698_crop.jpg","played":"15","wins":"8","draws":"1","losses":"6","score":"28:30","points":"25"},{"rank":"6","team":"Kobeřice","team_logo_url":"https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"31:23","points":"22"},{"rank":"7","team":"MFK Vítkovice B","team_logo_url":"https://is1.fotbal.cz/media/kluby/af880d06-6ffc-493a-94bb-90e2bdab7119/af880d06-6ffc-493a-94bb-90e2bdab7119_crop.jpg","played":"15","wins":"6","draws":"4","losses":"5","score":"33:26","points":"22"},{"rank":"8","team":"Brušperk","team_logo_url":"/dist/img/logo-club-empty.svg","played":"15","wins":"7","draws":"1","losses":"7","score":"34:31","points":"22"},{"rank":"9","team":"FC Vřesina","team_logo_url":"https://is1.fotbal.cz/media/kluby/dc05f9c5-a436-4fce-b9cb-06c7ff85d019/dc05f9c5-a436-4fce-b9cb-06c7ff85d019_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"33:32","points":"22"},{"rank":"10","team":"Jakubčovice","team_logo_url":"https://is1.fotbal.cz/media/kluby/4e0b5f2f-4a27-444c-bf77-e3725b898086/4e0b5f2f-4a27-444c-bf77-e3725b898086_crop.jpg","played":"15","wins":"7","draws":"1","losses":"7","score":"22:31","points":"22"},{"rank":"11","team":"Slavia Orlová","team_logo_url":"https://is1.fotbal.cz/media/kluby/6ea06d3a-a7bf-4ee2-99ff-1ba1edc62c8f/6ea06d3a-a7bf-4ee2-99ff-1ba1edc62c8f_crop.jpg","played":"15","wins":"6","draws":"2","losses":"7","score":"29:32","points":"20"},{"rank":"12","team":"FK Kofola Krnov","team_logo_url":"https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg","played":"15","wins":"6","draws":"1","losses":"8","score":"23:30","points":"19"},{"rank":"13","team":"SK BESKYD Frenštát p. R.","team_logo_url":"/dist/img/logo-club-empty.svg","played":"15","wins":"5","draws":"2","losses":"8","score":"22:31","points":"17"},{"rank":"14","team":"Heřmanice","team_logo_url":"https://is1.fotbal.cz/media/kluby/c32dc305-6b78-41c7-8053-d8644eef96f1/c32dc305-6b78-41c7-8053-d8644eef96f1_crop.jpg","played":"15","wins":"3","draws":"5","losses":"7","score":"20:34","points":"14"},{"rank":"15","team":"Darkovičky","team_logo_url":"https://is1.fotbal.cz/media/kluby/8e207b30-7b68-44bb-ad08-bc25495dd094/8e207b30-7b68-44bb-ad08-bc25495dd094_crop.jpg","played":"15","wins":"3","draws":"4","losses":"8","score":"20:26","points":"13"},{"rank":"16","team":"FC Dolní Benešov","team_logo_url":"https://is1.fotbal.cz/media/kluby/080e3ae1-2bc4-4d93-bea9-db26da768fa5/080e3ae1-2bc4-4d93-bea9-db26da768fa5_crop.jpg","played":"15","wins":"3","draws":"2","losses":"10","score":"22:42","points":"11"}]}},{"id":"7ae7e3d0-ab3c-4afe-af6d-4a26d74ea554","code":"C1A","name":"KALMAN TRADE Krajský přebor starší dorost","team_count":"16","matches_link":"https://www.fotbal.cz/souteze/turnaje/table/7ae7e3d0-ab3c-4afe-af6d-4a26d74ea554","table":{"overall":[{"rank":"1","team":"Hlubina","team_logo_url":"https://is1.fotbal.cz/media/kluby/da62c705-ca73-4561-9ea4-ab93e6afce88/da62c705-ca73-4561-9ea4-ab93e6afce88_crop.jpg","played":"15","wins":"14","draws":"0","losses":"1","score":"74:14","points":"42"},{"rank":"2","team":"Polanka nad Odrou","team_logo_url":"https://is1.fotbal.Line truncated
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:26Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:23Z","last_modified":""}
-11
View File
@@ -1,15 +1,4 @@
[
{
"away": "FK Kofola Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "SATUM 5. liga mužů",
"date": "2025-11-09",
"home": "FC Vřesina",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/dc05f9c5-a436-4fce-b9cb-06c7ff85d019/dc05f9c5-a436-4fce-b9cb-06c7ff85d019_crop.jpg",
"id": "03347fa2-2d39-49e0-840b-b5a1fea723e2",
"time": "14:00",
"venue": "Vřesina - tráva"
},
{
"away": "FK Kofola Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
-101
View File
@@ -1,101 +0,0 @@
[
{
"away": "FK Kofola Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "SATUM 5. liga mužů",
"date": "2025-11-15",
"home": "Kobeřice",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/55f96307-c916-4801-948b-bc84f46f21bd/55f96307-c916-4801-948b-bc84f46f21bd_crop.jpg",
"id": "761a2e5a-8b0f-4514-b35c-ba019c957a3e",
"time": "13:30",
"venue": "Kobeřice - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor starší dorost",
"date": "2025-11-16",
"home": "FK H\u0026P Staré Město",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg",
"id": "8211e3c7-3cef-4be8-88b7-367fa5960506",
"time": "10:00",
"venue": "Chlebovice - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "KALMAN TRADE Krajský přebor mladší dorost",
"date": "2025-11-16",
"home": "FK H\u0026P Staré Město",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/ec3b8f7f-5764-4a4e-b37f-56dea70696cb/ec3b8f7f-5764-4a4e-b37f-56dea70696cb_crop.jpg",
"id": "3ac0d48d-0353-4e85-b313-695db2909cff",
"time": "12:15",
"venue": "Chlebovice - tráva"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 15 sk. E",
"date": "2025-11-19",
"home": "Karviná",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/4cbe25e6-57f3-41c0-8d92-782b19b61731/4cbe25e6-57f3-41c0-8d92-782b19b61731_crop.jpg",
"id": "8604ff36-b0df-46c1-92a1-10c04d01ce07",
"time": "17:30",
"venue": "UMT Kovona"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 15 sk. E",
"date": "2025-11-16",
"home": "Valašské Meziříčí",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "42b21b39-2f7e-466c-98ac-3969afd46b75",
"time": "10:00",
"venue": "Valašské Meziříčí"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 14 sk. E",
"date": "2025-11-19",
"home": "Karviná",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/4cbe25e6-57f3-41c0-8d92-782b19b61731/4cbe25e6-57f3-41c0-8d92-782b19b61731_crop.jpg",
"id": "883313c6-7766-4496-a1f4-aa0365e683b6",
"time": "17:30",
"venue": "UT - Městský stadion"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "2.MSŽL-U 14 sk. E",
"date": "2025-11-16",
"home": "Valašské Meziříčí",
"home_logo_url": "/dist/img/logo-club-empty.svg",
"id": "fe82ff0c-75e9-4ff0-9834-8a42a5053427",
"time": "12:00",
"venue": "Valašské Meziříčí"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "1. liga SpSM-U 13 SEVER",
"date": "2025-11-15",
"home": "VÍTKOVICE",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/a3ff17d6-0888-47e7-9dee-0a98ec8734d0/a3ff17d6-0888-47e7-9dee-0a98ec8734d0_crop.jpg",
"id": "3090d0e0-2d1e-44df-8312-f223673fedcb",
"time": "10:00",
"venue": "UT Vista"
},
{
"away": "Krnov",
"away_logo_url": "https://is1.fotbal.cz/media/kluby/7eacd9f0-bfa0-4928-a9b6-936140168f58/7eacd9f0-bfa0-4928-a9b6-936140168f58_crop.jpg",
"competition": "1. liga SpSM-U 12 SEVER",
"date": "2025-11-15",
"home": "VÍTKOVICE",
"home_logo_url": "https://is1.fotbal.cz/media/kluby/a3ff17d6-0888-47e7-9dee-0a98ec8734d0/a3ff17d6-0888-47e7-9dee-0a98ec8734d0_crop.jpg",
"id": "8fed4192-b8df-4301-a2b9-f97c46f7cacc",
"time": "12:00",
"venue": "UT Vista"
}
]
+1 -1
View File
@@ -1 +1 @@
{"lastUpdated":"2025-11-09T13:55:44Z"}
{"lastUpdated":"2025-11-12T19:22:23Z"}
+12 -12
View File
@@ -1,7 +1,17 @@
{
"baseURL": "http://localhost:8080/api/v1",
"duration_ms": 67,
"duration_ms": 5950,
"endpoints": [
{
"path": "/sponsors",
"file": "sponsors.json",
"ok": true
},
{
"path": "/events/upcoming",
"file": "events_upcoming.json",
"ok": true
},
{
"path": "/public/team-logo-overrides",
"file": "team_logo_overrides.json",
@@ -27,16 +37,6 @@
"file": "articles.json",
"ok": true
},
{
"path": "/sponsors",
"file": "sponsors.json",
"ok": true
},
{
"path": "/events/upcoming",
"file": "events_upcoming.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
"file": "facr_club_info.json",
@@ -48,5 +48,5 @@
"ok": true
}
],
"lastUpdated": "2025-11-09T13:55:44Z"
"lastUpdated": "2025-11-12T19:22:23Z"
}
+1 -1
View File
@@ -1 +1 @@
{"additional_meta":"","canonical_base_url":"http://localhost:3000","default_og_image_url":"http://logoapi.sportcreative.eu/logos/7eacd9f0-bfa0-4928-a9b6-936140168f58?format=svg","enable_indexing":true,"meta_keywords":"","site_description":"Fotbalový klub Krnov – oficiální klubový web: aktuality, zápasy, tabulky, hráči.","site_title":"Fotbalový klub Krnov","twitter_handle":""}
{"additional_meta":"","canonical_base_url":"","default_og_image_url":"","enable_indexing":false,"meta_keywords":"","site_description":"","site_title":"","twitter_handle":""}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"about_html":"","accent_color":"#ffae00","api_base_url":"http://localhost:8080/api/v1","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"/uploads/logos/club/7eacd9f0-bfa0-4928-a9b6-936140168f58/club-logo.svg","club_name":"Fotbalový klub Krnov","club_type":"football","club_url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","contact_address":"Petrovická","contact_city":"Krnov","contact_country":"Česko","contact_email":"[email protected]","contact_phone":"+420778701838","contact_zip":"794 01","custom_nav":null,"facebook_url":"https://www.facebook.com/people/FK-Kofola-Krnov/61561103731912","font_body":"Archivo","font_heading":"Archivo","frontend_base_url":"http://localhost:3000","gallery_label":"","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov/1470757","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":50.0948669,"location_longitude":17.7001456,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","premium":false,"primary_color":"#ffd500","secondary_color":"#0033ff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","title":"Bizoni UH-Helas Brno\\","uploaded_at":"2025-11-08","url":"https://www.youtube.com/watch?v=nGv61kag-9I"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=_OsRmfYOXJ4"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","uploaded_at":"2025-10-09","url":"https://www.youtube.com/watch?v=h_-TS6oVvKA"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg","title":"Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH","uploaded_at":"2025-10-09","url":"https://www.youtube.com/watch?v=ozH8xE7V458"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","youtube_url":"https://www.youtube.com/@FCBizoniUH"}
{"about_html":"","accent_color":"#ffae00","api_base_url":"http://localhost:8080/api/v1","background_color":"#ffffff","club_id":"7eacd9f0-bfa0-4928-a9b6-936140168f58","club_logo_url":"/uploads/logos/club/7eacd9f0-bfa0-4928-a9b6-936140168f58/club-logo.svg","club_name":"Fotbalový klub Krnov","club_type":"football","club_url":"https://www.fotbal.cz/souteze/club/club/7eacd9f0-bfa0-4928-a9b6-936140168f58","contact_address":"Petrovická","contact_city":"Krnov","contact_country":"Česko","contact_email":"[email protected]","contact_phone":"+420778701838","contact_zip":"794 01","custom_nav":null,"facebook_url":"https://www.facebook.com/people/FK-Kofola-Krnov/61561103731912","font_body":"Archivo","font_heading":"Archivo","frontend_base_url":"http://localhost:3000","gallery_label":"","gallery_url":"https://eu.zonerama.com/FKKofolaKrnov/1470757","instagram_url":"https://www.instagram.com/fkkofolakrnov/","location_latitude":50.0948669,"location_longitude":17.7001456,"map_style":"voyager","map_zoom_level":15,"merch_items":null,"merch_limit":0,"merch_module_enabled":false,"merch_source":"","merch_style":"","premium":false,"primary_color":"#ffdd00","secondary_color":"#002aff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":[{"length":"","thumbnail_url":"https://img.youtube.com/vi/vklbT4csWQ0/maxresdefault.jpg","title":"Bizoni UH-Jeseník 11:3/5:2/-5.kolo 2. futsal ligy-11.11.25 v UH","uploaded_at":"2025-11-12","url":"https://www.youtube.com/watch?v=vklbT4csWQ0"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","title":"Bizoni UH-Helas Brno\\","uploaded_at":"2025-11-08","url":"https://www.youtube.com/watch?v=nGv61kag-9I"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=WKXh4Z6SYMs"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=_OsRmfYOXJ4"},{"length":"","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","uploaded_at":"2025-10-12","url":"https://www.youtube.com/watch?v=h_-TS6oVvKA"}],"videos_limit":5,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","videos_title_overrides":{},"youtube_url":"https://www.youtube.com/@FCBizoniUH"}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
[{"ID":1,"CreatedAt":"2025-11-09T12:27:21.320276Z","UpdatedAt":"2025-11-09T12:27:21.320276Z","DeletedAt":null,"name":"jskgjsglkesgj","logo_url":"/uploads/upload_1762691233_4b0c3ef4cbb3f521.png","website_url":"jlgjslkgjeslkgjslkeg.conged","description":"","is_active":true,"tier":"standard","display_order":0,"placement":"","width":0,"height":0}]
[]
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"by_id":{"0c83e0d2-dafb-48e3-9326-ce1bc44c52a8":{"logo_url":"http://logoapi.sportcreative.eu/logos/0c83e0d2-dafb-48e3-9326-ce1bc44c52a8?format=png","name":"SK Hranice"},"298adc0a-b0c9-4796-9999-5754825d0a28":{"logo_url":"https://is1.fotbal.cz/media/kluby/298adc0a-b0c9-4796-9999-5754825d0a28/298adc0a-b0c9-4796-9999-5754825d0a28_crop.jpg","name":"Školní sportovní klub Bílovec"},"35e4f595-f2a7-4c0c-abd7-73926f33d687":{"logo_url":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","name":"1.BFK Frýdlant nad Ostravicí"},"831702b0-cf90-4d94-9878-b1389b6a72b4":{"logo_url":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","name":"SK Beskyd Frenštát pod Radhoštěm"}},"by_name":{"1.BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","SK Beskyd Frenštát pod Radhoštěm":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","SK Hranice":"http://logoapi.sportcreative.eu/logos/0c83e0d2-dafb-48e3-9326-ce1bc44c52a8?format=png","Školní sportovní klub Bílovec":"https://is1.fotbal.cz/media/kluby/298adc0a-b0c9-4796-9999-5754825d0a28/298adc0a-b0c9-4796-9999-5754825d0a28_crop.jpg"}}
{"by_id":{"35e4f595-f2a7-4c0c-abd7-73926f33d687":{"logo_url":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","name":"1.BFK Frýdlant nad Ostravicí"},"831702b0-cf90-4d94-9878-b1389b6a72b4":{"logo_url":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","name":"SK Beskyd Frenštát pod Radhoštěm"},"eb9e21fd-42a0-4ff5-b253-a028343da896":{"logo_url":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png","name":"Spolek SK Brušperk"}},"by_name":{"1.BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","1.bfk frydlant nad ostravici":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","1BFK Frýdlant nad Ostravicí":"http://logoapi.sportcreative.eu/logos/35e4f595-f2a7-4c0c-abd7-73926f33d687?format=png","SK Beskyd Frenštát pod Radhoštěm":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","Spolek SK Brušperk":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png","sk beskyd frenstat pod radhostem":"http://logoapi.sportcreative.eu/logos/831702b0-cf90-4d94-9878-b1389b6a72b4?format=png","spolek sk brusperk":"http://logoapi.sportcreative.eu/logos/eb9e21fd-42a0-4ff5-b253-a028343da896?format=png"}}
+1 -1
View File
@@ -1 +1 @@
{"etag":"","fetched_at":"2025-11-10T07:08:21Z","last_modified":""}
{"etag":"","fetched_at":"2025-11-12T19:22:17Z","last_modified":""}
+1 -1
View File
@@ -1 +1 @@
{"channel":"@FCBizoniUH","channel_url":"https://www.youtube.com/@FCBizoniUH/videos","subscribers_text":"68 subscribers","subscribers":68,"videos":[{"video_id":"nGv61kag-9I","title":"Bizoni UH-Helas Brno\\","thumbnail_url":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","views_text":"182 views","views":182,"published_text":"1 day ago","published_date":"2025-11-08"},{"video_id":"WKXh4Z6SYMs","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","views_text":"110 views","views":110,"published_text":"4 weeks ago","published_date":"2025-10-12"},{"video_id":"_OsRmfYOXJ4","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","views_text":"273 views","views":273,"published_text":"4 weeks ago","published_date":"2025-10-12"},{"video_id":"h_-TS6oVvKA","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","views_text":"235 views","views":235,"published_text":"1 month ago","published_date":"2025-10-09"},{"video_id":"ozH8xE7V458","title":"Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH","thumbnail_url":"https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg","views_text":"306 views","views":306,"published_text":"1 month ago","published_date":"2025-10-09"},{"video_id":"nrj6_1IoYoo","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","views_text":"241 views","views":241,"published_text":"1 month ago","published_date":"2025-10-09"},{"video_id":"pSGwSJvdd14","title":"Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH","thumbnail_url":"https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg","views_text":"107 views","views":107,"published_text":"5 months ago","published_date":"2025-06-09"},{"video_id":"AbGKYfOmtlw","title":"FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg","views_text":"239 views","views":239,"published_text":"7 months ago","published_date":"2025-04-09"},{"video_id":"6TE21O06TZA","title":"FC Bizoni UH 11-3 Amor Kloboučky Vyškov B | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/6TE21O06TZA/maxresdefault.jpg","views_text":"123 views","views":123,"published_text":"8 months ago","published_date":"2025-03-09"},{"video_id":"LTEZcrev6xI","title":"FC Bizoni UH 10-0 FFT Vinohrady Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/LTEZcrev6xI/maxresdefault.jpg","views_text":"139 views","views":139,"published_text":"9 months ago","published_date":"2025-02-09"},{"video_id":"V91k9Dirvkg","title":"FC Bizoni UH 19-2 FC Kozlany-Bohdalice | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/V91k9Dirvkg/maxresdefault.jpg","views_text":"180 views","views":180,"published_text":"9 months ago","published_date":"2025-02-09"},{"video_id":"FWLttVdzfsQ","title":"FC Bizoni UH 13-3 TJ Agrotec Hustopeče | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/FWLttVdzfsQ/maxresdefault.jpg","views_text":"175 views","views":175,"published_text":"11 months ago","published_date":"2024-12-09"},{"video_id":"zx747-IPN4s","title":"FC Bizoni UH 11-8 Remos Oslavany | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/zx747-IPN4s/maxresdefault.jpg","views_text":"196 views","views":196,"published_text":"11 months ago","published_date":"2024-12-09"},{"video_id":"ZzsBsqJTJdo","title":"FC Bizoni UH 18-2 FC KALÁBEK SPORT Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/ZzsBsqJTJdo/maxresdefault.jpg","views_text":"309 views","views":309,"published_text":"11 months ago","published_date":"2024-12-09"},{"video_id":"m0R2d3AZy3A","title":"FC Bizoni UH 10-1 Žabinští Vlci Brno B | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/m0R2d3AZy3A/maxresdefault.jpg","views_text":"263 views","views":263,"published_text":"1 year ago","published_date":"2024-11-09"},{"video_id":"FY9aYVNpyqY","title":"FC Bizoni UH - Žabinští Vlci Brno B (celý zápas)","thumbnail_url":"https://img.youtube.com/vi/FY9aYVNpyqY/maxresdefault.jpg","views_text":"95 views","views":95,"published_text":"1 year ago","published_date":"2024-11-09"},{"video_id":"cYazpGpL7PQ","title":"FC Bizoni UH 14-4 AC Napoleon Prace | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/cYazpGpL7PQ/maxresdefault.jpg","views_text":"237 views","views":237,"published_text":"1 year ago","published_date":"2024-11-09"},{"video_id":"M2kb2QvBKPw","title":"SK UP Olomouc 2-9 FK Chrudim | HIGHLIGHTS | 1. Futsal liga 2024/25","thumbnail_url":"https://img.youtube.com/vi/M2kb2QvBKPw/maxresdefault.jpg","views_text":"62 views","views":62,"published_text":"1 year ago","published_date":"2024-11-09"},{"video_id":"CUtNBwJg9Uc","title":"SKUP Olomouc - FK CLine truncated
{"channel":"@FCBizoniUH","channel_url":"https://www.youtube.com/@FCBizoniUH/videos","subscribers_text":"74 subscribers","subscribers":74,"videos":[{"video_id":"vklbT4csWQ0","title":"Bizoni UH-Jeseník 11:3/5:2/-5.kolo 2. futsal ligy-11.11.25 v UH","thumbnail_url":"https://img.youtube.com/vi/vklbT4csWQ0/maxresdefault.jpg","views_text":"51 views","views":51,"published_text":"8 hours ago","published_date":"2025-11-12"},{"video_id":"nGv61kag-9I","title":"Bizoni UH-Helas Brno\\","thumbnail_url":"https://img.youtube.com/vi/nGv61kag-9I/maxresdefault.jpg","views_text":"234 views","views":234,"published_text":"4 days ago","published_date":"2025-11-08"},{"video_id":"WKXh4Z6SYMs","title":"Bizoni UH vs. FC ATRAPS z.s. - 2. Futsal liga - východ (celý zápas)","thumbnail_url":"https://img.youtube.com/vi/WKXh4Z6SYMs/maxresdefault.jpg","views_text":"112 views","views":112,"published_text":"1 month ago","published_date":"2025-10-12"},{"video_id":"_OsRmfYOXJ4","title":"Bizoni UH-Atraps Brno 6:5/3:4/-4.kolo 2.futs.liga Východ-UH 10.10.25","thumbnail_url":"https://img.youtube.com/vi/_OsRmfYOXJ4/maxresdefault.jpg","views_text":"275 views","views":275,"published_text":"1 month ago","published_date":"2025-10-12"},{"video_id":"h_-TS6oVvKA","title":"Bizoni UH-RT F.Místek 5:5/1:3/-2.kolo 2.liga UH 26.9.25","thumbnail_url":"https://img.youtube.com/vi/h_-TS6oVvKA/maxresdefault.jpg","views_text":"237 views","views":237,"published_text":"1 month ago","published_date":"2025-10-12"},{"video_id":"ozH8xE7V458","title":"Bizoni UH-Tango Hodonín 7:4/2:3/-regionální finále poháru SFČR-16.9.25-UH","thumbnail_url":"https://img.youtube.com/vi/ozH8xE7V458/maxresdefault.jpg","views_text":"308 views","views":308,"published_text":"1 month ago","published_date":"2025-10-12"},{"video_id":"nrj6_1IoYoo","title":"Bizoni UH-Fr.Místek 7:2/4:1/-Superpohár-12.9.25 v Uh.Hradišti","thumbnail_url":"https://img.youtube.com/vi/nrj6_1IoYoo/maxresdefault.jpg","views_text":"241 views","views":241,"published_text":"1 month ago","published_date":"2025-10-12"},{"video_id":"pSGwSJvdd14","title":"Bizoni UH vs Žabinští Vlci Brno 6:4 | Semifinále poháru SFČR UH","thumbnail_url":"https://img.youtube.com/vi/pSGwSJvdd14/maxresdefault.jpg","views_text":"109 views","views":109,"published_text":"5 months ago","published_date":"2025-06-12"},{"video_id":"AbGKYfOmtlw","title":"FC Bizoni UH 14-1 Hombres Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/AbGKYfOmtlw/maxresdefault.jpg","views_text":"240 views","views":240,"published_text":"7 months ago","published_date":"2025-04-12"},{"video_id":"6TE21O06TZA","title":"FC Bizoni UH 11-3 Amor Kloboučky Vyškov B | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/6TE21O06TZA/maxresdefault.jpg","views_text":"123 views","views":123,"published_text":"8 months ago","published_date":"2025-03-12"},{"video_id":"LTEZcrev6xI","title":"FC Bizoni UH 10-0 FFT Vinohrady Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/LTEZcrev6xI/maxresdefault.jpg","views_text":"139 views","views":139,"published_text":"9 months ago","published_date":"2025-02-12"},{"video_id":"V91k9Dirvkg","title":"FC Bizoni UH 19-2 FC Kozlany-Bohdalice | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/V91k9Dirvkg/maxresdefault.jpg","views_text":"181 views","views":181,"published_text":"9 months ago","published_date":"2025-02-12"},{"video_id":"FWLttVdzfsQ","title":"FC Bizoni UH 13-3 TJ Agrotec Hustopeče | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/FWLttVdzfsQ/maxresdefault.jpg","views_text":"175 views","views":175,"published_text":"11 months ago","published_date":"2024-12-12"},{"video_id":"zx747-IPN4s","title":"FC Bizoni UH 11-8 Remos Oslavany | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/zx747-IPN4s/maxresdefault.jpg","views_text":"197 views","views":197,"published_text":"11 months ago","published_date":"2024-12-12"},{"video_id":"ZzsBsqJTJdo","title":"FC Bizoni UH 18-2 FC KALÁBEK SPORT Brno | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/ZzsBsqJTJdo/maxresdefault.jpg","views_text":"309 views","views":309,"published_text":"11 months ago","published_date":"2024-12-12"},{"video_id":"m0R2d3AZy3A","title":"FC Bizoni UH 10-1 Žabinští Vlci Brno B | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/m0R2d3AZy3A/maxresdefault.jpg","views_text":"263 views","views":263,"published_text":"1 year ago","published_date":"2024-11-12"},{"video_id":"FY9aYVNpyqY","title":"FC Bizoni UH - Žabinští Vlci Brno B (celý zápas)","thumbnail_url":"https://img.youtube.com/vi/FY9aYVNpyqY/maxresdefault.jpg","views_text":"95 views","views":95,"published_text":"1 year ago","published_date":"2024-11-12"},{"video_id":"cYazpGpL7PQ","title":"FC Bizoni UH 14-4 AC Napoleon Prace | HIGHLIGHTS","thumbnail_url":"https://img.youtube.com/vi/cYazpGpL7PQ/maxresdefault.jpg","views_text":"239 views","views":239,"published_text":"1 year ago","published_date":"2024-11-12"},{"video_id":"M2kb2QvBKPw","title":"SK UP Olomouc 2-9 FLine truncated
+1 -1
View File
@@ -1 +1 @@
{"fetched_at":"2025-11-09T10:47:36Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
{"fetched_at":"2025-11-12T16:22:23Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
-11
View File
@@ -1,11 +0,0 @@
[
{
"id": "576619562",
"album_id": "14130762",
"album_url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14130762/576619562",
"image_url": "https://eu.zonerama.com/photos/576619562_1500x1000.jpg",
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
"picked_at": "2025-11-09T12:05:12Z"
}
]
+10 -10
View File
@@ -7,7 +7,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -17,7 +17,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -27,7 +27,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -37,7 +37,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -47,7 +47,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -57,7 +57,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -67,7 +67,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -77,7 +77,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -87,7 +87,7 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
},
{
"id": "",
@@ -97,6 +97,6 @@
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-09T10:47:48Z"
"fetched_at": "2025-11-12T16:22:38Z"
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
{
"fetched_at": "2025-11-09T10:47:48Z",
"fetched_at": "2025-11-12T16:22:38Z",
"link": ""
}
+219 -219
View File
@@ -108,7 +108,7 @@
"photos_count": 108,
"title": "Kategorie U15 Hranice 5:1 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130762",
"views_count": 63
"views_count": 83
},
{
"date": "2. 11. 2025",
@@ -213,7 +213,112 @@
"photos_count": 64,
"title": "Kategorie U14 Hranice 12:1 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14130503",
"views_count": 56
"views_count": 93
},
{
"date": "28. 10. 2025",
"id": "14102334",
"photos": [
{
"id": "575240876",
"image_1500": "https://eu.zonerama.com/photos/575240876_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240876"
},
{
"id": "575240870",
"image_1500": "https://eu.zonerama.com/photos/575240870_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240870"
},
{
"id": "575240864",
"image_1500": "https://eu.zonerama.com/photos/575240864_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240864"
},
{
"id": "575240867",
"image_1500": "https://eu.zonerama.com/photos/575240867_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240867"
},
{
"id": "575240866",
"image_1500": "https://eu.zonerama.com/photos/575240866_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240866"
},
{
"id": "575240865",
"image_1500": "https://eu.zonerama.com/photos/575240865_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240865"
},
{
"id": "575240861",
"image_1500": "https://eu.zonerama.com/photos/575240861_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240861"
},
{
"id": "575240860",
"image_1500": "https://eu.zonerama.com/photos/575240860_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240860"
},
{
"id": "575240846",
"image_1500": "https://eu.zonerama.com/photos/575240846_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240846"
},
{
"id": "575240854",
"image_1500": "https://eu.zonerama.com/photos/575240854_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240854"
},
{
"id": "575240845",
"image_1500": "https://eu.zonerama.com/photos/575240845_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240845"
},
{
"id": "575240843",
"image_1500": "https://eu.zonerama.com/photos/575240843_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240843"
},
{
"id": "575240851",
"image_1500": "https://eu.zonerama.com/photos/575240851_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240851"
},
{
"id": "575240842",
"image_1500": "https://eu.zonerama.com/photos/575240842_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240842"
},
{
"id": "575240844",
"image_1500": "https://eu.zonerama.com/photos/575240844_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240844"
},
{
"id": "575240833",
"image_1500": "https://eu.zonerama.com/photos/575240833_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240833"
},
{
"id": "575240829",
"image_1500": "https://eu.zonerama.com/photos/575240829_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240829"
},
{
"id": "575240836",
"image_1500": "https://eu.zonerama.com/photos/575240836_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240836"
},
{
"id": "575240823",
"image_1500": "https://eu.zonerama.com/photos/575240823_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240823"
}
],
"photos_count": 122,
"title": "Kategorie U15 FK Krnov 3:2 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102334",
"views_count": 104
},
{
"date": "28. 10. 2025",
@@ -318,7 +423,7 @@
"photos_count": 81,
"title": "Kategorie muži FK Krnov 1:2 Slavia Orlová",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102134",
"views_count": 116
"views_count": 126
},
{
"date": "28. 10. 2025",
@@ -453,112 +558,7 @@
"photos_count": 38,
"title": "Kategorie U14 FK Krnov 1:9 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14101976",
"views_count": 100
},
{
"date": "28. 10. 2025",
"id": "14102334",
"photos": [
{
"id": "575240876",
"image_1500": "https://eu.zonerama.com/photos/575240876_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240876"
},
{
"id": "575240870",
"image_1500": "https://eu.zonerama.com/photos/575240870_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240870"
},
{
"id": "575240864",
"image_1500": "https://eu.zonerama.com/photos/575240864_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240864"
},
{
"id": "575240867",
"image_1500": "https://eu.zonerama.com/photos/575240867_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240867"
},
{
"id": "575240866",
"image_1500": "https://eu.zonerama.com/photos/575240866_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240866"
},
{
"id": "575240865",
"image_1500": "https://eu.zonerama.com/photos/575240865_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240865"
},
{
"id": "575240861",
"image_1500": "https://eu.zonerama.com/photos/575240861_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240861"
},
{
"id": "575240860",
"image_1500": "https://eu.zonerama.com/photos/575240860_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240860"
},
{
"id": "575240846",
"image_1500": "https://eu.zonerama.com/photos/575240846_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240846"
},
{
"id": "575240854",
"image_1500": "https://eu.zonerama.com/photos/575240854_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240854"
},
{
"id": "575240845",
"image_1500": "https://eu.zonerama.com/photos/575240845_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240845"
},
{
"id": "575240843",
"image_1500": "https://eu.zonerama.com/photos/575240843_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240843"
},
{
"id": "575240851",
"image_1500": "https://eu.zonerama.com/photos/575240851_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240851"
},
{
"id": "575240842",
"image_1500": "https://eu.zonerama.com/photos/575240842_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240842"
},
{
"id": "575240844",
"image_1500": "https://eu.zonerama.com/photos/575240844_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240844"
},
{
"id": "575240833",
"image_1500": "https://eu.zonerama.com/photos/575240833_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240833"
},
{
"id": "575240829",
"image_1500": "https://eu.zonerama.com/photos/575240829_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240829"
},
{
"id": "575240836",
"image_1500": "https://eu.zonerama.com/photos/575240836_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240836"
},
{
"id": "575240823",
"image_1500": "https://eu.zonerama.com/photos/575240823_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14102334/575240823"
}
],
"photos_count": 122,
"title": "Kategorie U15 FK Krnov 3:2 Poruba - Petřvald",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14102334",
"views_count": 100
"views_count": 108
},
{
"date": "26. 10. 2025",
@@ -653,112 +653,7 @@
"photos_count": 76,
"title": "Kategorie muži FK Krnov 1:3 Frenštát p. Radhoštěm",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087623",
"views_count": 102
},
{
"date": "25. 10. 2025",
"id": "14087896",
"photos": [
{
"id": "574587784",
"image_1500": "https://eu.zonerama.com/photos/574587784_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587784"
},
{
"id": "574587775",
"image_1500": "https://eu.zonerama.com/photos/574587775_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587775"
},
{
"id": "574587774",
"image_1500": "https://eu.zonerama.com/photos/574587774_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587774"
},
{
"id": "574587771",
"image_1500": "https://eu.zonerama.com/photos/574587771_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587771"
},
{
"id": "574587772",
"image_1500": "https://eu.zonerama.com/photos/574587772_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587772"
},
{
"id": "574587761",
"image_1500": "https://eu.zonerama.com/photos/574587761_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587761"
},
{
"id": "574587756",
"image_1500": "https://eu.zonerama.com/photos/574587756_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587756"
},
{
"id": "574587737",
"image_1500": "https://eu.zonerama.com/photos/574587737_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587737"
},
{
"id": "574587732",
"image_1500": "https://eu.zonerama.com/photos/574587732_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587732"
},
{
"id": "574587740",
"image_1500": "https://eu.zonerama.com/photos/574587740_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587740"
},
{
"id": "574587742",
"image_1500": "https://eu.zonerama.com/photos/574587742_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587742"
},
{
"id": "574587731",
"image_1500": "https://eu.zonerama.com/photos/574587731_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587731"
},
{
"id": "574587730",
"image_1500": "https://eu.zonerama.com/photos/574587730_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587730"
},
{
"id": "574587733",
"image_1500": "https://eu.zonerama.com/photos/574587733_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587733"
},
{
"id": "574587717",
"image_1500": "https://eu.zonerama.com/photos/574587717_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587717"
},
{
"id": "574587718",
"image_1500": "https://eu.zonerama.com/photos/574587718_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587718"
},
{
"id": "574587719",
"image_1500": "https://eu.zonerama.com/photos/574587719_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587719"
},
{
"id": "574587700",
"image_1500": "https://eu.zonerama.com/photos/574587700_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587700"
},
{
"id": "574587701",
"image_1500": "https://eu.zonerama.com/photos/574587701_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587701"
}
],
"photos_count": 65,
"title": "Kategorie U15 FK Krnov 1:2 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087896",
"views_count": 60
"views_count": 107
},
{
"date": "25. 10. 2025",
@@ -863,7 +758,112 @@
"photos_count": 52,
"title": "Kategorie U14 FK Krnov 0:10 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087590",
"views_count": 63
"views_count": 67
},
{
"date": "25. 10. 2025",
"id": "14087896",
"photos": [
{
"id": "574587784",
"image_1500": "https://eu.zonerama.com/photos/574587784_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587784"
},
{
"id": "574587775",
"image_1500": "https://eu.zonerama.com/photos/574587775_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587775"
},
{
"id": "574587774",
"image_1500": "https://eu.zonerama.com/photos/574587774_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587774"
},
{
"id": "574587771",
"image_1500": "https://eu.zonerama.com/photos/574587771_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587771"
},
{
"id": "574587772",
"image_1500": "https://eu.zonerama.com/photos/574587772_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587772"
},
{
"id": "574587761",
"image_1500": "https://eu.zonerama.com/photos/574587761_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587761"
},
{
"id": "574587756",
"image_1500": "https://eu.zonerama.com/photos/574587756_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587756"
},
{
"id": "574587737",
"image_1500": "https://eu.zonerama.com/photos/574587737_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587737"
},
{
"id": "574587732",
"image_1500": "https://eu.zonerama.com/photos/574587732_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587732"
},
{
"id": "574587740",
"image_1500": "https://eu.zonerama.com/photos/574587740_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587740"
},
{
"id": "574587742",
"image_1500": "https://eu.zonerama.com/photos/574587742_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587742"
},
{
"id": "574587731",
"image_1500": "https://eu.zonerama.com/photos/574587731_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587731"
},
{
"id": "574587730",
"image_1500": "https://eu.zonerama.com/photos/574587730_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587730"
},
{
"id": "574587733",
"image_1500": "https://eu.zonerama.com/photos/574587733_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587733"
},
{
"id": "574587717",
"image_1500": "https://eu.zonerama.com/photos/574587717_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587717"
},
{
"id": "574587718",
"image_1500": "https://eu.zonerama.com/photos/574587718_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587718"
},
{
"id": "574587719",
"image_1500": "https://eu.zonerama.com/photos/574587719_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587719"
},
{
"id": "574587700",
"image_1500": "https://eu.zonerama.com/photos/574587700_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587700"
},
{
"id": "574587701",
"image_1500": "https://eu.zonerama.com/photos/574587701_1500x1000.jpg",
"page_url": "https://eu.zonerama.com/FKKofolaKrnov/Photo/14087896/574587701"
}
],
"photos_count": 65,
"title": "Kategorie U15 FK Krnov 1:2 Třinec",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14087896",
"views_count": 65
},
{
"date": "18. 10. 2025",
@@ -968,7 +968,7 @@
"photos_count": 75,
"title": "Kategorie U15 Uničov 3:4 FK Krnov",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14045127",
"views_count": 115
"views_count": 118
},
{
"date": "12. 10. 2025",
@@ -1073,9 +1073,9 @@
"photos_count": 112,
"title": "Kategorie muži FK Krnov 2:0 TJ Tatran Jakubčovice",
"url": "https://eu.zonerama.com/FKKofolaKrnov/Album/14014307",
"views_count": 214
"views_count": 215
}
],
"fetched_at": "2025-11-09T10:47:48Z",
"fetched_at": "2025-11-12T16:22:38Z",
"input_link": "https://eu.zonerama.com/FKKofolaKrnov/1470757"
}
+29
View File
@@ -0,0 +1,29 @@
# Project Diagrams
This folder contains Mermaid diagrams for the project:
- ER Diagram of the database schema
- System Architecture (frontend ↔ backend ↔ integrations)
- Admin Module Map (grouped by navigation categories)
- Frontpage Data Map (sections → data sources)
## Recommended extensions (VS Code)
- Markdown Preview Mermaid Support (ID: bpruitt-goddard.vscode-mermaid-preview)
- Alternative: Markdown Preview Enhanced (ID: shd101wyy.markdown-preview-enhanced)
## How to preview
1) Install one of the extensions above.
2) Open any .md file here (e.g., er-diagram.md).
3) Press Ctrl+Shift+V (or Right click → Open Preview / Open Preview to the Side).
4) If prompted to allow scripts for Mermaid, accept.
## Files
- er-diagram.md — ER diagram of DB entities and relationships
- system-architecture.md — high-level system flow
- admin-map.md — map of admin sections
- frontpage-data-map.md — frontpage sections → data sources
## Optional: Export as images
- You can install Mermaid CLI to export to PNG/SVG: `npm i -g @mermaid-js/mermaid-cli`
- For Markdown files in this folder, run: `mmdc -i er-diagram.md -o er-diagram.svg --inputType markdown`
(If you extract the mermaid code to a standalone .mmd file, you can omit `--inputType`.)
+61
View File
@@ -0,0 +1,61 @@
# Admin Module Map
```mermaid
graph LR
subgraph Zakladni
ADASH[Nastenka]
AANALYT[Analytika]
end
subgraph Sport
TEAMS[Tymy]
MATCHES[Zapasy]
PLAYERS[Hraci]
ALIASES[Alias_soutezi]
SCORE[Tabule_Scoreboard]
SCORE_R[Scoreboard_Remote]
end
subgraph Obsah
ARTICLES[Clanky]
ACTIVITIES[Aktivity]
CATEGORIES[Kategorie]
COMMENTS[Komentare]
end
subgraph Media
VIDEOS[Videa]
GALLERY[Galerie]
FILES[Soubory]
BANNERS[Bannery]
end
subgraph Komunikace
MSGS[Zpravy]
NEWSLTR[Zpravodaj]
CONTACTS[Kontakty]
end
subgraph Marketing
SPONSORS[Sponzori]
MERCH[Obleceni]
POLLS[Ankety]
SWEEP[Souteze]
ENGAGE[Odmeny_a_Uspechy]
SHORT[Zkracene_odkazy]
end
subgraph Nastroje
PREFETCH[Prefetch_a_Cache]
ERRORS[Chyby]
DOCS[Dokumentace]
end
subgraph Nastaveni
SETTINGS[Nastaveni]
USERS[Uzivatele]
NAV[Navigace]
ABOUT[O_klubu]
end
ADASH --> Sport
ADASH --> Obsah
ADASH --> Media
ADASH --> Komunikace
ADASH --> Marketing
ADASH --> Nastroje
ADASH --> Nastaveni
```
+57
View File
@@ -0,0 +1,57 @@
graph LR
subgraph Zakladni
ADASH[Nastenka]
AANALYT[Analytika]
end
subgraph Sport
TEAMS[Tymy]
MATCHES[Zapasy]
PLAYERS[Hraci]
ALIASES[Alias_soutezi]
SCORE[Tabule_Scoreboard]
SCORE_R[Scoreboard_Remote]
end
subgraph Obsah
ARTICLES[Clanky]
ACTIVITIES[Aktivity]
CATEGORIES[Kategorie]
COMMENTS[Komentare]
end
subgraph Media
VIDEOS[Videa]
GALLERY[Galerie]
FILES[Soubory]
BANNERS[Bannery]
end
subgraph Komunikace
MSGS[Zpravy]
NEWSLTR[Zpravodaj]
CONTACTS[Kontakty]
end
subgraph Marketing
SPONSORS[Sponzori]
MERCH[Obleceni]
POLLS[Ankety]
SWEEP[Souteze]
ENGAGE[Odmeny_a_Uspechy]
SHORT[Zkracene_odkazy]
end
subgraph Nastroje
PREFETCH[Prefetch_a_Cache]
ERRORS[Chyby]
DOCS[Dokumentace]
end
subgraph Nastaveni
SETTINGS[Nastaveni]
USERS[Uzivatele]
NAV[Navigace]
ABOUT[O_klubu]
end
ADASH --> Sport
ADASH --> Obsah
ADASH --> Media
ADASH --> Komunikace
ADASH --> Marketing
ADASH --> Nastroje
ADASH --> Nastaveni
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1433.27px; background-color: white;" viewBox="0 0 1433.265625 1219" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#333;color:#333;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#my-svg .flowchart-link{stroke:#333333;fill:none;}#my-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#my-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#my-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#my-svg .icon-shape rect,#my-svg .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-cLine truncated

After

Width:  |  Height:  |  Size: 33 KiB

+37
View File
@@ -0,0 +1,37 @@
graph LR
subgraph Backend
Router[API Router /api/v1]
Middleware[Middleware JWT RateLimit CORS Gzip Recovery]
Controllers[Controllers]
Services[Services]
Models[Models GORM]
DB[PostgreSQL]
Migrations[Migrations]
Jobs[Background jobs Prefetcher Newsletter]
Uploads[uploads static dist]
end
subgraph Integrations
FACR[FACR API]
YT[YouTube API]
ZON[Zonerama]
SMTP[SMTP Email]
MAPS[Google Maps]
UMAMI[Umami Analytics]
end
Router --> Middleware
Router --> Controllers
Controllers --> Services
Services --> Models
Models --> DB
Migrations --> DB
Jobs --> Services
Jobs --> DB
Controllers --> Uploads
Controllers --> FACR
Controllers --> YT
Controllers --> ZON
Controllers --> SMTP
Controllers --> MAPS
Controllers -. telemetry .-> UMAMI
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1331.78px; background-color: white;" viewBox="0 0 1331.78125 1149" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#333;color:#333;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#my-svg .flowchart-link{stroke:#333333;fill:none;}#my-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#my-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#my-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#my-svg .icon-shape rect,#my-svg .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crLine truncated

After

Width:  |  Height:  |  Size: 29 KiB

+79
View File
@@ -0,0 +1,79 @@
# ER Diagram
```mermaid
erDiagram
USERS ||--o{ ARTICLES : author_id
CATEGORIES ||--o{ ARTICLES : category_id
ARTICLES ||--o{ ARTICLE_TEAM_LINKS : article_id
ARTICLES ||--o{ ARTICLE_MATCH_LINKS : article_id
TEAMS ||--o{ PLAYERS : team_id
USERS ||--o{ EVENTS : created_by_id
EVENTS ||--o{ EVENT_ATTACHMENTS : event_id
POLLS ||--o{ POLL_OPTIONS : poll_id
POLLS ||--o{ POLL_VOTES : poll_id
POLL_OPTIONS ||--o{ POLL_VOTES : option_id
USERS o{--o| POLL_VOTES : user_id
CATEGORIES o{--o| POLLS : category_id
ARTICLES o{--o| POLLS : related_article_id
EVENTS o{--o| POLLS : related_event_id
PLAYERS o{--o| POLL_OPTIONS : player_id
USERS ||--|| USER_PROFILES : user_id
USERS ||--o{ PASSWORD_RESETS : user_id
USERS ||--o{ COMMENTS : user_id
COMMENTS o{--o| COMMENTS : parent_id
USERS ||--o{ COMMENT_BANS : user_id
USERS ||--o{ UNBAN_REQUESTS : user_id
COMMENTS ||--o{ COMMENT_REACTIONS : comment_id
COMMENTS ||--o{ COMMENT_REPORTS : comment_id
USERS o{--o| UPLOADED_FILES : uploaded_by_id
UPLOADED_FILES ||--o{ FILE_USAGES : file_id
CONTACT_CATEGORIES o{--o| CONTACTS : category_id
SHORT_LINKS o{--o| LINK_CLICKS : short_link_id
USERS o{--o| SHORT_LINKS : created_by_id
EMAIL_LOGS ||--o{ EMAIL_EVENTS : email_log_id
ARTICLES ||--o{ BLOG_NOTIFICATIONS : article_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_PRIZES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_ENTRIES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_WINNERS : sweepstake_id
USERS ||--o{ SWEEPSTAKE_ENTRIES : user_id
USERS ||--o{ SWEEPSTAKE_WINNERS : user_id
SWEEPSTAKE_ENTRIES ||--o{ SWEEPSTAKE_WINNERS : entry_id
SWEEPSTAKE_PRIZES |o--o{ SWEEPSTAKE_WINNERS : prize_id
USERS ||--o{ POINTS_TRANSACTIONS : user_id
USERS ||--o{ USER_ACHIEVEMENTS : user_id
ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : achievement_id
REWARD_ITEMS ||--o{ REWARD_REDEMPTIONS : reward_id
USERS ||--o{ REWARD_REDEMPTIONS : user_id
USERS o{--o| AUDIT_LOGS : user_id
USERS o{--o| ERROR_EVENTS : user_id
USERS o{--o| VISITOR_EVENTS : user_id
SETUP_INFO ||--o{ CLUB_INFO : setup_info_id
NAVIGATION_ITEMS o{--o| NAVIGATION_ITEMS : parent_id
%% Standalone/core tables (configured/consumed by services)
SETTINGS
ABOUT_PAGES
SPONSORS
BANNERS
CLOTHING
COMPETITION_ALIASES
MATCH_OVERRIDES
TEAM_LOGO_OVERRIDES
NEWSLETTER_SUBSCRIPTIONS
NEWSLETTER_SENT_LOG
MATCH_NOTIFICATIONS
SCOREBOARD_STATES
```
+75
View File
@@ -0,0 +1,75 @@
erDiagram
USERS ||--o{ ARTICLES : author_id
CATEGORIES ||--o{ ARTICLES : category_id
ARTICLES ||--o{ ARTICLE_TEAM_LINKS : article_id
ARTICLES ||--o{ ARTICLE_MATCH_LINKS : article_id
TEAMS ||--o{ PLAYERS : team_id
USERS ||--o{ EVENTS : created_by_id
EVENTS ||--o{ EVENT_ATTACHMENTS : event_id
POLLS ||--o{ POLL_OPTIONS : poll_id
POLLS ||--o{ POLL_VOTES : poll_id
POLL_OPTIONS ||--o{ POLL_VOTES : option_id
USERS |o--o{ POLL_VOTES : user_id
CATEGORIES |o--o{ POLLS : category_id
ARTICLES |o--o{ POLLS : related_article_id
EVENTS |o--o{ POLLS : related_event_id
PLAYERS |o--o{ POLL_OPTIONS : player_id
USERS ||--|| USER_PROFILES : user_id
USERS ||--o{ PASSWORD_RESETS : user_id
USERS ||--o{ COMMENTS : user_id
COMMENTS |o--o{ COMMENTS : parent_id
USERS ||--o{ COMMENT_BANS : user_id
USERS ||--o{ UNBAN_REQUESTS : user_id
COMMENTS ||--o{ COMMENT_REACTIONS : comment_id
COMMENTS ||--o{ COMMENT_REPORTS : comment_id
USERS |o--o{ UPLOADED_FILES : uploaded_by_id
UPLOADED_FILES ||--o{ FILE_USAGES : file_id
CONTACT_CATEGORIES |o--o{ CONTACTS : category_id
SHORT_LINKS |o--o{ LINK_CLICKS : short_link_id
USERS |o--o{ SHORT_LINKS : created_by_id
EMAIL_LOGS ||--o{ EMAIL_EVENTS : email_log_id
ARTICLES ||--o{ BLOG_NOTIFICATIONS : article_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_PRIZES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_ENTRIES : sweepstake_id
SWEEPSTAKES ||--o{ SWEEPSTAKE_WINNERS : sweepstake_id
USERS ||--o{ SWEEPSTAKE_ENTRIES : user_id
USERS ||--o{ SWEEPSTAKE_WINNERS : user_id
SWEEPSTAKE_ENTRIES ||--o{ SWEEPSTAKE_WINNERS : entry_id
SWEEPSTAKE_PRIZES |o--o{ SWEEPSTAKE_WINNERS : prize_id
USERS ||--o{ POINTS_TRANSACTIONS : user_id
USERS ||--o{ USER_ACHIEVEMENTS : user_id
ACHIEVEMENTS ||--o{ USER_ACHIEVEMENTS : achievement_id
REWARD_ITEMS ||--o{ REWARD_REDEMPTIONS : reward_id
USERS ||--o{ REWARD_REDEMPTIONS : user_id
USERS |o--o{ AUDIT_LOGS : user_id
USERS |o--o{ ERROR_EVENTS : user_id
USERS |o--o{ VISITOR_EVENTS : user_id
SETUP_INFO ||--o{ CLUB_INFO : setup_info_id
NAVIGATION_ITEMS |o--o{ NAVIGATION_ITEMS : parent_id
%% Standalone/core tables (configured/consumed by services)
SETTINGS
ABOUT_PAGES
SPONSORS
BANNERS
CLOTHING
COMPETITION_ALIASES
MATCH_OVERRIDES
TEAM_LOGO_OVERRIDES
NEWSLETTER_SUBSCRIPTIONS
NEWSLETTER_SENT_LOG
MATCH_NOTIFICATIONS
SCOREBOARD_STATES
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="erDiagram" style="max-width: 12625.8px; background-color: white;" viewBox="0 0 12625.8193359375 840" role="graphics-document document" aria-roledescription="er"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .entityBox{fill:#ECECFF;stroke:#9370DB;}#my-svg .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#my-svg .relationshipLabelBox rect{opacity:0.5;}#my-svg .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#my-svg .edgeLabel .label{fill:#9370DB;font-size:14px;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .edge-pattern-dashed{stroke-dasharray:8,8;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#my-svg .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><defs><marker id="my-svg_er-onlyOneStart" class="marker onlyOne er" refX="0" refY="9" markerWidth="18" markerHeight="18" orient="auto"><path d="M9,0 L9,18 M15,0 L15,18"/></marker></defs><defs><marker id="my-svg_er-onlyOneEnd" class="marker onlyOne er" refX="18" refY="9" markerWidth="18" markerHeight="18" orient="auto"><path d="M3,0 L3,18 M9,0 L9,18"/></marker></defs><defs><marker id="my-svg_er-zeroOrOneStart" class="marker zeroOrOne er" refX="0" refY="9" markerWidth="30" markerHeight="18" orient="auto"><circle fill="white" cx="21" cy="9" r="6"/><path d="M9,0 L9,18"/></marker></defs><defs><marker id="my-svg_er-zeroOrOneEnd" class="marker zeroOrOne er" refX="30" refY="9" markerWidth="30" markerHeight="18" orient="auto"><circle fill="white" cx="9" cy="9" r="6"/><path d="M21,0 L21,18"/></marker></defs><defs><marker id="my-svg_er-oneOrMoreStart" class="marker oneOrMore er" refX="18" refY="18" markerWidth="45" markerHeight="36" orient="auto"><path d="M0,18 Q 18,0 36,18 Q 18,36 0,18 M42,9 L42,27"/></marker></defs><defs><marker id="my-svg_er-oneOrMoreEnd" class="marker oneOrMore er" refX="27" refY="18" markerWidth="45" markerHeight="36" orient="auto"><path d="M3,9 L3,27 M9,18 Q27,0 45,18 Q27,36 9,18"/></marker></defs><defs><marker id="my-svg_er-zeroOrMoreStart" class="marker zeroOrMore er" refX="18" refY="18" markerWidth="57" markerHeight="36" orient="auto"><circle fill="white" cx="48" cy="18" r="6"/><path d="M0,18 Q18,0 36,18 Q18,36 0,18"/></marker></defs><defs><marker id="my-svg_er-zeroOrMoreEnd" class="marker zeroOrMore er" refX="39" refY="18" markerWidth="57" markerHeight="36" orient="auto"><circle fill="white" cx="9" cy="18" r="6"/><path d="M21,18 Q39,0 57,18 Q39,36 21,18"/></marker></defs><g class="root"><g class="clusters"/><g class="edgePaths"><path d="M4384.499,51.626L3918.818,66.772C3453.138,81.917,2521.776,112.209,2009.595,140.081C1497.414,167.953,1404.414,193.406,1357.914,206.132L1311.414,218.859" id="id_entity-USERS-0_entity-ARTICLES-1_0" class="edge-thickness-normal edge-pattern-solid relationshipLine" style="undefined;;;undefined" data-edge="true" data-et="edge" data-id="id_entity-USERS-0_entity-ARTICLES-1_0" data-points="W3sieCI6NDM4NC40OTkyMTg3NTA3NDUsInkiOjUxLjYyNjE4MTk2OTIxMjU0Nn0seyJ4IjoxNTkwLjQxNDA2MjUsInkiOjE0Mi41fSx7IngiOjEzMTEuNDE0MDYyNSwieSI6MjE4Ljg1ODg0NTE0OTIxMDZ9XQ==" marker-start="url(#my-svg_er-onlyOneStart)" marker-end="url(#my-svg_er-zeroOrMoreEnd)"/><path d="M67.102,92L64.545,100.417C61.988,108.833,56.875,125.667,244.602,148.743C432.328,171.819,812.895,201.138,1003.178,215.797L1193.461,230.456" id="id_entity-CATEGORIES-2_entity-ARTICLES-1_1" class="edge-thickness-normal edge-pattern-solid relationshipLine" style="undefined;;;undefined" data-edge="true" data-et="edge" data-id="id_entity-CATEGORIES-2_entity-ARTICLES-1_1" data-points="W3sieCI6NjcuMTAxNTIwMjcLine truncated

After

Width:  |  Height:  |  Size: 91 KiB

+72
View File
@@ -0,0 +1,72 @@
# Frontpage Data Map
```mermaid
graph TB
Home[Homepage sections]
News[News (Articles)]
Matches[Upcoming & recent matches]
TableSec[Standings / Tables]
Activities[Activities (Events)]
GallerySec[Gallery]
VideosSec[Videos]
PlayersSec[Players]
SponsorsSec[Sponsors]
MerchSec[Merch]
PollsSec[Polls]
MapSec[Club Map]
NewsletterSec[Newsletter box]
BannersSec[Banners]
Home --> News
Home --> Matches
Home --> TableSec
Home --> Activities
Home --> GallerySec
Home --> VideosSec
Home --> PlayersSec
Home --> SponsorsSec
Home --> MerchSec
Home --> PollsSec
Home --> MapSec
Home --> NewsletterSec
Home --> BannersSec
ARTICLES["Articles"]
FACR_API["FACR API"]
MATCH_OVERRIDES["Match overrides"]
COMPETITION_ALIASES["Competition aliases"]
EVENTS["Events"]
SETTINGS["Settings"]
ZONERAMA["Zonerama"]
YOUTUBE["YouTube"]
TEAMS["Teams"]
DB_PLAYERS["Players"]
SPONSORS["Sponsors"]
CLOTHING["Clothing"]
POLLS["Polls"]
POLL_OPTIONS["Poll options"]
GOOGLE_MAPS["Google Maps"]
NEWSLETTER_SUBSCRIPTIONS["Newsletter subscriptions"]
BANNERS["Banners"]
News -->|DB| ARTICLES
Matches -->|Source| FACR_API
Matches -->|Overrides| MATCH_OVERRIDES
TableSec -->|Source| FACR_API
TableSec -->|Aliases| COMPETITION_ALIASES
Activities -->|DB| EVENTS
GallerySec -->|Profile URL| SETTINGS
GallerySec --> ZONERAMA
VideosSec -->|Config| SETTINGS
VideosSec --> YOUTUBE
PlayersSec --> TEAMS
PlayersSec --> DB_PLAYERS
SponsorsSec -->|DB| SPONSORS
MerchSec -->|DB| CLOTHING
PollsSec -->|DB| POLLS
PollsSec --> POLL_OPTIONS
MapSec -->|lat/lng + style| SETTINGS
MapSec --> GOOGLE_MAPS
NewsletterSec -->|subscribe| NEWSLETTER_SUBSCRIPTIONS
BannersSec -->|DB| BANNERS
```
+68
View File
@@ -0,0 +1,68 @@
graph TB
Home[Homepage sections]
News[News Articles]
Matches[Upcoming and recent matches]
TableSec[Standings Tables]
Activities[Activities Events]
GallerySec[Gallery]
VideosSec[Videos]
PlayersSec[Players]
SponsorsSec[Sponsors]
MerchSec[Merch]
PollsSec[Polls]
MapSec[Club Map]
NewsletterSec[Newsletter]
BannersSec[Banners]
Home --> News
Home --> Matches
Home --> TableSec
Home --> Activities
Home --> GallerySec
Home --> VideosSec
Home --> PlayersSec
Home --> SponsorsSec
Home --> MerchSec
Home --> PollsSec
Home --> MapSec
Home --> NewsletterSec
Home --> BannersSec
ARTICLES[Articles]
FACR_API[FACR API]
MATCH_OVERRIDES[Match overrides]
COMPETITION_ALIASES[Competition aliases]
EVENTS[Events]
SETTINGS[Settings]
ZONERAMA[Zonerama]
YOUTUBE[YouTube]
TEAMS[Teams]
DB_PLAYERS[Players]
SPONSORS[Sponsors]
CLOTHING[Clothing]
POLLS[Polls]
POLL_OPTIONS[Poll options]
GOOGLE_MAPS[Google Maps]
NEWSLETTER_SUBSCRIPTIONS[Newsletter subscriptions]
BANNERS[Banners]
News -->|DB| ARTICLES
Matches -->|Source| FACR_API
Matches -->|Overrides| MATCH_OVERRIDES
TableSec -->|Source| FACR_API
TableSec -->|Aliases| COMPETITION_ALIASES
Activities -->|DB| EVENTS
GallerySec -->|Profile URL| SETTINGS
GallerySec --> ZONERAMA
VideosSec -->|Config| SETTINGS
VideosSec --> YOUTUBE
PlayersSec --> TEAMS
PlayersSec --> DB_PLAYERS
SponsorsSec -->|DB| SPONSORS
MerchSec -->|DB| CLOTHING
PollsSec -->|DB| POLLS
PollsSec --> POLL_OPTIONS
MapSec -->|lat lng and style| SETTINGS
MapSec --> GOOGLE_MAPS
NewsletterSec -->|subscribe| NEWSLETTER_SUBSCRIPTIONS
BannersSec -->|DB| BANNERS
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 3715.11px; background-color: white;" viewBox="0 0 3715.11328125 326" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#333;color:#333;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#my-svg .flowchart-link{stroke:#333333;fill:none;}#my-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#my-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#my-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#my-svg .icon-shape rect,#my-svg .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-Line truncated

After

Width:  |  Height:  |  Size: 52 KiB

+3
View File
@@ -0,0 +1,3 @@
{
"args": ["--no-sandbox", "--disable-setuid-sandbox"]
}
+56
View File
@@ -0,0 +1,56 @@
# System Architecture
```mermaid
graph LR
subgraph Clients
A["Public site React SPA"]
B["Admin SPA"]
C["Scoreboard Overlay"]
end
subgraph Frontend
FE["React 18 + Chakra UI; Router + Query"]
end
subgraph Backend
BE["Go Gin REST API api v1; GORM services"]
JOBS["Background jobs; Prefetcher; Newsletter automation"]
end
subgraph Data
DB["PostgreSQL"]
UP["uploads static dist"]
end
subgraph Integrations_optional
FACR["FACR API"]
YT["YouTube API"]
ZON["Zonerama"]
SMTP["SMTP email"]
MAPS["Google Maps"]
UMAMI["Umami Analytics"]
end
A --> FE
B --> FE
C --> FE
FE -->|REST JSON| BE
FE -->|uploads static| UP
BE --> DB
BE --> UP
%% External calls
BE --> FACR
BE --> YT
BE --> ZON
BE --> SMTP
BE -. "telemetry" .-> UMAMI
BE --> MAPS
%% Jobs
JOBS --> BE
JOBS --> DB
JOBS --> SMTP
```
+52
View File
@@ -0,0 +1,52 @@
graph LR
subgraph Clients
A["Public site React SPA"]
B["Admin SPA"]
C["Scoreboard Overlay"]
end
subgraph Frontend
FE["React 18 + Chakra UI; Router + Query"]
end
subgraph Backend
BE["Go Gin REST API api v1; GORM services"]
JOBS["Background jobs; Prefetcher; Newsletter automation"]
end
subgraph Data
DB["PostgreSQL"]
UP["uploads static dist"]
end
subgraph Integrations_optional
FACR["FACR API"]
YT["YouTube API"]
ZON["Zonerama"]
SMTP["SMTP email"]
MAPS["Google Maps"]
UMAMI["Umami Analytics"]
end
A --> FE
B --> FE
C --> FE
FE -->|REST JSON| BE
FE -->|uploads static| UP
BE --> DB
BE --> UP
%% External calls
BE --> FACR
BE --> YT
BE --> ZON
BE --> SMTP
BE -. "telemetry" .-> UMAMI
BE --> MAPS
%% Jobs
JOBS --> BE
JOBS --> DB
JOBS --> SMTP
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+1
View File
@@ -0,0 +1 @@
<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1417.92px; background-color: white;" viewBox="0 0 1417.921875 1152" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#333;color:#333;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#my-svg .flowchart-link{stroke:#333333;fill:none;}#my-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#my-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#my-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#my-svg .icon-shape rect,#my-svg .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-cLine truncated

After

Width:  |  Height:  |  Size: 32 KiB

+300
View File
@@ -0,0 +1,300 @@
# System Issues Tracker
## Admin Dashboard
- [ ] **Nástěnka**
- Status: Fully working
- Priority: Low
- Notes: No issues detected
- [ ] **Analytika**
- Status: Fully working
- Priority: Low
- Notes: No issues detected
## Týmy
- [x] **Logo Overwrite Issue**
- Status: Fixed
- Priority: High
- Affected Teams:
- Frýdlant n. O.
- SK OLOMOUC SIGMA MŽ, z.s.
- Working Examples:
- Tělovýchovná jednota Sokol Kozmice, z.s.
- Error: No error, just doesn't update
- Environment: Admin interface
## Zápasy
- [x] **Match Editing**
- Status: Fixed
- Priority: High
- Issues:
- Cannot edit match places
- Cannot edit match dates
- Overwrites don't work
- Environment: Admin interface
## Hráči
- [x] **Age Display**
- Status: Fixed
- Priority: Medium
- Current: "33 roky"
- Should be: "33 let"
- Location: Player profile pages
- [x] **Active Player Filter**
- Status: Fixed
- Priority: High
- Issue: Inactive players still visible when "Pouze aktivní" is toggled
- URL: http://localhost:3000/players
- Expected: Should hide inactive players
## Alias soutěží
- [x] **Frontpage Order**
- Status: Fixed
- Priority: Medium
- Issue: Order changes not reflected on homepage
- Works on: Other pages
## Tabule / Scoreboard
- [ ] **Styling Issues**
- Status: Needs update
- Priority: Medium
- Issues:
- Different from myscore board
- Missing sponsor overlay style
## Články
- [x] **File Upload**
- Status: Fixed
- Priority: High
- Error:
```
Chyba při nahrávání Soubor "example.pptx":
Request failed with status code 400
```
- File Size: ~11MB
- Expected: Should handle files up to at least 20MB
## Frontpage - Blog Detail Page
### Layout Structure Issues
- [x] **Two-Column Layout**
- Left Column (wider):
- Main content
- Images
- Gallery
- Comments
- Right Column (narrower):
- Upcoming matches (5 max)
- Polls
- Additional files
### Missing Components
- [x] **Match Section**
- Location: Under main blog picture
- Should include:
- Club logos on each side
- Score or countdown in middle
- Place and date below
- Modal on click
- Club colors for each side
- [x] **Breadcrumbs**
- Location: Below reading time and publish date
- Missing completely
- [x] **Gallery Section**
- Should show:
- Mosaic of 5 pictures
- 2 smaller pictures on each side
- 1 larger picture in middle
- "Zobrazit celou galerii" button
- Connected to Zonerama album
- [x] **Uploaded Files**
- Location: Above comments section
- Currently missing
- [x] **Poll Section**
- Location: Above files section
- Currently missing
- Should show connected poll
## Blog List Page (/blog)
- [x] **Grid Layout**
- Status: Fixed
- Priority: Medium
- Issues:
- Inconsistent spacing
- Poor responsiveness
- [x] **Category Switcher**
- Status: Fixed
- Priority: High
- Expected: Should filter articles by category
- [x] **Grid Item Layout**
- Missing publish date
- Current elements:
- Left: Category
- Right: Read time
- Should add:
- Middle: Publish date
- Hover tooltips for better UX
## Rich Text Editor
- [ ] **Loading Issues**
- Status: Inconsistent
- Success Rate: ~50%
- Affected Pages:
- Articles
- Activities
- [x] **Toolbar Tools**
- Issue: Separate "Zrušit barvu" and "Zrušit pozadí" buttons
- Should be: Integrated into color picker
- [ ] **Console Errors**
- Error: `addRange(): The given range isn't in document`
- Issue: Toolbar disappears after changes
## Galerie
- [x] **Zonerama Sync**
- Status: Fixed
- Error:
```
Access to XMLHttpRequest at 'http://localhost:8080/api/v1/admin/gallery/refresh'
from origin 'http://localhost:3000' has been blocked by CORS policy
```
- Additional Error:
```
POST http://localhost:8080/api/v1/admin/gallery/refresh
415 (Unsupported Media Type)
```
- Expected: Should sync with Zonerama
## Soubory
- [x] **UI Improvements**
- Status: Fixed
- Verified:
- Success messages are in Czech
- "Vymazat vše" buttons present in "Nepoužívané" and "Duplicity" tabs
## Zpravodaj
- [x] **Auto-Enable Feature**
- Status: Fixed
- Expected: Should enable when ≥1 recipient
- Current: Shows "Vypnuto" even with 1 recipient
- [x] **Delivery Status**
- Status: Fixed
- Should show:
- What will be sent
- Exact send time
- More delivery details
- [ ] **Delivery Issues**
- Example:
- Time: 11. 11. 2025 11:53
- Subject: Vítejte v odběru
- Recipient: [email protected]
- Status: failed
## Kontakty
- [ ] **Missing Category**
- Status: Competition alias category missing
- Location: Category list
- [ ] **Missing Photos**
- Status: Not showing on frontpage
- Note: Visible in admin
## Bannery
- [ ] **Positioning**
- Status: Incorrect
- Issues:
- Sidebar floating
- "Banner v článcích" not visible
## MyUIbrix
- [ ] **Aktuality Section**
- Status: Showing wrong content
- Current: Shows all blogs
- Should: Show only non-primary blogs (primary should be in hero)
## Ankety
- [ ] **User Tracking**
- Status: Not working
- Issues:
- Logged-in admin shown as non-logged visitor
- Voting tracking incorrect
## Soutěže
- [ ] **Image Upload**
- Status: Not working
- Element: "Titulní obrázek"
- [ ] **Registration**
- Status: Not working
- Issue: No action on "Vstoupit" button
- [ ] **UI/UX**
- Status: Needs improvement
- Suggestions:
- Add tab system for managing winnings
- Improve overall layout
## Odměny a úspěchy
- [ ] **UI/UX**
- Status: Needs improvement
- Issues:
- Overly complex
- Needs date pickers for "Platnost od/do"
- Suggested Changes:
- Remove "Dávkové vytvoření"
- Simplify interface
## Zkrácené odkazy
- [ ] **Link Generation**
- Status: Predictable pattern
- Current: Always uses "ig-share" as first link
- Should: Use random strings
- Example: `http://localhost:8080/s/randomstring123`
## Uživatelé
- [ ] **Role Permissions**
- Status: Too restrictive
- Issue: 403 errors in admin interface
- Needed: Define proper permission levels
## Navigace
- [ ] **Hidden Elements**
- Status: Loses data on refresh
- Issues:
- Shows "#" instead of element name
- Cannot restore hidden elements
- [ ] **Drag and Drop**
- Status: Limited functionality
- Issue: Can't drag subcategories between main categories
- Current: Only works within same category
## Working Correctly
- Aktivity
- Kategorie (marked for removal)
- Videa
- Oblečení
- Prefetch/Fetch
- Nastavení
- Odhlášení
## Testing Notes
- Test Environment: Local development
- Frontend: http://localhost:3000
- Backend: http://localhost:8080
- Browser: [Specify if known]
## Priority Classification
- **High**: Critical functionality issues
- **Medium**: Important but not blocking
- **Low**: Cosmetic or minor issues
+9 -3
View File
@@ -86,7 +86,6 @@ const PrivacyPolicyPage = lazy(() => import('./pages/legal/PrivacyPolicyPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const ArticlesAdminPage = lazy(() => import('./pages/admin/ArticlesAdminPage'));
const SponsorsAdminPage = lazy(() => import('./pages/admin/SponsorsAdminPage'));
const CategoriesAdminPage = lazy(() => import('./pages/admin/CategoriesAdminPage'));
const MatchesAdminPage = lazy(() => import('./pages/admin/MatchesAdminPage'));
const PlayersAdminPage = lazy(() => import('./pages/admin/PlayersAdminPage'));
const TeamsAdminPage = lazy(() => import('./pages/admin/TeamsAdminPage'));
@@ -132,7 +131,7 @@ const FontLoader: React.FC = () => {
// Public route wrapper
const PublicRoute = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useAuth();
const { isAuthenticated, isLoading, user } = useAuth();
const [checkingSetup, setCheckingSetup] = useState(true);
const [requiresSetup, setRequiresSetup] = useState<boolean>(false);
@@ -156,8 +155,15 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => {
}
if (isAuthenticated) {
const role = (user as any)?.role;
if (role === 'admin') {
return <Navigate to="/admin" replace />;
}
if (role === 'editor') {
return <Navigate to="/admin/clanky" replace />;
}
return <Navigate to="/" replace />;
}
const currentPath = window.location.pathname;
if (requiresSetup && currentPath !== '/setup') {
@@ -261,6 +267,7 @@ const AppLazy: React.FC = () => {
<Route element={<ProtectedRoute requiredRole="editor"><AdminRoutesWrapper /></ProtectedRoute>}>
<Route path="/admin/clanky" element={<ArticlesAdminPage />} />
<Route path="/admin/aktivity" element={<AdminActivitiesPage />} />
<Route path="/admin/shortlinks" element={<ShortlinksAdminPage />} />
</Route>
{/* Admin routes */}
@@ -272,7 +279,6 @@ const AppLazy: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
<Route path="/admin/tymy" element={<TeamsAdminPage />} />
-2
View File
@@ -32,7 +32,6 @@ import ActivitiesCalendarPage from './pages/ActivitiesCalendarPage';
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
import ArticlesAdminPage from './pages/admin/ArticlesAdminPage';
import SponsorsAdminPage from './pages/admin/SponsorsAdminPage';
import CategoriesAdminPage from './pages/admin/CategoriesAdminPage';
import MatchesAdminPage from './pages/admin/MatchesAdminPage';
import PlayersAdminPage from './pages/admin/PlayersAdminPage';
import TeamsAdminPage from './pages/admin/TeamsAdminPage';
@@ -493,7 +492,6 @@ const App: React.FC = () => {
<Route path="/admin/galerie" element={<GalleryAdminPage />} />
<Route path="/admin/obleceni" element={<AdminMerchPage />} />
<Route path="/admin/sponzori" element={<SponsorsAdminPage />} />
<Route path="/admin/kategorie" element={<CategoriesAdminPage />} />
{/* moved to editor-accessible routes below */}
<Route path="/admin/zapasy" element={<MatchesAdminPage />} />
<Route path="/admin/hraci" element={<PlayersAdminPage />} />
@@ -37,7 +37,6 @@ const adminIndex: AdminSearchItem[] = [
{ label: 'Soubory', path: '/admin/soubory', section: 'Média', keywords: ['files', 'uploads'], icon: FaFolderOpen },
{ label: 'Sponzoři', path: '/admin/sponzori', section: 'Marketing', keywords: ['sponsors', 'partners'], icon: FaHandshake },
{ label: 'Bannery', path: '/admin/bannery', section: 'Marketing', keywords: ['banners'], icon: FaImage },
{ label: 'Kategorie', path: '/admin/kategorie', section: 'Obsah', keywords: ['categories'], icon: FaAward },
{ label: 'Nastavení', path: '/admin/nastaveni', section: 'Systém', keywords: ['settings', 'config'], icon: FaCog },
{ label: 'Newsletter', path: '/admin/newsletter', section: 'Komunikace', keywords: ['email', 'campaign'], icon: FaEnvelope },
{ label: 'Uživatelé', path: '/admin/uzivatele', section: 'Systém', keywords: ['users', 'accounts'], icon: FaKey },
+33 -16
View File
@@ -263,9 +263,14 @@ const AdminSidebar = ({
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
// Load dynamic navigation from API (admins only)
useEffect(() => {
let active = true;
// Editors should not call admin-only navigation endpoint; use fallback
if (!isAdmin) {
setNavLoading(false);
return () => { active = false };
}
(async () => {
try {
const items = await getAllNavigationItems();
@@ -470,8 +475,8 @@ const AdminSidebar = ({
);
})}
{/* Ensure Shortlinks is present even if not configured in dynamic nav */}
{!hasShortlinks && (
{/* Ensure Shortlinks is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasShortlinks && (
<NavItem
icon={FaLink}
to="/admin/shortlinks"
@@ -481,8 +486,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Engagement page is present even if not configured in dynamic nav */}
{!hasEngagement && (
{/* Ensure Engagement page is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasEngagement && (
<NavItem
icon={FaAward}
to="/admin/engagement"
@@ -492,8 +497,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Comments moderation is present even if not configured in dynamic nav */}
{!hasComments && (
{/* Ensure Comments moderation is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasComments && (
<NavItem
icon={FaComments}
to="/admin/komentare"
@@ -502,8 +507,8 @@ const AdminSidebar = ({
Komentáře
</NavItem>
)}
{/* Ensure Sweepstakes is present even if not configured in dynamic nav */}
{!hasSweepstakes && (
{/* Ensure Sweepstakes is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasSweepstakes && (
<NavItem
icon={FaGift}
to="/admin/sweepstakes"
@@ -512,8 +517,8 @@ const AdminSidebar = ({
Soutěže
</NavItem>
)}
{/* Ensure Competition Aliases is present even if not configured in dynamic nav */}
{!hasCompetitionAliases && (
{/* Ensure Competition Aliases is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasCompetitionAliases && (
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
@@ -523,8 +528,8 @@ const AdminSidebar = ({
</NavItem>
)}
{/* Ensure Clothing is present even if not configured in dynamic nav */}
{!hasClothing && (
{/* Ensure Clothing is present even if not configured in dynamic nav (admins only) */}
{isAdmin && !hasClothing && (
<NavItem
icon={FaTshirt}
to="/admin/obleceni"
@@ -541,6 +546,7 @@ const AdminSidebar = ({
Hlavní
</Text>
{isAdmin && (
<NavItem
icon={FaTachometerAlt}
to="/admin"
@@ -548,6 +554,7 @@ const AdminSidebar = ({
>
Nástěnka
</NavItem>
)}
{isAdmin && (
<NavItem
@@ -563,6 +570,8 @@ const AdminSidebar = ({
Obsah
</Text>
{/* Core sports entities first */}
{isAdmin && (
<>
<NavItem
icon={FaUsers}
to="/admin/tymy"
@@ -583,6 +592,8 @@ const AdminSidebar = ({
</Text>
</Text>
</NavItem>
</>
)}
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
@@ -597,6 +608,7 @@ const AdminSidebar = ({
)}
</Text>
</NavItem>
{isAdmin && (
<NavItem
icon={FaFutbol}
to="/admin/hraci"
@@ -604,6 +616,7 @@ const AdminSidebar = ({
>
Hráči
</NavItem>
)}
{/* Other content */}
<NavItem
icon={FaNewspaper}
@@ -613,12 +626,14 @@ const AdminSidebar = ({
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
icon={FaLink}
to="/admin/shortlinks"
onClick={onClose}
>
Kategorie
Zkrácené odkazy
</NavItem>
{isAdmin && (
<>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
@@ -717,6 +732,8 @@ const AdminSidebar = ({
>
Odměny & Úspěchy
</NavItem>
</>
)}
<Divider my={2} />
{isAdmin && (
@@ -86,24 +86,21 @@ const InstagramGeneratorButton: React.FC<Props> = ({
if (!fullUrl) throw new Error('Nelze zjistit URL článku/aktivity');
// Deterministic shortlink code to keep link stable across generations
const code = article?.id ? `ig-a-${article.id}` : (activity?.id ? `ig-e-${activity.id}` : `ig-share`);
const payload = {
target_url: fullUrl,
title: article?.title || activity?.title || 'Link',
source_type: article ? 'article' : (activity ? 'event' : 'other'),
source_id: article?.id || activity?.id,
code,
} as any;
let sUrl = '';
try {
const res = await createShortLink(payload);
sUrl = res?.short_url || '';
} catch (err) {
// If code already exists or creation fails, fallback to computed short URL path
// Fallback to public shortlink (deterministic per URL) or use long URL
try {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
sUrl = origin ? `${origin}/s/${code}` : fullUrl;
const resPub = await createPublicShortLink({ target_url: fullUrl, title: article?.title || activity?.title || 'Link' });
sUrl = resPub?.short_url || fullUrl;
} catch {
sUrl = fullUrl;
}
@@ -74,12 +74,14 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const containerRef = useRef<HTMLDivElement | 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 toolbarDragRef = useRef<{ active: boolean; startX: number; startY: number; startLeft: number; startTop: number }>({ active: false, startX: 0, startY: 0, startLeft: 0, startTop: 0 });
const [isMounted, setIsMounted] = useState(false);
const [isVisible, setIsVisible] = useState(false);
// Ensure component is mounted before rendering Quill
useEffect(() => {
@@ -87,6 +89,51 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return () => setIsMounted(false);
}, []);
// Track visibility of the editor container (avoid mounting Quill while hidden)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
let ro: ResizeObserver | null = null;
let io: IntersectionObserver | null = null;
let mo: MutationObserver | null = null;
const check = () => {
try {
const inDoc = document.contains(el);
const rects = el.getClientRects();
const style = window.getComputedStyle(el);
const visible = inDoc && rects.length > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
setIsVisible(visible);
} catch {}
};
// Observe size/visibility changes
try {
ro = new ResizeObserver(() => check());
ro.observe(el);
} catch {}
try {
io = new IntersectionObserver((entries) => {
const entry = entries[0];
setIsVisible(!!entry && (entry.isIntersecting || entry.intersectionRatio > 0));
}, { root: null, threshold: [0, 0.01] });
io.observe(el);
} catch {}
try {
mo = new MutationObserver(() => check());
mo.observe(document.body, { attributes: true, childList: true, subtree: true });
} catch {}
// Initial check
check();
return () => {
try { ro && ro.disconnect(); } catch {}
try { io && io.disconnect(); } catch {}
try { mo && mo.disconnect(); } catch {}
};
}, [containerRef]);
// Keep onChange ref up to date
useEffect(() => {
onChangeRef.current = onChange;
@@ -143,7 +190,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
full: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -153,7 +200,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }, 'colorreset', 'bgreset'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ align: [] }],
['link', 'image'],
@@ -302,16 +349,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setTimeout(() => setIsListStyleOpen(true), 0);
}
},
colorreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('color', false);
},
bgreset: () => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('background', false);
},
},
},
clipboard: {
@@ -391,8 +428,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
setTitle('button.ql-colorreset', 'Zrušit barvu');
setTitle('button.ql-bgreset', 'Zrušit pozadí');
// Inject reset option inside color/background pickers
try {
const injectReset = (
pickerSelector: string,
format: 'color' | 'background',
label: string
) => {
const picker = toolbarEl.querySelector(pickerSelector) as HTMLElement | null; // .ql-color or .ql-background
const options = picker?.querySelector('.ql-picker-options') as HTMLElement | null;
if (!options) return;
if (options.querySelector(`button.ql-picker-item[data-reset="${format}"]`)) return;
const btn = document.createElement('button');
btn.setAttribute('type', 'button');
btn.className = 'ql-picker-item';
btn.setAttribute('data-reset', format);
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
btn.style.width = '16px';
btn.style.height = '16px';
btn.style.border = '1px solid #e2e8f0';
btn.style.borderRadius = '2px';
btn.style.position = 'relative';
btn.style.background = '#ffffff';
const slash = document.createElement('span');
slash.style.position = 'absolute';
slash.style.left = '2px';
slash.style.right = '2px';
slash.style.top = '7px';
slash.style.height = '2px';
slash.style.background = '#e53e3e';
slash.style.transform = 'rotate(-45deg)';
btn.appendChild(slash);
btn.addEventListener('click', (e) => {
e.preventDefault();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(format, false);
try { picker?.classList.remove('ql-expanded'); } catch {}
});
options.insertBefore(btn, options.firstChild);
};
injectReset('.ql-color', 'color', 'Zrušit barvu');
injectReset('.ql-background', 'background', 'Zrušit pozadí');
} catch {}
// Headers
setTitle('.ql-header .ql-picker-label', 'Nadpis');
@@ -1401,7 +1480,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
const handleChange = (content: string) => {
onChangeRef.current(cleanEditorHTML(content));
onChangeRef.current(content);
};
@@ -1462,6 +1541,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderRadius="md"
overflow="visible"
bg={bgColor}
ref={containerRef}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
@@ -1622,7 +1702,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Allow user-chosen colors to show. White-on-white is handled during paste/sanitize only.
}}
>
{isMounted && (
{isMounted && isVisible && (
<ReactQuill
theme="snow"
value={value}
@@ -1632,6 +1712,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
ref={quillRef}
modules={quillModules}
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const html = editor?.getHTML ? editor.getHTML() : (quillRef.current?.getEditor().root.innerHTML || value);
onChangeRef.current(cleanEditorHTML(html));
} catch {}
}}
/>
)}
</Box>
@@ -1,7 +1,7 @@
import React from 'react';
import { Box, Image, Heading, Text, VStack, HStack, Badge, Skeleton, useColorModeValue, Button } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { getArticles, Article } from '../../services/articles';
import { getArticles, getFeaturedArticles, Article } from '../../services/articles';
import HorizontalScroller from '../ui/HorizontalScroller';
import { Link as RouterLink } from 'react-router-dom';
import { useClubTheme } from '../../contexts/ClubThemeContext';
@@ -108,8 +108,17 @@ const BlogCardsScroller: React.FC = () => {
queryKey: ['articles', { page: 1, page_size: 12, published: true }],
queryFn: () => getArticles({ page: 1, page_size: 12, published: true }),
});
// Load featured (primary) to exclude from scroller
const { data: featuredData } = useQuery({
queryKey: ['articles', 'featured', { page: 1, page_size: 100 }],
queryFn: () => getFeaturedArticles({ page: 1, page_size: 100 }),
});
const list: Article[] = data?.data || [];
const listAll: Article[] = data?.data || [];
const featuredKeys = new Set(
(featuredData?.data || []).map((a: Article) => (a.slug ? `s:${a.slug}` : `i:${a.id}`))
);
const list: Article[] = listAll.filter((a) => !featuredKeys.has(a.slug ? `s:${a.slug}` : `i:${a.id}`));
return (
<Box>
@@ -147,7 +147,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor}>
<Heading size="xl" color={headingColor} id="home-gallery-heading">
Fotogalerie
</Heading>
<Text color={textColor} fontSize="sm">
@@ -227,6 +227,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
h="200px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
) : (
<Box
+11 -4
View File
@@ -66,7 +66,6 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
// Default to 6 items on homepage unless overridden by settings (max 12)
const limit = Math.max(1, Math.min(12, settings?.videos_limit ?? 6));
const youtubeUrl = (settings as any)?.youtube_url || (settings as any)?.social_youtube || null;
const titleOverrides: Record<string, string> = (settings as any)?.videos_title_overrides || {};
useEffect(() => {
try {
@@ -171,7 +170,15 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
}}
>
<AspectRatio ratio={16 / 9}>
<Box position="relative" cursor="pointer" onClick={() => handlePlayClick(it)}>
<Box
position="relative"
cursor="pointer"
role="button"
tabIndex={0}
aria-label={`Přehrát video: ${it.title}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePlayClick(it); } }}
onClick={() => handlePlayClick(it)}
>
{/* Thumbnail */}
{thumb ? (
<Box
@@ -257,7 +264,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
<Box>
<Box className="section-head" style={{ marginTop: 8, marginBottom: 16 }}>
<HStack spacing={3}>
<Heading as="h3" size="lg" fontWeight="700">Videa</Heading>
<Heading as="h3" size="lg" fontWeight="700" id="home-videos-heading">Videa</Heading>
</HStack>
<Link as={RouterLink} to="/videa">
<Button
@@ -340,7 +347,7 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
return (
<Box>
<Box className="section-head">
<Heading as="h3" size="md">Videa</Heading>
<Heading as="h3" size="md" id="home-videos-heading">Videa</Heading>
<Link as={RouterLink} to="/videa">
<Button size="sm" variant="outline" colorScheme="blue">Více videí</Button>
</Link>
+20 -5
View File
@@ -62,16 +62,20 @@ const MatchesSlider: React.FC<{
const items = (current?.matches || []);
const looped = [...items, ...items, ...items];
return (
<section className="matches-slider matches-ticker" {...(elementProps || {})}>
<section className="matches-slider matches-ticker" aria-label={`Zápasy – ${current?.name || ''}`} {...(elementProps || {})}>
<div className="section-head" style={{ marginTop: 16, marginBottom: 8 }}>
<h3>{title}</h3>
<a href="/kalendar" className="see-all">Všechny zápasy</a>
</div>
<div className="ticker-belt">
<div className="ticker-belt" role="list">
{looped.map((m, idx) => (
<div
key={`${m.id || idx}-ticker`}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -104,17 +108,21 @@ const MatchesSlider: React.FC<{
}
return (
<section className="matches-slider" {...(elementProps || {})}>
<section className="matches-slider" aria-label={`Zápasy – ${current?.name || ''}`} {...(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}>
<div className="matches-track" ref={trackRef} role="list">
{(current?.matches || []).map((m, idx) => (
<div
key={m.id || idx}
className="match-card card"
role="button"
tabIndex={0}
aria-label={`${sanitizeClubName(m.home || '')} proti ${sanitizeClubName(m.away || '')}${m.date ? `, ${new Date(`${m.date}T${(m.time || '00:00')}:00`).toLocaleDateString()}` : ''}${m.time ? `, ${m.time}` : ''}${m.score ? `, skóre ${String(m.score)}` : ''}`}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onMatchClick?.(m, current?.name); } }}
onClick={(e) => { e.preventDefault(); onMatchClick?.(m, current?.name); }}
style={{ cursor: onMatchClick ? 'pointer' as const : 'default' as const }}
>
@@ -163,7 +171,14 @@ const MatchesSlider: React.FC<{
</div>
<div className="matches-tabs">
{comps.map((c, i) => (
<button key={`${c.name}-${i}`} className={i === activeIndex ? 'active' : ''} onClick={() => onActiveChange(i)}>{c.name}</button>
<button
key={`${c.name}-${i}`}
className={i === activeIndex ? 'active' : ''}
aria-pressed={i === activeIndex}
onClick={() => onActiveChange(i)}
>
{c.name}
</button>
))}
</div>
</div>
@@ -27,12 +27,17 @@ const NextMatch: React.FC<{
<section
className="next-match"
{...(elementProps as any)}
role={onOpen ? 'button' : 'region'}
aria-label={`Další zápas: ${sanitizeClubName(show?.home || '')} vs ${sanitizeClubName(show?.away || '')}${competitionName ? `, ${competitionName}` : ''}`}
tabIndex={onOpen ? 0 : -1}
onKeyDown={(e) => { if (!onOpen) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); onOpen?.(); } }}
onClick={(e) => { e.stopPropagation(); onOpen?.(); }}
style={{ cursor: onOpen ? 'pointer' : 'default', position: 'relative', ...(elementProps?.style || {}) }}
>
{onPrev && (
<button
aria-label="Předchozí soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onPrev?.(); }}
className="nav prev"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -78,6 +83,7 @@ const NextMatch: React.FC<{
{onNext && (
<button
aria-label="Další soutěž"
type="button"
onClick={(e) => { e.stopPropagation(); onNext?.(); }}
className="nav next"
style={{ background: 'transparent', border: 'none', color: 'var(--text-on-primary)' }}
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { getCurrentSweepstake, enterSweepstake, markSweepstakeVisualPlayed, CurrentSweepstakeResponse } from '../../services/sweepstakes';
import { useToast } from '@chakra-ui/react';
const fmtDate = (iso?: string | null) => {
if (!iso) return '';
@@ -15,6 +16,7 @@ const SweepstakeWidget: React.FC = () => {
const [joining, setJoining] = useState<boolean>(false);
const [playing, setPlaying] = useState<boolean>(false);
const playedRef = useRef(false);
const toast = useToast();
const load = async () => {
setLoading(true);
@@ -65,9 +67,11 @@ const SweepstakeWidget: React.FC = () => {
setJoining(true);
try {
await enterSweepstake(s.id);
toast({ status: 'success', title: 'Úspěšně jste vstoupili do soutěže' });
await load();
} catch (e) {
// ignore
} catch (e: any) {
const msg = e?.response?.data?.error || 'Nelze vstoupit do soutěže';
toast({ status: 'error', title: msg });
} finally {
setJoining(false);
}
@@ -104,13 +108,11 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Začíná: {fmtDate(s.start_at)} • Končí: {fmtDate(s.end_at)}</div>
</div>
{!isLogged ? (
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se a zapojit</a>
<a href="/login" className="btn" style={{ padding: '10px 16px' }}>Přihlásit se</a>
) : data?.has_entered ? (
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
</button>
<span style={{ fontSize: 14, opacity: 0.85 }}>Soutěž ještě nezačala. Vstup bude možný od {fmtDate(s.start_at)}.</span>
)}
</div>
</div>
@@ -131,6 +133,12 @@ const SweepstakeWidget: React.FC = () => {
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 4 }}>{s.title}</div>
{s.description && <div style={{ opacity: 0.8 }}>{s.description}</div>}
<div style={{ marginTop: 8, fontSize: 14, opacity: 0.8 }}>Konec: {fmtDate(s.end_at)}</div>
<div style={{ marginTop: 6, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, background: '#eef', color: '#223', padding: '2px 8px', borderRadius: 12 }}>Vstup: {(s as any).entry_cost_points ? `${(s as any).entry_cost_points} bodů` : 'zdarma'}</span>
{(s as any).max_entries_per_user > 1 && (
<span style={{ fontSize: 13, background: '#f3f3f3', color: '#333', padding: '2px 8px', borderRadius: 12 }}>max {(s as any).max_entries_per_user}×/osoba</span>
)}
</div>
</div>
{!isLogged ? (
<div style={{ fontWeight: 600 }}>Právě zde probíhá soutěž. <a href="/login">Přihlaste se</a> a zapojte se.</div>
@@ -138,7 +146,7 @@ const SweepstakeWidget: React.FC = () => {
<span style={{ fontWeight: 600 }}>Jste zapojeni ✓</span>
) : (
<button className="btn" onClick={onJoin} disabled={joining}>
{joining ? 'Přihlašuji…' : 'Zapojit se'}
{joining ? 'Vstupuji…' : 'Vstoupit'}
</button>
)}
</div>
@@ -175,6 +175,27 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
py={2}
px={1}
cursor={draggable ? 'grab' : 'default'}
role="region"
aria-roledescription="carousel"
aria-label={title ? `Posuvník: ${title}` : 'Posuvník obsahu'}
tabIndex={0}
onKeyDown={(e) => {
const el = containerRef.current;
if (!el) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
scrollBy(-1);
} else if (e.key === 'ArrowRight') {
e.preventDefault();
scrollBy(1);
} else if (e.key === 'Home') {
e.preventDefault();
el.scrollTo({ left: 0, behavior: 'smooth' });
} else if (e.key === 'End') {
e.preventDefault();
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
}
}}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => { setIsHovering(false); if (draggable) onPointerUp(); }}
onMouseDown={(e) => { if (!draggable) return; e.preventDefault(); onPointerDown(e.clientX); }}
@@ -208,7 +229,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
{/* navigation buttons - must be above gradient masks */}
<IconButton
aria-label="scroll left"
aria-label="Posunout doleva"
icon={<ChevronLeftIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
@@ -234,7 +255,7 @@ const HorizontalScroller: React.FC<HorizontalScrollerProps> = ({ title, rightAct
pointerEvents="auto"
/>
<IconButton
aria-label="scroll right"
aria-label="Posunout doprava"
icon={<ChevronRightIcon boxSize={6} />}
onClick={(e) => {
e.preventDefault();
+74 -5
View File
@@ -1,4 +1,4 @@
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag } from '@chakra-ui/react';
import { Box, Container, Heading, Image, Spinner, Stack, Text, HStack, Badge, Link, SimpleGrid, Button, AspectRatio, useColorModeValue, Flex, VStack, Tag, Breadcrumb, BreadcrumbItem, BreadcrumbLink } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { useParams, Link as RouterLink } from 'react-router-dom';
import { getArticle, getArticleBySlug, getArticleMatchLink, trackArticleView, getArticles } from '../services/articles';
@@ -17,6 +17,7 @@ import TeamLogo from '../components/common/TeamLogo';
import MatchModal from '../components/home/MatchModal';
import { extractPalette } from '../utils/colors';
import { getTeamLogo } from '../utils/sportLogosAPI';
import { getBanners, Banner as UIBanner } from '../services/banners';
import FilePreview from '../components/common/FilePreview';
import { usePublicSettings } from '../hooks/usePublicSettings';
import InstagramGeneratorButton from '../components/admin/InstagramGeneratorButton';
@@ -42,6 +43,8 @@ const ArticleDetailPage: React.FC = () => {
enabled: Boolean(slug || id),
});
// Load competition aliases to resolve category → alias mapping for MatchesWidget filtering
const aliasesQ = useQuery<{ list: CompetitionAlias[] }>({
queryKey: ['competition-aliases-public'],
@@ -336,6 +339,13 @@ const ArticleDetailPage: React.FC = () => {
});
}, [(data as any)?.content, toAbsoluteUploads]);
const articleBannersQ = useQuery<UIBanner[]>({
queryKey: ['banners', { placement: 'article_inline' }],
queryFn: () => getBanners({ active: true, placement: 'article_inline' }),
staleTime: 60 * 1000,
});
const articleBanners = (articleBannersQ.data || []) as UIBanner[];
const relatedArticlesQuery = useQuery({
queryKey: ['related-articles', (data as any)?.category?.id || 'none', (data as any)?.id],
enabled: Boolean((data as any)?.id),
@@ -526,6 +536,22 @@ const ArticleDetailPage: React.FC = () => {
</HStack>
) : null}
</HStack>
<Breadcrumb fontSize="sm" mt={2} color={textMuted} separator="/">
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/">Domů</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/blog">Blog</BreadcrumbLink>
</BreadcrumbItem>
{(data as any)?.category?.id ? (
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to={`/news?category_id=${(data as any).category.id}`}>{(data as any).category.name}</BreadcrumbLink>
</BreadcrumbItem>
) : null}
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>{data.title}</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</Container>
</Box>
<Container maxW="7xl">
@@ -541,7 +567,8 @@ const ArticleDetailPage: React.FC = () => {
{/* Match Section - Card with logos, score/countdown, venue/date */}
{(matchLinkQuery.data as any)?.external_match_id && (
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden">
<Box position="relative" borderWidth="1px" borderRadius="lg" p={{ base: 4, md: 5 }} bg={cardBg} overflow="hidden" cursor="pointer"
onClick={() => { if (facrMatchQuery?.data) { setSelectedMatch({ ...(facrMatchQuery.data as any), competition: (facrMatchQuery.data as any).competitionName }); setIsMatchModalOpen(true); } }}>
{/* 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 && (
@@ -639,6 +666,27 @@ const ArticleDetailPage: React.FC = () => {
}}
dangerouslySetInnerHTML={{ __html: safeContentHTML }}
/>
{articleBanners.length > 0 && (
<Box textAlign="center" mt={{ base: 4, md: 6 }}>
<a
href={articleBanners[0].click_url || '#'}
target={articleBanners[0].click_url ? '_blank' : undefined}
rel={articleBanners[0].click_url ? 'noopener noreferrer' : undefined}
style={{ display: 'inline-block' }}
>
<Image
src={assetUrl((articleBanners[0] as any).image_url) || '/images/sponsors/placeholder.png'}
alt={articleBanners[0].name}
maxW="100%"
w={articleBanners[0].width ? `${articleBanners[0].width}px` : '100%'}
h={articleBanners[0].height ? `${articleBanners[0].height}px` : 'auto'}
borderRadius="md"
loading="lazy"
decoding="async"
/>
</a>
</Box>
)}
{/* YouTube Video Section - simplified with rounded edges */}
{(data as any)?.youtube_video_id && (
<Box borderRadius="xl" overflow="hidden">
@@ -708,6 +756,13 @@ const ArticleDetailPage: React.FC = () => {
</Stack>
</Box>
<VStack align="stretch" spacing={6} gridColumn={{ base: '1 / -1', lg: 'span 4' }}>
{/* Polls in sidebar */}
{data?.id ? (
<Widget title="Anketa">
<EmbeddedPoll articleId={(data as any).id} maxPolls={1} />
</Widget>
) : null}
{relatedArticlesQuery.isLoading ? null : (() => {
const list = ((relatedArticlesQuery.data as any)?.data || [])
.filter((a: any) => a?.id !== (data as any)?.id)
@@ -768,12 +823,28 @@ const ArticleDetailPage: React.FC = () => {
</Widget>
);
})()}
{/* Attachments in sidebar */}
{Array.isArray((data as any)?.attachments) && (data as any).attachments.length > 0 && (
<Widget title="Přílohy">
<VStack align="stretch" 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>
))}
</VStack>
</Widget>
)}
</VStack>
</SimpleGrid>
</Container>
</Box>
{/* Attachments - bottom above CTA */}
{/* Polls (Ankety) above attachments */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Attachments - bottom above comments */}
{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}>
@@ -789,8 +860,6 @@ const ArticleDetailPage: React.FC = () => {
</Box>
</Container>
)}
{/* Polls (Ankety) above CTA */}
{data?.id && <EmbeddedPoll articleId={(data as any).id} maxPolls={3} />}
{/* Comments at the end */}
{(data as any)?.id ? (
<Container maxW="7xl" mt={4}>
+25 -50
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery } from '@chakra-ui/react';
import { Box, Container, Heading, VStack, Image, Text, Skeleton, LinkBox, HStack, Select, Badge, useColorModeValue, Input, InputGroup, InputLeftElement, InputRightElement, IconButton, Grid, GridItem, useMediaQuery, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { getArticles, Article, Paginated, getFeaturedArticles } from '../services/articles';
import { getBanners, Banner as UIBanner } from '../services/banners';
@@ -24,6 +24,9 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
? ({ base: '160px', md: '180px' } as const)
: ({ base: '200px', md: '220px' } as const);
const publishedAt = (article as any).published_at || (article as any).created_at;
const publishedDateStr = publishedAt ? (()=>{ try { return new Date(publishedAt).toLocaleDateString('cs-CZ'); } catch { return ''; } })() : '';
return (
<LinkBox
as={RouterLink}
@@ -49,59 +52,31 @@ const BlogTile: React.FC<{ article: Article; variant?: 'large' | 'small' }> = ({
fetchPriority={variant === 'large' ? 'high' as any : 'auto' as any}
/>
<Box position="absolute" inset={0} bgGradient="linear(to-t, rgba(0,0,0,0.55), rgba(0,0,0,0.15))" />
{categoryName && (
<Badge
position="absolute"
top={2}
left={2}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
{/* Top info row: category (left), date (center), read time (right) */}
<HStack position="absolute" top={2} left={2} right={2} justify="space-between" align="center">
{categoryName ? (
<Tooltip label="Kategorie" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{categoryName}
</Badge>
)}
{/* Stats badges at top */}
{(readTime || (viewCount && viewCount > 0)) && (
<HStack position="absolute" top={2} right={2} spacing={1}>
{readTime && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
</Tooltip>
) : <Box />}
{publishedDateStr ? (
<Tooltip label="Datum publikace" hasArrow>
<Badge bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
{publishedDateStr}
</Badge>
</Tooltip>
) : <Box />}
{readTime ? (
<Tooltip label="Doba čtení" hasArrow>
<Badge display="flex" alignItems="center" gap={1} bg="rgba(0,0,0,0.7)" color="white" fontSize="xs" px={2} py={1} borderRadius="md">
<Clock size={12} />
{readTime} min
</Badge>
)}
{viewCount && viewCount > 0 && (
<Badge
display="flex"
alignItems="center"
gap={1}
bg="rgba(0,0,0,0.7)"
color="white"
fontSize="xs"
px={2}
py={1}
borderRadius="md"
>
<Eye size={12} />
{viewCount}
</Badge>
)}
</Tooltip>
) : <Box />}
</HStack>
)}
<Heading
as="h3"
@@ -367,9 +342,9 @@ const BlogPage: React.FC = () => {
</Container>
)}
<Container maxW="5xl">
<Container maxW="7xl">
{/* Responsive grid with consistent card sizing */}
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={6}>
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={8}>
{isLoading && Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} h={{ base: '260px', md: '300px' }} borderRadius="md" />
))}
+4 -3
View File
@@ -37,6 +37,7 @@ import ContactMap from '../components/home/ContactMap';
import { getPublicContacts, GroupedContacts } from '../services/contactInfo';
import { facrApi } from '../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../services/competitionAliases';
import { getImageUrl } from '../utils/imageUtils';
type ContactFormData = {
name: string;
@@ -276,7 +277,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -317,7 +318,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
@@ -359,7 +360,7 @@ const ContactPage: React.FC = () => {
<Box key={contact.id} bg={bgColor} p={4} borderRadius="md" borderWidth="1px" borderColor={borderColor}>
<VStack align="start" spacing={3}>
{contact.image_url && (
<Avatar src={contact.image_url} name={contact.name} size="lg" />
<Avatar src={getImageUrl(contact.image_url)} name={contact.name} size="lg" />
)}
<Box>
<Heading size="sm">{contact.name}</Heading>
+212 -58
View File
@@ -39,6 +39,7 @@ const MatchesSlider = React.lazy(() => import('../components/pack/MatchesSlider'
import ActivitiesList from '../components/pack/ActivitiesList';
import { useAuth } from '../contexts/AuthContext';
import SweepstakeWidget from '../components/sweepstakes/SweepstakeWidget';
import { sortCategoriesWithOrder } from '../utils/categorySort';
// Types for real API-driven data
type NewsItem = {
@@ -92,7 +93,7 @@ const HomePage: React.FC = () => {
const [edgeRoleIdx, setEdgeRoleIdx] = useState<number>(0);
const blogAutoRef = useRef<HTMLDivElement | null>(null);
// FACR competitions with matches (for slider)
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string }>>([]);
const [facrCompetitions, setFacrCompetitions] = useState<Array<{ name:string; matches:Array<any>; matches_link?:string; display_order?: number }>>([]);
const [matchesTab, setMatchesTab] = useState<number>(0);
const [selectedClub, setSelectedClub] = useState<any>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -118,10 +119,11 @@ const HomePage: React.FC = () => {
const [merchItems, setMerchItems] = useState<UiMerch[]>([]);
const [merchEnabled, setMerchEnabled] = useState<boolean>(false);
const [upcomingEvents, setUpcomingEvents] = useState<UiEvent[]>([]);
const [activitiesLoaded, setActivitiesLoaded] = useState<boolean>(false);
const [defer, setDefer] = useState<boolean>(false);
// Aliases
const [aliases, setAliases] = useState<CompetitionAlias[]>([]);
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string; display_order?: number }>>({});
const [settings, setSettings] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isEditingMode, setIsEditingMode] = useState<boolean>(false);
@@ -164,6 +166,33 @@ const HomePage: React.FC = () => {
slug: item.slug,
})), [featured]);
const upcomingCompIndices = useMemo(() => {
const now = Date.now();
try {
return (facrCompetitions || [])
.map((c, i) => {
const items = Array.isArray(c?.matches) ? c.matches : [];
const hasUpcoming = items.some((m: any) => {
const t = new Date(`${m.date || ''}T${(m.time || '00:00')}:00`).getTime();
return !isNaN(t) && t > now;
});
return hasUpcoming ? i : -1;
})
.filter((i) => i !== -1);
} catch {
return [] as number[];
}
}, [facrCompetitions]);
useEffect(() => {
try {
if (!Array.isArray(upcomingCompIndices) || upcomingCompIndices.length === 0) return;
if (!upcomingCompIndices.includes(nextCompIdx)) {
setNextCompIdx(upcomingCompIndices[0]);
}
} catch {}
}, [upcomingCompIndices, nextCompIdx]);
useEffect(() => {
let cancelled = false;
@@ -262,8 +291,8 @@ const HomePage: React.FC = () => {
try {
aliasesList = await getCompetitionAliasesPublic();
} catch {}
const amap: Record<string, { alias: string; original_name?: string }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name }; });
const amap: Record<string, { alias: string; original_name?: string; display_order?: number }> = {};
(aliasesList || []).forEach((a) => { if (a?.code && a?.alias) amap[a.code] = { alias: a.alias, original_name: a.original_name, display_order: a.display_order }; });
// Try live settings API first
let liveSettings: any = null;
try {
@@ -392,10 +421,12 @@ const HomePage: React.FC = () => {
return {
name: (amap?.[c?.code]?.alias) || c.name || c.code || 'Soutěž',
matches_link: c.matches_link,
matches: filtered
matches: filtered,
display_order: (amap?.[c?.code]?.display_order),
};
});
setFacrCompetitions(comps);
const sortedComps = sortCategoriesWithOrder(comps as any);
setFacrCompetitions(sortedComps as any);
// Next match FACR link
const first = filteredMatches?.[0];
@@ -414,7 +445,7 @@ const HomePage: React.FC = () => {
// Load players via API (include inactive to show as non-active instead of hiding)
try {
const apiPlayers: ApiPlayer[] = await apiGetPlayers({ active: false });
const apiPlayers: ApiPlayer[] = await apiGetPlayers();
const mappedPlayers: UiPlayer[] = (apiPlayers || []).map((p: ApiPlayer) => ({
id: p.id,
name: [p.first_name, p.last_name].filter(Boolean).join(' '),
@@ -481,7 +512,7 @@ const HomePage: React.FC = () => {
const top3 = all.slice(0, 3);
setFeatured(top3);
setNews((prev) => {
const featuredKeys = new Set(top3.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
const featuredKeys = new Set(all.map((f) => (f.slug ? `s:${f.slug}` : `i:${f.id}`)));
return (prev || []).filter((n) => !featuredKeys.has(n.slug ? `s:${n.slug}` : `i:${n.id}`));
});
} catch {}
@@ -531,6 +562,8 @@ const HomePage: React.FC = () => {
if (facrTablesJSON?.competitions?.length) {
const comps = (facrTablesJSON.competitions || []).map((c: any) => ({
name: (amap?.[c?.code]?.alias) || c.name || c.code,
display_order: (amap?.[c?.code]?.display_order),
code: c.code,
table: (c.table?.overall || []).map((r: any, idx: number) => ({
position: Number(r.rank || idx + 1),
team: r.team || r.team_name || '-',
@@ -544,7 +577,8 @@ const HomePage: React.FC = () => {
score: r.score || '0:0',
})),
}));
setStandings(comps);
const sortedTables = sortCategoriesWithOrder(comps as any);
setStandings(sortedTables);
}
// Club name/logo from FACR if not provided by settings
@@ -630,6 +664,9 @@ const HomePage: React.FC = () => {
}));
if (active) setUpcomingEvents(mapped);
} catch {}
finally {
if (active) setActivitiesLoaded(true);
}
})();
return () => { active = false; };
}, []);
@@ -1401,6 +1438,9 @@ const HomePage: React.FC = () => {
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>{featured[0].title}</h2>
</div>
</a>
) : (
isLoading ? (
<div className="hero-card big skeleton" style={{ borderRadius: 16 }} />
) : (
<a href="/news" className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')` }} />
@@ -1409,6 +1449,7 @@ const HomePage: React.FC = () => {
<h2 style={{ margin: '4px 0 0 0', color: '#ffffff' }}>Nejnovější titulek</h2>
</div>
</a>
)
)}
<div className="small-col">
{featured.slice(1, 3).map((n, idx) => (
@@ -1421,6 +1462,9 @@ const HomePage: React.FC = () => {
</a>
))}
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
isLoading ? (
<div key={`placeholder-${idx}`} className="hero-card small skeleton" style={{ borderRadius: 16 }} />
) : (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
@@ -1428,6 +1472,7 @@ const HomePage: React.FC = () => {
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</a>
)
))}
</div>
</section>
@@ -1438,7 +1483,7 @@ const HomePage: React.FC = () => {
{(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 */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1446,34 +1491,37 @@ const HomePage: React.FC = () => {
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) - fixed edge rail, left/right via MyUIbrix variant */}
{/* Sidebar banners (homepage_sidebar) - sticky within page container */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
<section
key={`sidebar-${refreshKey}-${getVariant('sidebar', 'right')}`}
data-element="sidebar"
data-variant={getVariant('sidebar', 'right')}
className={`banner banner-sidebar sidebar-${getVariant('sidebar', 'right')}`}
style={{ margin: '24px 0', ...getStyles('sidebar') }}
>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div
style={{
// Use configured styles but force fixed rail placement
...getStyles('sidebar'),
position: 'fixed',
position: 'sticky',
top: 112,
left: getVariant('sidebar', 'right') === 'left' ? 12 : 'auto',
right: getVariant('sidebar', 'right') === 'left' ? 'auto' : 12,
width: 320,
maxWidth: '100%',
zIndex: 50,
pointerEvents: 'none',
marginLeft: getVariant('sidebar', 'right') === 'left' ? 0 : 'auto',
marginRight: getVariant('sidebar', 'right') === 'left' ? 'auto' : 0,
zIndex: 1,
}}
>
{(banners || []).filter(b => b.placement === 'homepage_sidebar').map((b) => (
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, pointerEvents: 'auto', padding: 4 }}>
<div key={b.id} className="card" style={{ display: 'block', marginBottom: 12, padding: 4 }}>
<a href={b.url || '#'} target={b.url ? '_blank' : undefined} rel={b.url ? 'noopener noreferrer' : undefined} style={{ display: 'block' }}>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img loading="lazy" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ width: b.width ? `${b.width}px` : '100%', height: b.height ? `${b.height}px` : 'auto', maxWidth: '100%' }} />
</a>
</div>
))}
</div>
</div>
</section>
)}
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
@@ -1492,16 +1540,23 @@ const HomePage: React.FC = () => {
)}
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{facrCompetitions.length > 0 && isVisible('matches', true) ? (
{isVisible('matches', true) ? (
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
const comp = facrCompetitions[Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1))];
const safeIndex = Math.max(0, Math.min(nextCompIdx, facrCompetitions.length - 1));
const pos = upcomingCompIndices.indexOf(safeIndex);
const effectiveIndex = pos >= 0 ? upcomingCompIndices[pos] : upcomingCompIndices[0];
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
const upcoming = items
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
const show = upcoming || items[0] || null;
const show = upcoming || null;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
const handleNextMatchClick = () => {
if (show) {
setSelectedMatch({
@@ -1519,18 +1574,20 @@ const HomePage: React.FC = () => {
data={show}
competitionName={comp?.name}
countdown={countdown}
onPrev={() => setMatchesTab((i) => (i - 1 + facrCompetitions.length) % facrCompetitions.length)}
onNext={() => setMatchesTab((i) => (i + 1) % facrCompetitions.length)}
onPrev={() => setNextCompIdx(prevIdx)}
onNext={() => setNextCompIdx(nextIdx)}
onOpen={handleNextMatchClick}
elementProps={{
'data-element': 'matches' as any,
'data-variant': getVariant('matches', 'compact') as any,
'aria-live': 'polite' as any,
style: { ...getStyles('matches') },
}}
/>
);
})()
) : isVisible('matches', true) ? (
) : null
) : (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
@@ -1541,9 +1598,10 @@ const HomePage: React.FC = () => {
away_logo_url: matches[0]?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), style: { position: 'relative', ...getStyles('matches') } }}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
)
) : null}
{/* Sweepstakes / Lottery widget (visible around matches section) */}
@@ -1571,6 +1629,20 @@ const HomePage: React.FC = () => {
) : null
)}
{facrCompetitions.length === 0 && isLoading && (
<section data-element="matches-slider" data-variant={getVariant('matches-slider', 'carousel')} aria-label="Zápasy" style={{ position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '280px', ...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</a>
</div>
<div style={{ display: 'flex', gap: 18, overflow: 'hidden', padding: '8px 2px 16px 2px' }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ minWidth: 340, height: 160, borderRadius: 12 }} />
))}
</div>
</section>
)}
{/* News + Tables: split into two independent sections */}
{(() => {
// Compute matching standings for the selected competition
@@ -1597,23 +1669,31 @@ const HomePage: React.FC = () => {
style={{ marginTop: 32 }}
>
{showNews && (
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" style={{ ...getStyles('news') }}>
<section key={`news-${refreshKey}-${newsVariant}`} data-element="news" data-variant={newsVariant} className="news-list" aria-labelledby="home-news-heading" style={{ ...getStyles('news'), contentVisibility: 'auto' as any, containIntrinsicSize: '600px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Další aktuality</h3>
<h3 id="home-news-heading">Další aktuality</h3>
<a href="/news" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{newsVariant === 'scroller' ? (
<BlogCardsScroller />
) : (
isLoading && (!news || (news as any).length === 0) ? (
<div className="blog-list">
{[1,2,3,4].map(i => (
<div key={i} className="card skeleton" style={{ height: 96, borderRadius: 12 }} />
))}
</div>
) : (
<NewsList items={news as any} />
)
)}
</section>
)}
{showTable && (
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} style={{ ...getStyles('table') }}>
<div key={`table-${refreshKey}-${getVariant('table', 'split_news')}`} data-element="table" data-variant={getVariant('table', 'split_news')} role="region" aria-labelledby="home-table-heading" style={{ ...getStyles('table'), contentVisibility: 'auto' as any, containIntrinsicSize: '520px' }}>
<div className="section-head" style={{ marginTop: 0, marginBottom: 12 }}>
<h3>Tabulky</h3>
<h3 id="home-table-heading">Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
{defer ? (
@@ -1639,7 +1719,15 @@ const HomePage: React.FC = () => {
}}
/>
</Suspense>
) : null}
) : (
<div className="table-card">
<div className="standings">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="standing-row skeleton" style={{ borderRadius: 12 }} />
))}
</div>
</div>
)}
{/* Banners under the table, inside the table column */}
{(banners || []).some(b => b.placement === 'homepage_under_table') && (
defer ? (
@@ -1658,11 +1746,27 @@ const HomePage: React.FC = () => {
{/* Competition tables moved into right column below */}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} style={{ marginTop: 32, marginBottom: 16, position: 'relative', ...getStyles('activities') }}>
{isVisible('activities', true) && !activitiesLoaded && (
<section data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3>Aktivity</h3>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map(i => (
<div key={i} className="card skeleton" style={{ height: 120, borderRadius: 12 }} />
))}
</div>
</div>
</section>
)}
{upcomingEvents.length > 0 && isVisible('activities', true) && (
<section key={`activities-${refreshKey}-${getVariant('activities', 'list')}`} data-element="activities" data-variant={getVariant('activities', 'list')} aria-labelledby="home-activities-heading" style={{ marginTop: 32, marginBottom: 16, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('activities') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<div className="section-head" style={{ marginTop: 0 }}>
<h3 id="home-activities-heading">Aktivity</h3>
<a href="/aktivity" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<ActivitiesList items={upcomingEvents as any} />
@@ -1671,10 +1775,25 @@ const HomePage: React.FC = () => {
)}
{/* Players scroller */}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" style={{ marginTop: 32, position: 'relative', ...getStyles('team') }}>
{isVisible('team', false) && players.length === 0 && isLoading && (
<section data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3>Hráči</h3>
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
{[1,2,3,4,5,6].map(i => (
<div key={i} className="player-card skeleton" style={{ width: 170, height: 210, borderRadius: 14 }} />
))}
</div>
</section>
)}
{players.length > 0 && isVisible('team', false) && (
<section key={`team-${refreshKey}-${getVariant('team', 'grid')}`} data-element="team" data-variant={getVariant('team', 'grid')} className="players-scroller" aria-labelledby="home-players-heading" style={{ marginTop: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('team') }}>
<div className="section-head">
<h3 id="home-players-heading">Hráči</h3>
<a href="/players" className="see-all">Zobrazit vše <FiArrowRight /></a>
</div>
<div className="scroll-x">
@@ -1691,7 +1810,7 @@ const HomePage: React.FC = () => {
{/* Gallery */}
{isVisible('gallery', false) && (
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('gallery') }}>
<section key={`gallery-${refreshKey}-${getVariant('gallery', 'grid')}`} data-element="gallery" data-variant={getVariant('gallery', 'grid')} aria-labelledby="home-gallery-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('gallery') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1704,7 +1823,7 @@ const HomePage: React.FC = () => {
{/* Videos */}
{isVisible('videos', false) && (
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('videos') }}>
<section key={`videos-${refreshKey}-${getVariant('videos', 'carousel')}`} data-element="videos" data-variant={getVariant('videos', 'carousel')} aria-labelledby="home-videos-heading" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '700px', ...getStyles('videos') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1713,26 +1832,50 @@ const HomePage: React.FC = () => {
variant={(getVariant('videos', 'carousel') as any) as 'grid' | 'carousel'}
/>
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-videos-heading">Videa</h3>
<a href="/videa" className="see-all">Více videí</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 12 }}>
{[1,2,3].map((i) => (
<div key={i} className="card skeleton" style={{ height: 240, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{isVisible('merch', true) && (
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('merch') }}>
<section key={`merch-${refreshKey}-${getVariant('merch', 'grid')}`} data-element="merch" data-variant={getVariant('merch', 'grid')} aria-labelledby="home-merch-heading" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '600px', ...getStyles('merch') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
<MerchSection variant={(getVariant('merch', 'grid') as any) as 'grid' | 'carousel' | 'featured' | 'list'} />
</Suspense>
) : null}
) : (
<>
<div className="section-head">
<h3 id="home-merch-heading">Oblečení týmu</h3>
<a href="/obleceni" className="see-all">Zobrazit vše</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
{[1,2,3,4,5].map((i) => (
<div key={i} className="card skeleton" style={{ height: 180, borderRadius: 12 }} />
))}
</div>
</>
)}
</div>
</section>
)}
{/* Polls / Voting */}
{isVisible('poll', false) && (
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} style={{ marginTop: 32, marginBottom: 32, position: 'relative', ...getStyles('poll') }}>
<section key={`poll-${refreshKey}-${getVariant('poll', 'vertical')}`} data-element="poll" data-variant={getVariant('poll', 'vertical')} aria-label="Anketa" style={{ marginTop: 32, marginBottom: 32, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '500px', ...getStyles('poll') }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
{defer ? (
<Suspense fallback={null}>
@@ -1740,7 +1883,9 @@ const HomePage: React.FC = () => {
<PollsWidget featuredOnly={true} maxPolls={1} title="Anketa" />
</div>
</Suspense>
) : null}
) : (
<div className="card skeleton" style={{ height: 320, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1751,7 +1896,7 @@ const HomePage: React.FC = () => {
{(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 */}
<img src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
<img loading="lazy" decoding="async" src={b.image} alt={b.name} style={{ maxWidth: '100%', width: b.width ? `${b.width}px` : undefined, height: b.height ? `${b.height}px` : 'auto' }} />
</a>
))}
</section>
@@ -1759,13 +1904,15 @@ const HomePage: React.FC = () => {
{/* CTA (Newsletter) moved up */}
{isVisible('newsletter', false) && (
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24, position: 'relative', ...getStyles('newsletter') }}>
<section key={`newsletter-${refreshKey}-${getVariant('newsletter', 'default')}`} data-element="newsletter" data-variant={getVariant('newsletter', 'default')} className="newsletter-cta" aria-label="Přihlášení k newsletteru" style={{ marginTop: 24, marginBottom: 24, position: 'relative', contentVisibility: 'auto' as any, containIntrinsicSize: '420px', ...getStyles('newsletter') }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
{defer ? (
<Suspense fallback={null}>
<NewsletterSubscribe />
</Suspense>
) : null}
) : (
<div className="skeleton" style={{ height: 280, borderRadius: 12 }} />
)}
</div>
</section>
)}
@@ -1830,6 +1977,7 @@ const HomePage: React.FC = () => {
data-element="sponsors"
data-variant={variant}
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
aria-labelledby="home-sponsors-heading"
style={{
width: '100vw',
position: 'relative',
@@ -1839,19 +1987,28 @@ const HomePage: React.FC = () => {
paddingLeft: 'max(16px, calc((100vw - 1200px) / 2))',
paddingRight: 'max(16px, calc((100vw - 1200px) / 2))',
boxSizing: 'border-box',
contentVisibility: 'auto' as any,
containIntrinsicSize: '520px',
...getStyles('sponsors')
}}
>
<div className="section-head">
<h3>Sponzoři</h3>
<h3 id="home-sponsors-heading">Sponzoři</h3>
</div>
{isLoading && ordered.length === 0 && (
<div className="sponsors-grid">
{[1,2,3,4,5,6,7,8].map(i => (
<div key={i} className="sponsor-tile skeleton" style={{ minHeight: 90, borderRadius: 12 }} />
))}
</div>
)}
{variant === 'grid' && (
<>
{general.length > 0 && (
<div className="title-sponsor">
{general.map((g) => (
<a key={`g-${g.id}`} className="sponsor-tile" href={g.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
<img loading="lazy" decoding="async" src={assetUrl(g.logo) || '/images/sponsors/placeholder.png'} alt={g.name} />
</a>
))}
</div>
@@ -1860,7 +2017,7 @@ const HomePage: React.FC = () => {
<div className="sponsors-grid">
{(standard.length > 0 ? standard : (general.length === 0 ? ordered : [])).map((s) => (
<a key={s.id} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1872,7 +2029,7 @@ const HomePage: React.FC = () => {
<div className="track">
{[...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1883,7 +2040,7 @@ const HomePage: React.FC = () => {
<div className="belt">
{[...ordered, ...ordered, ...ordered].map((s, idx) => (
<a key={`${s.id}-${idx}`} className="sponsor-tile" href={s.url || '#'} target="_blank" rel="noreferrer noopener">
<img src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
<img loading="lazy" decoding="async" src={assetUrl(s.logo) || '/images/sponsors/placeholder.png'} alt={s.name} />
</a>
))}
</div>
@@ -1943,11 +2100,8 @@ const HomePage: React.FC = () => {
};
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+2 -5
View File
@@ -130,11 +130,8 @@ function calculateAge(iso: string): number | null {
}
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { useMemo, useState } from 'react';
import { SearchIcon } from '@chakra-ui/icons';
const PlayersPage: React.FC = () => {
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players'], queryFn: () => getPlayers() });
const { data, isLoading, isError } = useQuery<Player[]>({ queryKey: ['players-all'], queryFn: () => getPlayers({ active: false }) });
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
@@ -49,6 +49,7 @@ import { api } from '../../services/api';
// Removed react-datepicker to prevent crash; using native date/time inputs instead
import { getPublicSettings } from '../../services/settings';
import PollLinker from '../../components/admin/PollLinker';
import { useAuth } from '../../contexts/AuthContext';
import FilePreview from '../../components/common/FilePreview';
import { facrApi } from '../../services/facr/facrApi';
import { getCompetitionAliasesPublic } from '../../services/competitionAliases';
@@ -73,6 +74,8 @@ const types: Array<{ value: Event['type']; label: string }> = [
];
const AdminActivitiesPage: React.FC = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const inputBg = useColorModeValue('white', 'gray.700');
@@ -1135,7 +1138,7 @@ const AdminActivitiesPage: React.FC = () => {
{/* Poll Section */}
<Box mt={6} pt={4} borderTopWidth="1px" borderColor={borderColor}>
<Heading size="sm" mb={3}>Anketa</Heading>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker eventId={editing.id} />
) : (
<Box bg={useColorModeValue('blue.50', 'blue.900')} p={4} borderRadius="md" borderWidth="1px" borderColor="blue.200">
+9 -9
View File
@@ -99,15 +99,6 @@ const AdminVideosPage: React.FC = () => {
if (mounted) setAutoLoading(false);
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
@@ -159,6 +150,15 @@ const AdminVideosPage: React.FC = () => {
}
};
const saveOverrides = async () => {
try {
await updateAdminSettings({ videos_title_overrides: titleOverrides } as any);
toast({ status: 'success', title: 'Přepisy uloženy', description: 'Názvy videí byly aktualizovány.', duration: 2500 });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit přepisy názvů.', duration: 3000 });
}
};
const fetchChannelVideos = async () => {
const channel = channelInput?.trim();
if (!channel) {
+13 -4
View File
@@ -11,6 +11,7 @@ import {
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 { useAuth } from '../../contexts/AuthContext';
import { Article, deleteArticle, getArticles, createArticle, updateArticle, uploadFile, CreateArticlePayload, UpdateArticlePayload, getArticleMatchLink, putArticleMatchLink, deleteArticleMatchLink } from '../../services/articles';
import { generateBlogAI } from '../../services/ai';
import { useState, useRef, useCallback, useMemo } from 'react';
@@ -172,6 +173,8 @@ const parseYoutubeVideoId = (raw: string): string => {
};
const ArticlesAdminPage = () => {
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const toast = useToast();
const qc = useQueryClient();
const [page, setPage] = useState(1);
@@ -519,7 +522,8 @@ const ArticlesAdminPage = () => {
try {
// Set cover image immediately
setEditing((prev) => ({ ...(prev as any), image_url: pick.image_url }));
// Persist pick to unified cache (admin)
// Persist pick to unified cache (admin only)
if (isAdmin) {
await putZoneramaPick({
id: pick.id,
album_id: pick.album_id,
@@ -529,6 +533,9 @@ const ArticlesAdminPage = () => {
title: pick.title,
} as any);
toast({ title: 'Obrázek vybrán ze Zonerama', status: 'success' });
} else {
toast({ title: 'Obrázek nastaven', status: 'success' });
}
} catch (e: any) {
toast({ title: 'Uložení výběru selhalo', description: e?.response?.data?.error || e?.message || 'Chyba', status: 'error' });
}
@@ -537,9 +544,11 @@ const ArticlesAdminPage = () => {
// Handle album photo selection for blog content
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
try {
// Save album to cache
// Save album to cache (admins only)
if (isAdmin) {
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
}
// Store album info with article and append images to content
setEditing((prev) => {
@@ -573,7 +582,7 @@ const ArticlesAdminPage = () => {
toast({
title: 'Album přidáno',
description: `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.`,
description: isAdmin ? `${photos.length} fotografií vloženo do článku. Album dostupné také v sekci Média.` : `${photos.length} fotografií vloženo do článku.`,
status: 'success',
duration: 4000
});
@@ -2092,7 +2101,7 @@ const ArticlesAdminPage = () => {
</Text>
</Box>
{editing?.id ? (
{isAdmin && editing?.id ? (
<PollLinker articleId={editing.id} onPollsChanged={() => {
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
+54 -4
View File
@@ -2,7 +2,7 @@ import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
queryFn: adminListUnbanRequests,
});
const bansQ = useQuery({
queryKey: ['admin-comment-bans'],
queryFn: adminListBans,
});
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
const resolveUnbanMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] });
await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] });
toast({ status: 'success', title: 'Vyřízeno' });
},
});
const liftBanMut = useMutation({
mutationFn: (id: number) => adminLiftBan(id),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] }); toast({ status: 'success', title: 'Ban zrušen' }); },
});
React.useEffect(() => {
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
<Tr key={c.id}>
<Td>#{c.id}</Td>
<Td>#{c.user?.id} {c.user?.first_name} {c.user?.last_name}</Td>
<Td><Badge>{c.target_type}</Badge> <Text as="span">{c.target_id}</Text></Td>
<Td>
<Badge mr={2}>{c.target_type}</Badge>
<Text as="span">{c.target_label || c.target_id}</Text>
</Td>
<Td maxW="420px"><Text noOfLines={2}>{c.content}</Text></Td>
<Td>{(c as any).spam_score ? <Badge colorScheme={(c as any).spam_score > 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)}</Badge> : '-'}</Td>
<Td>{(c as any).reports ? <Badge colorScheme={(c as any).reports > 2 ? 'red' : 'yellow'}>{(c as any).reports}</Badge> : '-'}</Td>
@@ -213,7 +230,7 @@ const CommentsAdminPage: React.FC = () => {
{(unbanQ.data?.items || []).map((r) => (
<Tr key={r.id}>
<Td>#{r.id}</Td>
<Td>#{r.user_id}</Td>
<Td>#{r.user?.id} {r.user?.first_name} {r.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{r.user?.email}</Text></Td>
<Td maxW="480px"><Text noOfLines={2}>{r.message}</Text></Td>
<Td><Badge>{r.status}</Badge></Td>
<Td>
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
</Table>
</Box>
<Heading size="sm" mt={6} mb={2}>Zablokovaní uživatelé</Heading>
<Box borderWidth="1px" borderRadius="md" overflowX="auto">
<Table size="sm">
<Thead>
<Tr>
<Th>ID</Th>
<Th>Uživatel</Th>
<Th>Důvod</Th>
<Th>Zabanován</Th>
<Th>Platné do</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{(bansQ.data?.items || []).map((b) => {
const untilText = !b.until ? 'Trvale' : new Date(b.until).toLocaleString();
return (
<Tr key={b.id}>
<Td>#{b.id}</Td>
<Td>#{b.user?.id} {b.user?.first_name} {b.user?.last_name} <Text as="span" color="gray.500" fontSize="sm">{b.user?.email}</Text></Td>
<Td>{b.reason || '-'}</Td>
<Td>{new Date(b.created_at).toLocaleString()}</Td>
<Td>{untilText}</Td>
<Td>
<Button size="xs" variant="outline" onClick={() => liftBanMut.mutate(b.id)}>Zrušit ban</Button>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
{/* Ban modal */}
<Modal isOpen={banModal.isOpen} onClose={banModal.onClose} isCentered>
<ModalOverlay />
+24 -1
View File
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
// Map of competition code -> alias (public aliases)
const [compAliasMap, setCompAliasMap] = useState<Record<string, string>>({});
useEffect(() => {
loadData();
loadSettings();
}, []);
// Load competition aliases map for filtering categories (so alias-named categories are visible)
useEffect(() => {
(async () => {
try {
const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string }>);
const map: Record<string, string> = {};
(aliases || []).forEach((a: any) => {
const code = String(a?.code || '').trim();
const alias = String(a?.alias || '').trim();
if (code && alias) map[code] = alias;
});
setCompAliasMap(map);
} catch {
// ignore
}
})();
}, []);
const loadData = async () => {
setLoading(true);
try {
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
const code = String(comp?.code || '').trim();
const alias = code && compAliasMap[code] ? String(compAliasMap[code]).trim() : '';
if (alias) names.add(alias);
}
return Array.from(names);
} catch {
return [] as string[];
}
}, [facrCompetitions]);
}, [facrCompetitions, compAliasMap]);
const filteredContactCategories = useMemo(() => {
try {
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState<Partial<AdminRewardItem>>({});
// Remove raw JSON editing, keep structured metadata only
const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
</FormControl>
</WrapItem>
<WrapItem>
{batchEnabled && (
<Button size="sm" variant="outline" onClick={batchModal.onOpen}>Dávkové vytvoření</Button>
)}
</WrapItem>
</Wrap>
<HStack align="start" spacing={4}>
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
<FormHelperText>~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč</FormHelperText>
</FormControl>
{(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
<FormControl>
<FormLabel>Sklad</FormLabel>
<NumberInput value={form.stock} min={-1} onChange={(_v, n) => setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
<NumberInputField placeholder="Ks (-1 = neomezeně, 0 = vyprodáno)" />
</NumberInput>
</FormControl>
)}
</HStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<>
<FormControl>
<FormLabel>Obrázek URL</FormLabel>
<Input placeholder="https://.../avatar-1.png" value={form.image_url} onChange={(e) => setForm({ ...form, image_url: e.target.value })} />
<FormHelperText>Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.</FormHelperText>
<FormHelperText>Pro avatar uveďte URL obrázku.</FormHelperText>
</FormControl>
<HStack>
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={(e)=>handleUpload(e.target.files?.[0])} />
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()}>Nahrát obrázek</Button>
</HStack>
</>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" value={meta.valid_from || ''} onChange={(e)=>setMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" value={meta.valid_to || ''} onChange={(e)=>setMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Metadata helpers */}
{form.type === 'merch_coupon' && (
<VStack align="stretch" spacing={2}>
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
<FormLabel>Kód kuponu</FormLabel>
<Input value={meta.coupon_code || ''} onChange={(e)=>setMetaField('coupon_code', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do (ISO nebo datum)</FormLabel>
<Input value={meta.expires_at || ''} onChange={(e)=>setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
</FormControl>
<FormControl>
<FormLabel>Poznámka</FormLabel>
<Input value={meta.note || ''} onChange={(e)=>setMetaField('note', e.target.value)} />
@@ -432,6 +447,7 @@ const EngagementAdminPage: React.FC = () => {
<Button colorScheme="blue" onClick={() => createMut.mutate()} isLoading={createMut.isPending} isDisabled={!form.name.trim()}>Vytvořit</Button>
</HStack>
</VStack>
{(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
<Box>
<Text fontSize="sm" mb={2} color="gray.500">Náhled</Text>
<Box borderWidth="1px" borderRadius="md" p={2}>
@@ -442,6 +458,7 @@ const EngagementAdminPage: React.FC = () => {
)}
</Box>
</Box>
)}
</HStack>
</VStack>
</Box>
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
<Th>Body</Th>
<Th>Sklad</Th>
<Th>Obrázek</Th>
<Th>Platnost</Th>
<Th>Aktivní</Th>
<Th>Akce</Th>
</Tr>
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
</NumberInput>
</Td>
<Td>{r.image_url ? <Image src={r.image_url} alt={r.name} boxSize="40px" objectFit="cover" borderRadius="md" /> : '-'}</Td>
<Td>
{(() => {
const m = (r.metadata || {}) as any;
const vf = m.valid_from ? new Date(m.valid_from) : null;
const vt = m.valid_to ? new Date(m.valid_to) : null;
if (!vf && !vt) return <Text color="gray.500">-</Text>;
return (
<VStack align="start" spacing={0}>
{vf && <Text fontSize="xs">od {vf.toLocaleString()}</Text>}
{vt && <Text fontSize="xs">do {vt.toLocaleString()}</Text>}
</VStack>
);
})()}
</Td>
<Td>
<Switch
isChecked={!!r.active}
@@ -630,7 +662,6 @@ const EngagementAdminPage: React.FC = () => {
{editForm.type === 'merch_coupon' && (
<>
<FormControl><FormLabel>Kód kuponu</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).coupon_code || ''} onChange={(e)=>setEditMetaField('coupon_code', e.target.value)} /></FormControl>
<FormControl><FormLabel>Platnost do</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).expires_at || ''} onChange={(e)=>setEditMetaField('expires_at', e.target.value)} /></FormControl>
<FormControl><FormLabel>Poznámka</FormLabel><Input isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).note || ''} onChange={(e)=>setEditMetaField('note', e.target.value)} /></FormControl>
</>
)}
@@ -665,6 +696,16 @@ const EngagementAdminPage: React.FC = () => {
)}
</VStack>
)}
<VStack align="stretch" spacing={2}>
<FormControl>
<FormLabel>Platnost od</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_from || ''} onChange={(e)=>setEditMetaField('valid_from', e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Platnost do</FormLabel>
<Input type="datetime-local" isDisabled={editItem?.type === 'avatar_upload_unlock'} value={(editMeta as any).valid_to || ''} onChange={(e)=>setEditMetaField('valid_to', e.target.value)} />
</FormControl>
</VStack>
{/* Odstraněno: ruční JSON metadata v editoru. */}
<HStack>
<Text>Aktivní</Text>
@@ -699,7 +740,8 @@ const EngagementAdminPage: React.FC = () => {
</ModalContent>
</Modal>
{/* Batch create modal */}
{/* Batch create modal (hidden) */}
{batchEnabled && (
<Modal isOpen={batchModal.isOpen} onClose={batchModal.onClose} isCentered>
<ModalOverlay />
<ModalContent>
@@ -769,6 +811,7 @@ const EngagementAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
)}
</AdminLayout>
);
};
+93 -2
View File
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const [refreshResult, setRefreshResult] = useState<any>(null);
const [isBulkDeletingUnused, setIsBulkDeletingUnused] = useState(false);
const [isBulkDeletingDuplicates, setIsBulkDeletingDuplicates] = useState(false);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
return full || url;
};
const handleDeleteAllUnused = async () => {
if (unusedFiles.length === 0) return;
const confirmed = window.confirm(`Opravdu chcete smazat ${unusedFiles.length} nepoužívaných souborů? Tuto akci nelze vrátit.`);
if (!confirmed) return;
setIsBulkDeletingUnused(true);
let deleted = 0;
let failed = 0;
for (const f of unusedFiles) {
try {
await deleteFile(f.id, false);
deleted++;
} catch (e) {
failed++;
}
}
setIsBulkDeletingUnused(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Hromadné mazání dokončeno', description: `Smazáno ${deleted} / ${unusedFiles.length}. Chyby: ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
const handleDeleteAllDuplicates = async () => {
if (duplicateGroups.length === 0) return;
const confirmed = window.confirm('Smazat všechny duplicitní soubory bez použití? V každé skupině bude ponechán 1 soubor. Používané soubory budou přeskočeny.');
if (!confirmed) return;
setIsBulkDeletingDuplicates(true);
// Build list of files to delete: in each group keep one (oldest by created_at), delete the rest only if usage_count === 0
type FI = typeof duplicateFiles extends Record<string, infer A> ? A extends Array<infer B> ? B : never : never;
const toDelete: FI[] = [] as any;
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => {
if ((f.usage_count ?? 0) === 0) toDelete.push(f as any);
});
});
let deleted = 0;
let skipped = 0;
let failed = 0;
for (const f of toDelete) {
try {
await deleteFile((f as any).id, false);
deleted++;
} catch (e) {
failed++;
}
}
// Count duplicates with usage to report as skipped
duplicateGroups.forEach(([, files]) => {
if (files.length <= 1) return;
const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
const [, ...rest] = sorted;
rest.forEach(f => { if ((f.usage_count ?? 0) > 0) skipped++; });
});
setIsBulkDeletingDuplicates(false);
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
toast({ title: 'Mazání duplicit dokončeno', description: `Smazáno ${deleted}, přeskočeno (použité) ${skipped}, chyby ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
};
// Mime type options
const mimeTypes = useMemo(() => {
const types = new Set<string>();
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllUnused}
isLoading={isBulkDeletingUnused}
isDisabled={unusedFiles.length === 0}
>
Vymazat vše
</Button>
</HStack>
<Box overflowX="auto" borderWidth="1px" borderRadius="md" borderColor={borderColor}>
<Table size="sm">
<Thead>
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
</AlertDescription>
</Box>
</Alert>
<HStack>
<Spacer />
<Button
leftIcon={<FiTrash2 />}
colorScheme="red"
size="sm"
onClick={handleDeleteAllDuplicates}
isLoading={isBulkDeletingDuplicates}
isDisabled={duplicateGroups.length === 0}
>
Vymazat vše
</Button>
</HStack>
{duplicateGroups.length === 0 ? (
<Box textAlign="center" py={8}>
<Text color="gray.500">Žádné duplicity nenalezeny</Text>
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
try {
// Use the api service which automatically includes authentication
await api.post('/admin/gallery/refresh');
await api.post('/admin/gallery/refresh', {});
toast({
title: 'Galerie obnovena',
+13 -43
View File
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride } from '../../services/adminMatches';
import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
// Read cached FACR club info
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const url = `${origin}/cache/prefetch/facr_club_info.json`;
const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) =>
(Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
);
// Optional: stable sort by date ascending
const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
const formatDisplayDate = (s: string): string => {
const str = String(s || '').trim();
if (!str) return '';
const parseTs = (obj: any): number => {
const s = String(obj?.date_time || obj?.date || '').trim();
if (!s) return Number.MAX_SAFE_INTEGER;
try {
const dt = parse(str, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
const dt = parse(s, FACR_DATE_FMT, new Date());
if (!isNaN(dt.getTime())) return dt.getTime();
} catch {}
const d2 = new Date(str);
if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
return str;
const d2 = new Date(s);
if (!isNaN(d2.getTime())) return d2.getTime();
return Number.MAX_SAFE_INTEGER;
};
items.sort((a, b) => {
const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
return da - db;
});
return items.map((m: any) => ({
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
competition_id: m.competition_id,
home: m.home || m.home_team,
home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
away: m.away || m.away_team,
away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
score: m.score,
venue: m.venue,
home_logo_url: m.home_logo_url,
away_logo_url: m.away_logo_url,
}));
items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
return items;
},
});
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
const saveMutation = useMutation({
mutationFn: async () => {
const externalMatchId: string = selected?.match_id || selected?.id;
const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
{ value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
childrenDroppableId={`admin-children-${item.id}`}
draggableChildPrefix={'admin-child'}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
<Text>Automatické rozesílky</Text>
</HStack>
</HStack>
{/* Weekly schedule detail */}
<Box mt={3} color={textSecondary} fontSize="sm">
{statusData?.weekly_day ? (
<>
<Text>
<b>Týdenní přehled:</b> {statusData?.weekly_enabled ? 'Zapnuto' : 'Vypnuto'}
{statusData?.weekly_enabled ? (
<> — {({sun:'Neděle', mon:'Pondělí', tue:'Úterý', wed:'Středa', thu:'Čtvrtek', fri:'Pátek', sat:'Sobota'} as any)[statusData.weekly_day as any]}
{' '}{String(statusData?.weekly_hour ?? 9).padStart(2,'0')}:00</>
) : null}
</Text>
{statusData?.weekly_next_scheduled ? (
<Text>
<b>Příští týdenní odeslání:</b> {format(new Date(statusData.weekly_next_scheduled), 'd. M. yyyy HH:mm', { locale: cs })}
</Text>
) : null}
</>
) : null}
</Box>
{statusData?.next_approximate ? (
<Text color="gray.600" fontSize="sm" mt={2}>
Další automatický newsletter za {(() => {
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
function czYears(n: number): string {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 14) return 'let';
const mod10 = n % 10;
if (mod10 === 1) return 'rok';
if (mod10 >= 2 && mod10 <= 4) return 'roky';
if (n === 1) return 'rok';
if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
@@ -27,11 +27,14 @@ import {
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
const { user } = useAuth();
const isAdmin = (user as any)?.role === 'admin';
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
};
return (
<AdminLayout>
<AdminLayout requireAdmin={false}>
<Box>
<HStack justify="space-between" mb={4}>
<Text fontSize="xl" fontWeight="bold">Zkrácené odkazy</Text>
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
<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 }); }} />
{isAdmin && (
<IconButton aria-label="Statistiky" icon={<FiBarChart2 />} onClick={()=> openStats(it)} />
)}
</HStack>
</Td>
</Tr>
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
</Table>
</Box>
{/* Stats modal */}
{/* Stats modal (admins only) */}
{isAdmin && (
<Modal isOpen={statsModal.isOpen} onClose={statsModal.onClose} size="xl">
<ModalOverlay />
<ModalContent>
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
)}
</Box>
</AdminLayout>
);
+103 -87
View File
@@ -35,6 +35,11 @@ import {
Divider,
Image,
FormHelperText,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
const [form, setForm] = useState<any>(defaultForm);
const [editing, setEditing] = useState<Sweepstake | null>(null);
// Prizes modal state
const prizesDisc = useDisclosure();
const [prizeSweep, setPrizeSweep] = useState<Sweepstake | null>(null);
// Prizes state (integrated tab)
const [prizes, setPrizes] = useState<SweepstakePrize[]>([]);
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
const [savingPrize, setSavingPrize] = useState<boolean>(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const rulesInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<number>(0);
const [coverPreview, setCoverPreview] = useState<string>('');
const onUploadImage = async (file?: File | null) => {
if (!file) return;
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
};
const openPrizes = async (it: Sweepstake) => {
try {
setPrizeSweep(it);
prizesDisc.onOpen();
const list = await adminListPrizes(it.id);
setPrizes(list);
} catch {
setPrizes([]);
}
openEdit(it);
setActiveTab(2);
try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
};
const addPrize = async () => {
if (!prizeSweep) return;
if (!editing) { toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' }); return; }
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
try {
setSavingPrize(true);
await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
await adminCreatePrize(editing.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
setPrizes(await adminListPrizes(prizeSweep.id));
setPrizes(await adminListPrizes(editing.id));
} catch (e:any) {
toast({ status: 'error', title: 'Nelze uložit výhru' });
} finally {
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
};
const delPrize = async (p: SweepstakePrize) => {
if (!prizeSweep) return;
if (!editing) return;
if (!window.confirm('Smazat výhru?')) return;
await adminDeletePrize(prizeSweep.id, p.id as any);
setPrizes(await adminListPrizes(prizeSweep.id));
await adminDeletePrize(editing.id, p.id as any);
setPrizes(await adminListPrizes(editing.id));
};
const movePrize = async (idx: number, dir: -1 | 1) => {
if (!prizeSweep) return;
if (!editing) return;
const arr = [...prizes];
const ni = idx + dir;
if (ni < 0 || ni >= arr.length) return;
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
arr[idx] = arr[ni];
arr[ni] = tmp;
setPrizes(arr);
await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
await adminReorderPrizes(editing.id, arr.map(p => p.id as any));
};
useEffect(() => { load(); }, [status]);
const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
const openEdit = (it: Sweepstake) => {
setEditing(it);
setForm({
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
setActiveTab(0);
onOpen();
(async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
};
const save = async () => {
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
if (editing) {
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
} else {
await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno' });
}
onClose();
await load();
} else {
const created = await adminCreateSweepstake(payload);
toast({ status: 'success', title: 'Vytvořeno', description: 'Nyní můžete přidat výhry' });
setEditing(created);
setActiveTab(2);
try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
await load();
}
} catch (e: any) {
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
}
@@ -325,13 +331,21 @@ const SweepstakesAdminPage: React.FC = () => {
</Box>
)}
{/* Create/Edit Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
{/* Create/Edit Modal with tabs */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Tabs index={activeTab} onChange={setActiveTab as any} isFitted>
<TabList>
<Tab>Základní</Tab>
<Tab>Termíny a limity</Tab>
<Tab>Výhry</Tab>
</TabList>
<TabPanels>
<TabPanel>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>Název</FormLabel>
@@ -341,6 +355,40 @@ const SweepstakesAdminPage: React.FC = () => {
<FormLabel>Popis</FormLabel>
<Textarea value={form.description} onChange={(e)=>setForm({ ...form, description: e.target.value })} />
</FormControl>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Image src={coverPreview || form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={async (e)=>{ const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
</Button>
{form.image_url && (<Button size="sm" variant="ghost" onClick={()=>{ setForm((prev:any)=>({ ...prev, image_url: '' })); setCoverPreview(''); }}>Odebrat</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</VStack>
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
{form.rules_url && (<Button as={RouterLink} to={form.rules_url} target="_blank" rel="noreferrer noopener" variant="ghost">Otevřít</Button>)}
</HStack>
<Input placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</VStack>
</FormControl>
</SimpleGrid>
</VStack>
</TabPanel>
<TabPanel>
<VStack spacing={4} align="stretch">
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Začátek</FormLabel>
@@ -367,25 +415,6 @@ const SweepstakesAdminPage: React.FC = () => {
<FormHelperText>Max. 100 výherců</FormHelperText>
</FormControl>
</SimpleGrid>
<HStack>
<Button variant="outline" onClick={()=> editing ? openPrizes(editing) : toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' })}>Upravit výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<SimpleGrid columns={3} spacing={4}>
<FormControl>
<FormLabel>Vstupné (body)</FormLabel>
@@ -400,49 +429,30 @@ const SweepstakesAdminPage: React.FC = () => {
</NumberInput>
</FormControl>
</SimpleGrid>
<SimpleGrid columns={2} spacing={4}>
<FormControl>
<FormLabel>Titulní obrázek</FormLabel>
<HStack>
<Image src={form.image_url || '/dist/img/logo-club-empty.svg'} alt="cover" boxSize="80px" objectFit="cover" borderRadius="md" />
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát
<Input ref={imageInputRef} type="file" display="none" accept="image/*" onChange={(e)=>onUploadImage(e.target.files?.[0])} />
</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.image_url} onChange={(e)=>setForm({ ...form, image_url: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Pravidla</FormLabel>
<HStack>
<Button as="label" leftIcon={<FiUpload />} variant="outline">
Nahrát PDF/obrázek
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
</Button>
<Button variant="outline" onClick={onCreateRulesArticle}>Vytvořit stránku</Button>
</HStack>
<Input mt={2} placeholder="nebo vložte URL" value={form.rules_url} onChange={(e)=>setForm({ ...form, rules_url: e.target.value })} />
</FormControl>
</SimpleGrid>
</VStack>
</ModalBody>
<ModalFooter>
<HStack>
<Button onClick={onClose} variant="ghost">Zavřít</Button>
<Button colorScheme="blue" onClick={save}>Uložit</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Prizes Modal */}
<Modal isOpen={prizesDisc.isOpen} onClose={prizesDisc.onClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výhry – {prizeSweep?.title}</ModalHeader>
<ModalCloseButton />
<ModalBody>
</TabPanel>
<TabPanel>
<VStack align="stretch" spacing={3}>
<HStack>
<Button size="sm" onClick={()=>setActiveTab(0)} variant="outline">Zpět na základní</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Hlavní výhra', quantity: 1 }); toast({ status:'success', title:'Přidáno: Hlavní výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhru' }); }
}}>1× Hlavní výhra</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: 'Menší výhra', quantity: 3 }); toast({ status:'success', title:'Přidáno: 3× Menší výhra' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat výhry' }); }
}}>3× Menší výhry</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '100 bodů', kind:'points', points: 100, quantity: 10 }); toast({ status:'success', title:'Přidáno: 10× 100 bodů' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat body' }); }
}}>10× 100 bodů</Button>
<Button size="sm" onClick={async ()=>{
if (!editing) { toast({ status:'info', title:'Uložte soutěž pro přidání výher' }); return; }
try { await adminCreatePrize(editing.id, { name: '500 XP', kind:'xp', xp: 500, quantity: 5 }); toast({ status:'success', title:'Přidáno: 5× 500 XP' }); setPrizes(await adminListPrizes(editing.id)); } catch { toast({ status:'error', title:'Nelze přidat XP' }); }
}}>5× 500 XP</Button>
</HStack>
<Divider />
{prizes.length === 0 && <Text color="gray.500">Zatím žádné výhry</Text>}
{prizes.map((p, i) => (
<HStack key={p.id} spacing={2} borderWidth="1px" borderRadius="md" p={2}>
@@ -518,9 +528,15 @@ const SweepstakesAdminPage: React.FC = () => {
<Button leftIcon={<AddIcon />} colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat</Button>
</HStack>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
<ModalFooter>
<Button onClick={prizesDisc.onClose}>Zavřít</Button>
<HStack>
<Button onClick={onClose} variant="ghost">Zavřít</Button>
<Button colorScheme="blue" onClick={save}>Uložit</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
+54 -15
View File
@@ -63,6 +63,8 @@ function normalize(s: string): string {
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
out = out.replace(/\bn\.?\b/g, ' nad ');
out = out.replace(/\bp\.?\b/g, ' pod ');
// 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, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000,
});
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
// Lowercase-key index for robust UUID lookups irrespective of source casing
const overridesByIdLC = useMemo(() => {
const m: Record<string, { name?: string; logo_url?: string }> = {};
try {
for (const [k, v] of Object.entries(overridesById)) {
m[String(k).toLowerCase()] = v as any;
}
} catch {}
return m;
}, [overridesById]);
// Build an index by normalized team name for overrides that carry an ID
const overridesNameIndex = useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
if (r.team_id) teamIds.add(r.team_id);
if (r.team_id) teamIds.add(String(r.team_id).toLowerCase());
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
const u = String(overridesById[teamId].logo_url);
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
const u = String(overridesByIdLC[tid].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
}
// Priority 2: logoapi.sportcreative.eu if we have a team ID
if (teamId && sportLogosMap[teamId]) {
return sportLogosMap[teamId];
if (tid && sportLogosMap[tid]) {
return sportLogosMap[tid];
}
// Priority 3: FACR original
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
};
const getName = (teamName?: string, teamId?: string) => {
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
const tid = teamId ? String(teamId).toLowerCase() : '';
if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.name) {
return String(overridesByIdLC[tid].name || '').trim() || String(teamName || '');
}
// If no ID, but override exists for the normalized name, use canonical override name
try {
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
for (const r of rows) {
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
if (teamId) teamId = String(teamId).toLowerCase();
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
const onSave = useMutation({
mutationFn: async () => {
if (!form.external_team_id) {
let extTeamId = (form.external_team_id || '').trim();
if (!extTeamId) {
let derived: string | undefined = undefined;
try { derived = deriveTeamIdFromLogoUrl(form.logo_url); } catch {}
if (!derived && selected?.teamLogoUrl) {
try { derived = deriveTeamIdFromLogoUrl(selected.teamLogoUrl); } catch {}
}
if (!derived) {
const primaryNameTry = (form.team_name || selected?.teamName || '').trim();
if (primaryNameTry) {
try {
const results = await searchClubs(primaryNameTry);
const norm = (s: string) => normalize(s);
const exact = results.find(r => norm(r.name) === norm(primaryNameTry));
const pick = exact || results[0];
if (pick?.id) derived = String(pick.id);
} catch {}
}
}
if (derived) {
extTeamId = derived;
}
}
if (!extTeamId) {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
.filter(Boolean);
// 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 (!uploadedFile && extTeamId) {
const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
extTeamId,
logoFileToUpload,
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
filename: `${extTeamId}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
@@ -532,7 +570,7 @@ const TeamsAdminPage = () => {
}
}
await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
await putTeamLogoOverride(extTeamId, primaryName, logoUrl);
return true;
},
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
<Td isNumeric py={1.5} fontSize="xs" fontWeight="bold">{r.points}</Td>
<Td py={1.5}>
<Button size="xs" fontSize="xs" onClick={() => {
const tid = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tidRaw = ((r as any).team_id as any) || deriveTeamIdFromLogoUrl(r.team_logo_url);
const tid = tidRaw ? String(tidRaw).toLowerCase() : undefined;
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);
@@ -821,6 +821,15 @@ html {
100% { transform: translateX(-33.333%); }
}
/* Reduce motion preferences: disable continuous marquee-style animations */
@media (prefers-reduced-motion: reduce) {
.sponsors-slider .track,
.sponsors-scroller .belt,
.matches-slider.matches-ticker .ticker-belt {
animation: none !important;
}
}
/* Matches slider */
.matches-slider { margin: 12px 0 20px; }
.matches-slider .matches-grid {
+17
View File
@@ -29,6 +29,15 @@ export type CommentBan = {
reason?: string;
until?: string | null;
created_at: string;
created_by_id?: number;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListBans(): Promise<{ items: CommentBan[] }>{
@@ -49,6 +58,14 @@ export type UnbanRequest = {
created_at: string;
resolved_by_id?: number | null;
resolved_at?: string | null;
user?: {
id: number;
first_name?: string;
last_name?: string;
email?: string;
role?: string;
username?: string;
};
};
export async function adminListUnbanRequests(): Promise<{ items: UnbanRequest[] }>{
+10
View File
@@ -84,6 +84,16 @@ export interface NewsletterStatus {
interval_minutes: number;
next_approximate: string;
newsletter_enabled?: boolean;
// Scheduling detail (optional; provided by backend)
weekly_enabled?: boolean;
weekly_day?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
weekly_hour?: number;
weekly_next_scheduled?: string;
matches_enabled?: boolean;
reminder_lead_hours?: number;
results_enabled?: boolean;
quiet_start?: number;
quiet_end?: number;
}
export const getNewsletterStatus = async (): Promise<NewsletterStatus> => {
+1
View File
@@ -6,6 +6,7 @@ export type CommentItem = {
id: number;
target_type: TargetType;
target_id: string;
target_label?: string;
parent_id?: number | null;
content: string;
status?: 'visible' | 'hidden';
+8 -1
View File
@@ -36,8 +36,15 @@ export async function createPublicShortLink(payload: { target_url: string; title
}
export async function listShortLinks(): Promise<{ items: any[] }> {
const res = await api.get<{ items: any[] }>('/admin/shortlinks');
// Prefer editor-accessible endpoint
try {
const res = await api.get<{ items: any[] }>('/shortlinks');
return res.data;
} catch (e) {
// Fallback to admin endpoint (admins only)
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
return res2.data;
}
}
export async function getShortLinkStats(id: number | string): Promise<any> {
+1 -1
View File
@@ -121,7 +121,7 @@ func LoadConfig() {
// File upload settings
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
MaxUploadSize: int64(getEnvAsInt("MAX_UPLOAD_SIZE", 10)) * 1024 * 1024, // 10MB default
MaxUploadSize: int64(getEnvAsInt("MAX_UPLOAD_SIZE", 20)) * 1024 * 1024, // 20MB default
AllowedMimeTypes: []string{
// Images
"image/jpeg",
+225 -61
View File
@@ -72,7 +72,65 @@ func generateTeamNameAliases(name string) []string {
es := abbreviateAmpersand(e)
if es != "" && es != base && es != t && es != e { add(es) }
variants := []string{t, s, e, es}
// Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod)
makePNAbbrevs := func(s string) []string {
if strings.TrimSpace(s) == "" { return nil }
// Build variants for "nad <Word>" / "pod <Word>" ->
// n. W., n.W., n. W, n.W (and p. analogs)
mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string {
return re.ReplaceAllStringFunc(in, func(m string) string {
sub := re.FindStringSubmatch(m)
if len(sub) < 2 { return m }
letter := firstRuneUpper(sub[1])
if letter == "" { return m }
if withFinalDot {
if withSpace { return repPrefix + " " + letter + "." }
return repPrefix + letter + "."
}
if withSpace { return repPrefix + " " + letter }
return repPrefix + letter
})
}
// spaced + with dot
a := mk(s, rePNNadWord, "n.", true, true)
a = mk(a, rePNPodWord, "p.", true, true)
// no space + with dot
b := mk(s, rePNNadWord, "n.", true, false)
b = mk(b, rePNPodWord, "p.", true, false)
// spaced + without final dot
c := mk(s, rePNNadWord, "n.", false, true)
c = mk(c, rePNPodWord, "p.", false, true)
// no space + without final dot
d := mk(s, rePNNadWord, "n.", false, false)
d = mk(d, rePNPodWord, "p.", false, false)
// collect distinct, non-empty, changed variants
seen := map[string]struct{}{}
out := []string{}
addv := func(x string) {
x = strings.TrimSpace(x)
if x == "" || x == s { return }
if _, ok := seen[x]; ok { return }
seen[x] = struct{}{}
out = append(out, x)
}
addv(a); addv(b); addv(c); addv(d)
return out
}
for _, v := range []string{t, e} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
// Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...)
st := stripOrgPrefixes(t)
se := stripOrgPrefixes(e)
if st != "" && st != t { add(st) }
if se != "" && se != e { add(se) }
// PN abbreviations for stripped versions as well
for _, v := range []string{st, se} {
for _, p := range makePNAbbrevs(v) { add(p) }
}
variants := []string{t, s, e, es, st, se}
for _, v := range variants {
if strings.TrimSpace(v) == "" { continue }
nd := strings.ReplaceAll(v, ".", "")
@@ -84,7 +142,7 @@ func generateTeamNameAliases(name string) []string {
return out
}
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$`)
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`)
func trimLegalSuffixes(s string) string {
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
@@ -96,6 +154,25 @@ var (
reMultiSpace = regexp.MustCompile(`\s+`)
)
var (
rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`)
rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`)
)
// Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ...
var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`)
func stripOrgPrefixes(s string) string {
x := strings.TrimSpace(s)
if x == "" { return x }
for {
nx := reLeadingOrg.ReplaceAllString(x, "")
nx = strings.TrimSpace(nx)
if nx == x || nx == "" { return nx }
x = nx
}
}
func expandPNAbbrev(s string) string {
if s == "" { return s }
x := reAbbrevP.ReplaceAllString(s, "pod ")
@@ -667,14 +744,15 @@ func (bc *BaseController) GetStandings(c *gin.Context) {
if err := bc.DB.Find(&tlovs).Error; err == nil {
tloByID := map[string]models.TeamLogoOverride{}
for _, it := range tlovs {
tloByID[it.ExternalTeamID] = it
if it.ExternalTeamID == "" { continue }
tloByID[strings.ToLower(it.ExternalTeamID)] = it
}
for i := range rows {
id, _ := rows[i]["team_id"].(string)
if id == "" {
continue
}
if tlo, ok := tloByID[id]; ok {
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
if strings.TrimSpace(tlo.TeamName) != "" {
rows[i]["team"] = tlo.TeamName
}
@@ -2059,23 +2137,95 @@ func computeEstimatedReadMinutes(html string) int {
return minutes
}
// GetAdminMatches returns cached matches merged with DB overrides (admin only)
// GetAdminMatches returns cached FACR matches merged with DB overrides (admin only)
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
// Read cached events
p := filepath.Join("cache", "prefetch", "events_upcoming.json")
// Read cached FACR club info (contains competitions with matches)
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
f, err := os.Open(p)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
return
}
defer f.Close()
var matches []map[string]interface{}
if err := json.NewDecoder(f).Decode(&matches); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
var facr struct {
Competitions []struct {
ID string `json:"id"`
Name string `json:"name"`
Matches []struct {
MatchID string `json:"match_id"`
DateTime string `json:"date_time"`
Date string `json:"date"`
Time string `json:"time"`
Home string `json:"home"`
HomeTeam string `json:"home_team"`
HomeID string `json:"home_id"`
HomeTeamID string `json:"home_team_id"`
HomeTeamFACRID string `json:"home_team_facr_id"`
Away string `json:"away"`
AwayTeam string `json:"away_team"`
AwayID string `json:"away_id"`
AwayTeamID string `json:"away_team_id"`
AwayTeamFACRID string `json:"away_team_facr_id"`
Score string `json:"score"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.NewDecoder(f).Decode(&facr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"})
return
}
// Load overrides
// Helper to pick first non-empty string
firstNonEmpty := func(ss ...string) string {
for _, s := range ss {
s = strings.TrimSpace(s)
if s != "" {
return s
}
}
return ""
}
// Flatten and normalize to a simple slice of maps
items := make([]map[string]any, 0, 256)
for _, comp := range facr.Competitions {
for _, m := range comp.Matches {
id := strings.TrimSpace(m.MatchID)
if id == "" {
continue
}
home := firstNonEmpty(m.Home, m.HomeTeam)
away := firstNonEmpty(m.Away, m.AwayTeam)
homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID)
awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID)
item := map[string]any{
"id": id,
"match_id": id,
"date_time": strings.TrimSpace(m.DateTime),
"date": strings.TrimSpace(m.Date),
"time": strings.TrimSpace(m.Time),
"competitionName": strings.TrimSpace(comp.Name),
"competition_id": strings.TrimSpace(comp.ID),
"home": home,
"home_team": home,
"home_id": homeID,
"away": away,
"away_team": away,
"away_id": awayID,
"score": strings.TrimSpace(m.Score),
"venue": strings.TrimSpace(m.Venue),
"home_logo_url": strings.TrimSpace(m.HomeLogoURL),
"away_logo_url": strings.TrimSpace(m.AwayLogoURL),
}
items = append(items, item)
}
}
// Load overrides and apply
var movs []models.MatchOverride
if err := bc.DB.Find(&movs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
@@ -2096,16 +2246,8 @@ func (bc *BaseController) GetAdminMatches(c *gin.Context) {
tloByTeam[t.ExternalTeamID] = t
}
// Apply overrides in-place
for _, m := range matches {
// External match ID
var matchID string
if v, ok := m["match_id"].(string); ok {
matchID = v
} else if v2, ok2 := m["id"].(string); ok2 {
matchID = v2
}
for _, m := range items {
matchID, _ := m["match_id"].(string)
if ov, ok := movByID[matchID]; ok {
if ov.HomeNameOverride != nil {
m["home"] = *ov.HomeNameOverride
@@ -2121,6 +2263,7 @@ func (bc *BaseController) GetAdminMatches(c *gin.Context) {
if ov.DateTimeOverride != nil {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
m["time"] = ov.DateTimeOverride.Format("15:04")
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
@@ -2129,31 +2272,31 @@ func (bc *BaseController) GetAdminMatches(c *gin.Context) {
m["away_logo_url"] = *ov.AwayLogoURL
}
}
// Team-logo overrides by team id
if homeID, ok := m["home_id"].(string); ok {
if homeID, _ := m["home_id"].(string); homeID != "" {
if tlo, ok := tloByTeam[homeID]; ok {
if tlo.LogoURL != "" {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["home_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
if strings.TrimSpace(tlo.TeamName) != "" {
m["home"] = tlo.TeamName
m["home_team"] = tlo.TeamName
}
}
}
if awayID, ok := m["away_id"].(string); ok {
if awayID, _ := m["away_id"].(string); awayID != "" {
if tlo, ok := tloByTeam[awayID]; ok {
if tlo.LogoURL != "" {
if strings.TrimSpace(tlo.LogoURL) != "" {
m["away_logo_url"] = tlo.LogoURL
}
if tlo.TeamName != "" {
if strings.TrimSpace(tlo.TeamName) != "" {
m["away"] = tlo.TeamName
m["away_team"] = tlo.TeamName
}
}
}
}
c.JSON(http.StatusOK, matches)
c.JSON(http.StatusOK, items)
}
// --- Admin: Match & Team Logo Overrides ---
@@ -2411,6 +2554,8 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return
}
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
go bc.writeTeamLogoOverridesCache()
c.JSON(http.StatusOK, item)
}
@@ -4841,9 +4986,11 @@ func (bc *BaseController) DeleteCategory(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
// Successful deletion
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
// UploadImage handles generic file uploads (images, documents, archives)
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
@@ -4857,45 +5004,69 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
}
name := strings.TrimSpace(f.Filename)
ext := strings.ToLower(filepath.Ext(name))
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true}
// Allow images, PDFs, Office docs, text, archives, and common media
allowed := map[string]bool{
// Images
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
// Documents
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
// Archives
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
// Media
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
}
if !allowed[ext] {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
return
}
// Light content sniffing to ensure the uploaded payload matches the declared extension
// and basic SVG sanitization (reject obvious script/event patterns).
// Light content sniffing to ensure payload matches extension and sanitize SVGs
if src, err := f.Open(); err == nil {
defer src.Close()
buf := make([]byte, 2048)
n, _ := src.Read(buf)
_ = src.Close()
detected := http.DetectContentType(buf[:n])
n, _ := io.ReadFull(src, buf)
if n < 0 { n = 0 }
dl := strings.ToLower(http.DetectContentType(buf[:n]))
validCT := false
switch ext {
case ".pdf":
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
case ".svg":
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
lower := strings.ToLower(string(buf[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
case ".docx", ".xlsx", ".pptx":
validCT = strings.Contains(dl, "officedocument") || strings.Contains(dl, "application/zip") || dl == "application/octet-stream"
case ".doc", ".xls", ".ppt":
validCT = strings.Contains(dl, "msword") || strings.Contains(dl, "vnd.ms-") || dl == "application/octet-stream"
case ".zip":
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
case ".rar":
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
case ".7z":
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
case ".tar":
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
case ".gz":
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
case ".txt", ".csv":
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
case ".mp4", ".avi", ".mov":
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
case ".mp3", ".wav":
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
default:
validCT = strings.HasPrefix(detected, "image/")
validCT = strings.HasPrefix(dl, "image/")
}
if !validCT {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
return
}
}
// Additional SVG content check against common script vectors
if ext == ".svg" {
if src2, err := f.Open(); err == nil {
defer src2.Close()
check := make([]byte, 65536)
n, _ := io.ReadFull(src2, check)
lower := strings.ToLower(string(check[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
@@ -4922,16 +5093,12 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
// Take the first value if comma-separated
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" {
host = h
if h != "" { host = h }
}
}
}
// Append forwarded port when host has no explicit port and it's non-default
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
@@ -4941,11 +5108,8 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{
// Always return a backend-relative path for storage
"url": urlPath,
// Convenience absolute URL for immediate usage in UIs
"absolute_url": absolute,
// Basic metadata (best-effort)
"name": outName,
"type": mimeType,
"size": f.Size,
+174 -8
View File
@@ -5,9 +5,12 @@ import (
"strings"
"time"
"encoding/json"
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -24,7 +27,54 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load bans"}); return
}
c.JSON(http.StatusOK, gin.H{"items": bans})
// Load users
uids := make([]uint, 0, len(bans))
seen := map[uint]bool{}
for _, b := range bans { if !seen[b.UserID] { uids = append(uids, b.UserID); seen[b.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type banOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Reason string `json:"reason"`
Until *time.Time `json:"until"`
CreatedAt time.Time `json:"created_at"`
CreatedByID uint `json:"created_by_id"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]banOut, 0, len(bans))
for _, b := range bans {
o := banOut{ ID: b.ID, UserID: b.UserID, Reason: b.Reason, Until: b.Until, CreatedAt: b.CreatedAt, CreatedByID: b.CreatedByID }
if u, ok := users[b.UserID]; ok {
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
}
if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: lift a ban early by setting until = now
@@ -74,11 +124,12 @@ func (cc *CommentController) React(c *gin.Context) {
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": r.Type, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
@@ -140,8 +191,71 @@ func (cc *CommentController) AdminList(c *gin.Context) {
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
// Prepare target labels (titles) for admin visibility: articles and events
articleIDs := make([]uint, 0)
eventIDs := make([]uint, 0)
for _, it := range items {
switch it.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
articleIDs = append(articleIDs, uint(v))
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
eventIDs = append(eventIDs, uint(v))
}
}
}
articleTitleByID := map[uint]string{}
if len(articleIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("articles").Select("id, title").Where("id IN ?", articleIDs).Scan(&rows).Error
for _, r := range rows { articleTitleByID[r.ID] = r.Title }
}
eventTitleByID := map[uint]string{}
if len(eventIDs) > 0 {
type row struct{ ID uint; Title string }
var rows []row
_ = cc.DB.Table("events").Select("id, title").Where("id IN ?", eventIDs).Scan(&rows).Error
for _, r := range rows { eventTitleByID[r.ID] = r.Title }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; if adminLiked[r.ID] { co.AdminLiked = true }; out = append(out, co) }
for _, r := range items {
co := toOutput(r)
if v, ok := repCounts[r.ID]; ok { co.Reports = v }
if adminLiked[r.ID] { co.AdminLiked = true }
// Compose human label for target
switch r.TargetType {
case "article":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := articleTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Článek: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Článek #%s", r.TargetID)
}
} else {
co.TargetLabel = "Článek"
}
case "event":
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
if t, ok := eventTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
co.TargetLabel = fmt.Sprintf("Aktivita: %s (#%s)", t, r.TargetID)
} else {
co.TargetLabel = fmt.Sprintf("Aktivita #%s", r.TargetID)
}
} else {
co.TargetLabel = "Aktivita"
}
case "gallery_album":
co.TargetLabel = fmt.Sprintf("Galerie album #%s", r.TargetID)
case "youtube_video":
co.TargetLabel = fmt.Sprintf("YouTube video %s", r.TargetID)
default:
co.TargetLabel = fmt.Sprintf("%s #%s", r.TargetType, r.TargetID)
}
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
@@ -181,9 +295,60 @@ func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
// Only pending requests
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
_ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error
// Load users and usernames
uids := make([]uint, 0, len(items))
seen := map[uint]bool{}
for _, it := range items { if !seen[it.UserID] { uids = append(uids, it.UserID); seen[it.UserID] = true } }
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
users := map[uint]userRow{}
if len(uids) > 0 {
var rows []userRow
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
for _, r := range rows { users[r.ID] = r }
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
var profs []prof
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
}
type unbanOut struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Message string `json:"message"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ResolvedByID *uint `json:"resolved_by_id,omitempty"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
User struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
Username string `json:"username,omitempty"`
} `json:"user"`
}
out := make([]unbanOut, 0, len(items))
for _, it := range items {
var u userRow
if r, ok := users[it.UserID]; ok { u = r }
o := unbanOut{
ID: it.ID, UserID: it.UserID, Message: it.Message, Status: it.Status, CreatedAt: it.CreatedAt, ResolvedByID: it.ResolvedByID, ResolvedAt: it.ResolvedAt,
}
o.User.ID = u.ID
o.User.FirstName = u.FirstName
o.User.LastName = u.LastName
o.User.Email = u.Email
o.User.Role = u.Role
if v, ok := usernameByID[it.UserID]; ok { o.User.Username = v }
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
}
// Admin: resolve unban request
@@ -218,6 +383,7 @@ type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
TargetLabel string `json:"target_label,omitempty"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
+139 -8
View File
@@ -33,16 +33,93 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Pagination
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
if page <= 0 { page = 1 }
if limit <= 0 || limit > 200 { limit = 50 }
items, total, err := models.GetContactMessages(cc.DB, page, limit)
if err != nil {
if page <= 0 {
page = 1
}
if limit <= 0 || limit > 200 {
limit = 50
}
// Filters
search := strings.TrimSpace(c.DefaultQuery("search", ""))
isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", ""))
var isRead *bool
if isReadParam != "" {
v := strings.ToLower(isReadParam)
if v == "true" || v == "1" {
t := true
isRead = &t
} else if v == "false" || v == "0" {
f := false
isRead = &f
}
}
// Sorting (map UI fields to DB columns)
sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt"))
sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc")))
if sortOrder != "asc" {
sortOrder = "desc"
}
sortField := "created_at"
switch sortBy {
case "name":
sortField = "name"
case "email":
sortField = "email"
case "subject":
sortField = "subject"
case "createdAt", "created_at":
sortField = "created_at"
}
// Build query
q := cc.DB.Model(&models.ContactMessage{})
if search != "" {
s := "%" + strings.ToLower(search) + "%"
q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s)
}
if isRead != nil {
q = q.Where("is_read = ?", *isRead)
}
// Count total
var total int64
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": limit})
// Fetch page
var items []models.ContactMessage
offset := (page - 1) * limit
if err := q.Order(sortField+" "+sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
return
}
// Compute pages (ceil)
pages := 1
if limit > 0 {
pages = (int(total) + limit - 1) / limit
if pages == 0 {
pages = 1
}
}
c.JSON(http.StatusOK, gin.H{
"data": items,
"pagination": gin.H{
"total": total,
"page": page,
"limit": limit,
"pages": pages,
},
})
}
// MarkMessageAsRead marks a message as read (admin)
@@ -75,20 +152,37 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
enabled := active > 0
// Persist flag
// Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults
var s models.Settings
_ = cc.DB.First(&s).Error
if s.ID == 0 {
s = models.Settings{}
}
changed := false
if s.NewsletterEnabled != enabled {
s.NewsletterEnabled = enabled
changed = true
}
if enabled {
// Auto-activate weekly digest and preset schedule if not configured
if !s.EnableWeekly {
s.EnableWeekly = true
changed = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
changed = true
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
} else {
} else if changed {
_ = cc.DB.Save(&s).Error
}
}
// Update runtime
if config.AppConfig != nil {
@@ -736,6 +830,8 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
var total, active int64
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
var s models.Settings
_ = cc.DB.First(&s).Error
var subs []models.NewsletterSubscription
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
sample := make([]string, 0, len(subs))
@@ -751,6 +847,31 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
}
}
next := time.Now().Add(interval)
// Compute next scheduled weekly time (exact), using settings (default Sun 09:00)
weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay))
if weeklyDay == "" { weeklyDay = "sun" }
weeklyHour := s.NewsletterWeeklyHour
if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 }
// find next occurrence
now := time.Now()
target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location())
toWD := func(d string) time.Weekday {
switch d {
case "mon": return time.Monday
case "tue": return time.Tuesday
case "wed": return time.Wednesday
case "thu": return time.Thursday
case "fri": return time.Friday
case "sat": return time.Saturday
default: return time.Sunday
}
}
for i := 0; i < 8; i++ {
if target.Weekday() == toWD(weeklyDay) && target.After(now) {
break
}
target = target.Add(24 * time.Hour)
}
c.JSON(http.StatusOK, gin.H{
"total_subscribers": total,
"active_subscribers": active,
@@ -758,6 +879,16 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
"interval_minutes": int(interval.Minutes()),
"next_approximate": next,
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
// Scheduling detail
"weekly_enabled": s.EnableWeekly,
"weekly_day": weeklyDay,
"weekly_hour": weeklyHour,
"weekly_next_scheduled": target,
"matches_enabled": s.EnableMatchReminders,
"reminder_lead_hours": s.NewsletterReminderLeadHours,
"results_enabled": s.EnableResults,
"quiet_start": s.NewsletterQuietStart,
"quiet_end": s.NewsletterQuietEnd,
})
}
+69 -1
View File
@@ -21,6 +21,29 @@ type EngagementController struct {
Email email.EmailService
}
// parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds.
func parseMetaTime(v interface{}) time.Time {
switch t := v.(type) {
case string:
s := strings.TrimSpace(t)
if s == "" { return time.Time{} }
if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts }
if ts, err := time.Parse("2006-01-02", s); err == nil { return ts }
case float64:
// JSON numbers decode to float64
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
case int64:
if t <= 0 { return time.Time{} }
return time.Unix(t, 0)
case int:
if t <= 0 { return time.Time{} }
return time.Unix(int64(t), 0)
}
return time.Time{}
}
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
return &EngagementController{DB: db, Email: es}
}
@@ -224,7 +247,31 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
return
}
c.JSON(http.StatusOK, items)
// Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to.
now := time.Now()
filtered := make([]models.RewardItem, 0, len(items))
for _, it := range items {
// Mandatory unlock reward is always available
if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") {
filtered = append(filtered, it)
continue
}
var startPtr, endPtr *time.Time
if it.Metadata != nil {
if v, ok := it.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := it.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
}
if startPtr != nil && now.Before(*startPtr) { continue }
if endPtr != nil && now.After(*endPtr) { continue }
filtered = append(filtered, it)
}
c.JSON(http.StatusOK, filtered)
}
// POST /api/v1/engagement/redeem (auth)
@@ -252,6 +299,27 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"})
return
}
// Check validity window (metadata.valid_from/valid_to or expires_at)
if item.Metadata != nil {
var startPtr, endPtr *time.Time
if v, ok := item.Metadata["valid_from"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
}
if v, ok := item.Metadata["valid_to"]; ok {
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
} else if v2, ok2 := item.Metadata["expires_at"]; ok2 {
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
}
now := time.Now()
if startPtr != nil && now.Before(*startPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not currently available"})
return
}
if endPtr != nil && now.After(*endPtr) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward validity has ended"})
return
}
}
if item.Stock == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
return
+2 -2
View File
@@ -228,7 +228,7 @@ func (ec *ErrorController) AdminListExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
@@ -263,7 +263,7 @@ func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
+1 -1
View File
@@ -620,7 +620,7 @@ func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "File tracking refreshed successfully",
"message": "Sledování souborů bylo úspěšně aktualizováno",
"stats": stats,
})
}
+90 -19
View File
@@ -162,28 +162,94 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
return
}
var updates models.NavigationItem
if err := c.ShouldBindJSON(&updates); err != nil {
// Bind into a generic map to know which fields are present (partial update)
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
item.Label = updates.Label
item.URL = updates.URL
item.Icon = updates.Icon
item.Type = updates.Type
item.PageType = updates.PageType
item.PageID = updates.PageID
item.Visible = updates.Visible
item.DisplayOrder = updates.DisplayOrder
item.ParentID = updates.ParentID
item.Target = updates.Target
item.CSSClass = updates.CSSClass
item.RequiresAuth = updates.RequiresAuth
item.RequiresAdmin = updates.RequiresAdmin
// Allow-list of updatable fields and basic type normalization
updates := map[string]interface{}{}
if err := nc.DB.Save(&item).Error; err != nil {
if v, ok := raw["label"]; ok {
if s, ok2 := v.(string); ok2 { updates["label"] = s }
}
if v, ok := raw["url"]; ok {
if s, ok2 := v.(string); ok2 { updates["url"] = s }
}
if v, ok := raw["icon"]; ok {
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
}
if v, ok := raw["type"]; ok {
if s, ok2 := v.(string); ok2 { updates["type"] = s }
}
if v, ok := raw["page_type"]; ok {
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
}
if v, ok := raw["page_id"]; ok {
switch t := v.(type) {
case float64:
updates["page_id"] = int(t)
case int:
updates["page_id"] = t
case int32:
updates["page_id"] = int(t)
case int64:
updates["page_id"] = int(t)
case nil:
updates["page_id"] = nil
}
}
if v, ok := raw["visible"]; ok {
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
}
if v, ok := raw["display_order"]; ok {
switch t := v.(type) {
case float64:
updates["display_order"] = int(t)
case int:
updates["display_order"] = t
case int32:
updates["display_order"] = int(t)
case int64:
updates["display_order"] = int(t)
}
}
if v, ok := raw["parent_id"]; ok {
switch t := v.(type) {
case float64:
updates["parent_id"] = int(t)
case int:
updates["parent_id"] = t
case int32:
updates["parent_id"] = int(t)
case int64:
updates["parent_id"] = int(t)
case nil:
updates["parent_id"] = nil
}
}
if v, ok := raw["target"]; ok {
if s, ok2 := v.(string); ok2 { updates["target"] = s }
}
if v, ok := raw["css_class"]; ok {
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
}
if v, ok := raw["requires_auth"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
}
if v, ok := raw["requires_admin"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
}
if len(updates) == 0 {
// Nothing to update
c.JSON(http.StatusOK, item)
return
}
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update navigation item",
"details": err.Error(),
@@ -191,6 +257,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
return
}
// Reload to return consistent, fresh data
if err := nc.DB.First(&item, id).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"message": "Updated", "id": id})
return
}
c.JSON(http.StatusOK, item)
}
@@ -524,8 +595,8 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
// Kategorie admin page removed (categories derived from competition aliases)
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
+9 -8
View File
@@ -301,10 +301,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias)
admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases)
// Categories (admin)
admin.POST("/categories", baseController.CreateCategory)
admin.PUT("/categories/:id", baseController.UpdateCategory)
admin.DELETE("/categories/:id", baseController.DeleteCategory)
// Categories admin removed: categories are derived from competition aliases
// Settings (singleton)
admin.GET("/settings", baseController.GetSettings)
@@ -634,10 +631,14 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/sweepstakes/current", sweepstakesController.GetCurrent)
api.GET("/sweepstakes/:id/visual", sweepstakesController.PublicVisualData)
api.GET("/polls", pollController.GetPolls)
api.GET("/polls/:id", pollController.GetPoll)
api.POST("/polls/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
api.GET("/polls/:id/results", pollController.GetPollResults)
pollsPub := api.Group("/polls")
pollsPub.Use(middleware.JWTOptional(db))
{
pollsPub.GET("", pollController.GetPolls)
pollsPub.GET("/:id", pollController.GetPoll)
pollsPub.POST("/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
pollsPub.GET("/:id/results", pollController.GetPollResults)
}
api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more