This commit is contained in:
Tomas Dvorak
2025-11-21 08:44:44 +01:00
parent c941313fd5
commit f5b6f83974
108 changed files with 8642 additions and 5871 deletions
+10
View File
@@ -78,6 +78,13 @@ LOG_LEVEL=info # debug, info, warn, error
LOG_FORMAT=text # text or json
LOG_OUTPUT=stdout # stdout, stderr, or file path
# Server timeouts (increase for long AI requests)
READ_TIMEOUT=15
WRITE_TIMEOUT=120
# Feature Flags
REMBG_ENABLED=false
# OpenRouter (for AI blog generation)
# Get a key at https://openrouter.ai
# Do not commit real keys. Set in deployment environment.
@@ -91,6 +98,9 @@ OPENROUTER_FALLBACK_MODEL=mistralai/mistral-nemo:free
OPENROUTER_SITE_URL=http://localhost:8080
OPENROUTER_APP_NAME=MyClub
# Frontend AI timeout (ms)
REACT_APP_AI_TIMEOUT_MS=90000
# Umami Analytics
UMAMI_URL=https://umami.tdvorak.dev
UMAMI_USERNAME=admin
+4
View File
@@ -77,6 +77,10 @@ LOG_LEVEL=info # debug, info, warn, error
LOG_FORMAT=text # text or json
LOG_OUTPUT=stdout # stdout, stderr, or file path
# Feature Flags
# If false, disables Python rembg background removal and uses FACR logos as-is
REMBG_ENABLED=true
# OpenRouter (for AI blog generation)
# Get a key at https://openrouter.ai
# Do not commit real keys. Set in deployment environment.
+88
View File
@@ -0,0 +1,88 @@
# Logo Background Removal with Rembg
## Overview
Automated background removal for club and team logos using Rembg's deep learning capabilities.
https://github.com/danielgatis/rembg
## System Requirements
- Python 3.7+
- Rembg library (with optional GPU support)
- Basic image processing tools
## Architecture
### 1. Core Components
- **Logo Processor Service** (Go)
- Handles image processing pipeline
- Manages Python subprocess
- Implements error handling and fallbacks
- **Background Removal Script** (Python)
- Contains Rembg integration
- Processes individual images
- Handles file operations
### 2. Integration Points
- Upload handler modification
- File storage management
- Database updates for processed assets
## Implementation Workflow
1. **Image Upload**
- File validation
- Temporary storage
- Metadata extraction
2. **Background Processing**
- Queue management
- Process isolation
- Progress tracking
3. **Output Management**
- Transparent PNG generation
- Naming conventions
- Storage optimization
## Performance Optimization
### Processing
- Implement job queuing
- Support batch operations
- Memory management
### Caching
- Processed image cache
- Thumbnail generation
- CDN integration
## Error Handling
- Input validation
- Process timeouts
- Fallback mechanisms
## Security Considerations
- File type verification
- Size limitations
- Permission management
## Maintenance
- Dependency updates
- Model retraining
- Performance monitoring
## Scaling
- Horizontal scaling support
- Resource allocation
- Load balancing
## Monitoring & Logging
- Processing metrics
- Error tracking
- Usage analytics
## Future Enhancements
- Custom model training
- Batch processing UI
- Advanced editing features
+162
View File
@@ -0,0 +1,162 @@
# Fotbal Club - Project Overview
## Project Description
A comprehensive football (soccer) club management system built with Go (Gin) backend and a modern frontend. The application serves as a complete platform for managing football club operations, including team management, news publishing, event scheduling, and fan engagement.
## Technical Stack
### Backend
- **Language**: Go (Golang)
- **Framework**: Gin Web Framework
- **Database**: PostgreSQL (with GORM ORM)
- **Authentication**: JWT (JSON Web Tokens)
- **Templating**: Go HTML templates
### Frontend
- **Framework**: React (TypeScript)
- **Styling**: CSS Modules, with custom theme support
- **Build Tool**: Webpack
- **Testing**: Jest, React Testing Library
### DevOps & Infrastructure
- **Containerization**: Docker
- **CI/CD**: GitHub Actions
- **Monitoring**: Prometheus metrics
- **Logging**: Custom logging service
## Core Features
### 1. User Management
- User registration and authentication
- Role-based access control (Admin, Editor, User)
- Profile management
- Password reset functionality
### 2. Content Management
- Article publishing system
- Media library for images and documents
- WYSIWYG editor for content creation
- Categories and tags for content organization
### 3. Team & Player Management
- Team rosters and player profiles
- Player statistics and performance tracking
- Team lineup configuration
- Match scheduling and results
### 4. Event Management
- Event creation and management
- Calendar integration
- RSVP and attendance tracking
- Event galleries
### 5. Fan Engagement
- Comments system with moderation
- Polls and surveys
- Newsletter subscriptions
- Social media integration
### 6. E-commerce
- Club merchandise store
- Ticket sales
- Donation system
- Payment processing integration
### 7. Analytics & Reporting
- Website traffic analytics
- User engagement metrics
- Custom report generation
- Export functionality for data
## Project Structure
```
fotbal-club/
├── cmd/ # Application entry points
│ └── sqlmigrate/ # Database migration tool
├── internal/ # Private application code
│ ├── config/ # Configuration management
│ ├── controllers/ # Request handlers
│ ├── middleware/ # HTTP middleware
│ ├── models/ # Database models
│ ├── routes/ # Route definitions
│ └── services/ # Business logic
├── pkg/ # Reusable packages
│ ├── database/ # Database connection and migrations
│ ├── email/ # Email service
│ └── logger/ # Logging utilities
├── frontend/ # Frontend application
│ ├── public/ # Static assets
│ ├── src/ # Source code
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── services/ # API services
│ │ ├── styles/ # Global styles
│ │ └── utils/ # Utility functions
│ └── tests/ # Frontend tests
├── uploads/ # User-uploaded files
├── .env # Environment variables
└── go.mod # Go module definition
```
## Key Technical Components
### Backend Architecture
- **RESTful API** design
- **Dependency Injection** for better testability
- **Repository pattern** for data access
- **Middleware** for cross-cutting concerns (auth, logging, etc.)
- **Background workers** for async tasks
### Frontend Architecture
- **Component-based** UI architecture
- **State management** with React Context API
- **Responsive design** for all device sizes
- **Progressive Web App** capabilities
- **Accessibility** (a11y) compliant
### Security Features
- CSRF protection
- XSS prevention
- Rate limiting
- Secure password hashing (bcrypt)
- Input validation and sanitization
## Development Setup
### Prerequisites
- Go 1.20+
- Node.js 16+
- PostgreSQL 13+
- Redis (for caching and sessions)
### Installation
1. Clone the repository
2. Set up environment variables (copy `.env.example` to `.env`)
3. Install backend dependencies: `go mod download`
4. Install frontend dependencies: `cd frontend && npm install`
5. Run database migrations: `go run cmd/sqlmigrate/main.go`
6. Start the development server: `go run main.go`
## Deployment
The application can be deployed using:
- Docker containers
- Traditional VM deployment
- Cloud platforms (AWS, GCP, Azure)
## API Documentation
API documentation is available at `/api/docs` when running in development mode.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a new Pull Request
## License
[Specify License]
+31 -8
View File
@@ -1,32 +1,55 @@
# Build stage
FROM golang:1.24.5-alpine AS builder
FROM golang:1.24.5-bullseye AS builder
ARG REMBG_ENABLED=true
WORKDIR /app
# Install dependencies
RUN apk add --no-cache git
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& if [ "$REMBG_ENABLED" = "true" ]; then apt-get install -y --no-install-recommends python3 python3-pip python3-dev; fi \
&& rm -rf /var/lib/apt/lists/*
# Download dependencies
# Download Go dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Install Python dependencies for rembg
COPY scripts/requirements-rembg.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
if [ "$REMBG_ENABLED" = "true" ]; then pip3 install -r requirements-rembg.txt; else echo "REMBG disabled, skipping pip install"; fi
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o fotbal-club
# Final stage
FROM alpine:latest
FROM debian:bullseye-slim
ARG REMBG_ENABLED=true
WORKDIR /app
# Install runtime dependencies (TLS certs, timezone data) and create non-root user
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app && adduser -S app -G app \
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
&& if [ "$REMBG_ENABLED" = "true" ]; then apt-get install -y --no-install-recommends python3 python3-pip python3-dev libgl1-mesa-glx libglib2.0-0; fi \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN addgroup --system app && adduser --system --ingroup app app \
&& mkdir -p /app/uploads /app/cache \
&& chown -R app:app /app
# Install rembg and its dependencies
COPY --from=builder /app/requirements-rembg.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
if [ "$REMBG_ENABLED" = "true" ]; then pip3 install -r requirements-rembg.txt; fi \
&& rm -f requirements-rembg.txt
# Copy the binary from builder
COPY --from=builder /app/fotbal-club ./fotbal-club
+51 -10
View File
@@ -1,18 +1,26 @@
FROM golang:1.24.5-alpine AS builder
# Build stage
FROM golang:1.24.5-bullseye AS builder
ARG REMBG_ENABLED=true
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache gcc musl-dev git
# Copy go mod and sum files
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/var/lib/apt/lists \
--mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& if [ "$REMBG_ENABLED" = "true" ]; then apt-get install -y --no-install-recommends python3 python3-pip python3-dev; fi \
&& rm -rf /var/lib/apt/lists/*
# Configure Go proxy with fallback and download dependencies with retry
ENV GOPROXY=https://proxy.golang.org,direct
ENV GOPRIVATE=
ENV GOSUMDB=sum.golang.org
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download all dependencies with retry logic and cache mount
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
@@ -22,6 +30,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
done && \
go mod verify
# Install Python dependencies for rembg (before copying full source for better cacheability)
COPY scripts/requirements-rembg.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
if [ "$REMBG_ENABLED" = "true" ]; then pip3 install -r requirements-rembg.txt; else echo "REMBG disabled, skipping pip install"; fi
# Copy the source code
COPY . .
@@ -31,20 +44,48 @@ RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-w -s" -trimpath -o main .
# Use a smaller image for the final container
FROM alpine:latest
# Final stage
FROM debian:bullseye-slim
ARG REMBG_ENABLED=true
WORKDIR /app
# Copy the binary from builder
COPY --from=builder /app/main .
# Install runtime dependencies
RUN --mount=type=cache,target=/var/lib/apt/lists \
--mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
&& if [ "$REMBG_ENABLED" = "true" ]; then apt-get install -y --no-install-recommends python3 python3-pip python3-dev libgl1-mesa-glx libglib2.0-0; fi \
&& rm -rf /var/lib/apt/lists/*
# Copy static files and templates
# Create non-root user and directories
RUN addgroup --system app && adduser --system --ingroup app app \
&& mkdir -p /app/uploads /app/cache /app/static /app/templates \
&& chown -R app:app /app
# Install rembg and its dependencies
COPY --from=builder /app/requirements-rembg.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
if [ "$REMBG_ENABLED" = "true" ]; then pip3 install -r requirements-rembg.txt; fi \
&& rm -f requirements-rembg.txt
# Copy the binary and other files
COPY --from=builder /app/main .
COPY --from=builder /app/static ./static
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/scripts ./scripts
# Set environment and permissions
ENV GIN_MODE=debug
USER app
# Expose port
EXPOSE 8080
# Healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD wget -q -O - http://127.0.0.1:8080/api/v1/health >/dev/null 2>&1 || exit 1
# Command to run the executable
CMD ["./main"]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"items":[],"page":1,"page_size":10,"total":0}
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
[]
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
[]
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:37Z","last_modified":""}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:41Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
null
-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
View File
@@ -1 +0,0 @@
{"lastUpdated":"2025-11-14T14:03:41Z"}
-52
View File
@@ -1,52 +0,0 @@
{
"baseURL": "http://localhost:8080/api/v1",
"duration_ms": 7015,
"endpoints": [
{
"path": "/settings",
"file": "settings.json",
"ok": true
},
{
"path": "/seo",
"file": "seo.json",
"ok": true
},
{
"path": "/articles?page=1\u0026page_size=10\u0026published=true",
"file": "articles.json",
"ok": true
},
{
"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",
"ok": true
},
{
"path": "/competition-aliases",
"file": "competition_aliases.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58",
"file": "facr_club_info.json",
"ok": true
},
{
"path": "/facr/club/football/7eacd9f0-bfa0-4928-a9b6-936140168f58/table",
"file": "facr_tables.json",
"ok": true
}
],
"lastUpdated": "2025-11-14T14:03:41Z"
}
-1
View File
@@ -1 +0,0 @@
{"additional_meta":"","canonical_base_url":"","default_og_image_url":"","enable_indexing":false,"meta_keywords":"","site_description":"","site_title":"","twitter_handle":""}
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
{"about_html":"","accent_color":"#ffbb00","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.png","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":"info@tdvorak.dev","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":"#0055ff","show_about_in_nav":true,"show_map_on_homepage":false,"sponsors_layout":"","sponsors_theme":"","text_color":"#111111","videos":null,"videos_items":null,"videos_limit":6,"videos_module_enabled":true,"videos_source":"auto","videos_style":"slider","videos_title_overrides":{},"youtube_url":"https://www.youtube.com/@FCBizoniUH"}
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
[]
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
-1
View File
@@ -1 +0,0 @@
{"by_id":{},"by_name":{}}
-1
View File
@@ -1 +0,0 @@
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"fetched_at":"2025-11-14T14:03:41Z","source":"https://youtube.tdvorak.dev/channel_videos?channel=https%3A%2F%2Fwww.youtube.com%2F%40FCBizoniUH"}
-102
View File
@@ -1,102 +0,0 @@
[
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
},
{
"id": "",
"title": "",
"url": "",
"date": "",
"photos_count": 0,
"views_count": 0,
"photos": null,
"fetched_at": "2025-11-14T14:03:49Z"
}
]
-1
View File
@@ -1 +0,0 @@
null
-4
View File
@@ -1,4 +0,0 @@
{
"fetched_at": "2025-11-14T14:03:49Z",
"link": ""
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,7 +1,7 @@
%%{init: {
'theme': 'base',
'securityLevel': 'loose',
'flowchart': { 'curve': 'basis' },
'flowchart': { 'curve': 'linear', 'useMaxWidth': true, 'nodeSpacing': 36, 'rankSpacing': 48 },
'themeVariables': {
'primaryColor': '#0b5cff',
'primaryTextColor': '#ffffff',
@@ -9,7 +9,7 @@
'tertiaryColor': '#f8fafc',
'fontSize': '12px'
},
'themeCSS': '.edgePath path { stroke-dasharray: 5 5; animation: dash 24s linear infinite; } @keyframes dash { to { stroke-dashoffset: 1000; } } .cluster rect { rx:8; ry:8; }'
'themeCSS': '.edgePath path { stroke-opacity: .6; } .cluster rect { rx:8; ry:8; }'
}}%%
flowchart TB
+5 -5
View File
@@ -1,4 +1,4 @@
%%{init: {"theme":"forest","flowchart":{"curve":"linear"},"themeCSS":".edgePath path { stroke-dasharray: 6 4; animation: dash 16s linear infinite; } @keyframes dash { to { stroke-dashoffset: -1000; } }" }}%%
%%{init: {"theme":"forest","securityLevel":"loose","flowchart":{"curve":"linear","useMaxWidth":true,"nodeSpacing":35,"rankSpacing":45},"themeCSS":".edgePath path { stroke-opacity:.6 } .cluster rect { rx:8; ry:8 }" }}%%
flowchart TB
classDef group fill:#eef7ff,stroke:#2b6cb0,color:#0b3a60;
@@ -16,7 +16,7 @@ client ==>|HTTP| api
client ==>|HTTP| rootgrp
subgraph PUBLIC["Public endpoints"]
direction TB
direction LR
p_health["GET /health"]:::pub
p_csrf["GET /csrf-token"]:::pub
p_image_proxy["GET /proxy/image"]:::pub
@@ -54,7 +54,7 @@ subgraph PUBLIC["Public endpoints"]
end
subgraph PROTECTED["Protected (JWTAuth + CSRF for state)"]
direction TB
direction LR
prot_sweep["POST /sweepstakes/:id/enter | POST /sweepstakes/:id/played | GET /sweepstakes/my-winnings"]:::route
prot_eng["Engagement: GET /leaderboard, /profile, /achievements, /transactions | POST /checkin, /article-read, /redeem | PATCH /profile, /avatar"]:::route
prot_comments["Comments: POST /comments | PUT/DELETE /comments/:id | react/unreact | unban-request | report"]:::route
@@ -68,7 +68,7 @@ subgraph PROTECTED["Protected (JWTAuth + CSRF for state)"]
end
subgraph ADMIN["Admin groups (JWT + Role: admin)"]
direction TB
direction LR
ad_errors["/admin/errors: list, get, external proxies"]:::admin
ad_comments["/admin/comments: list, status, bans, unban requests"]:::admin
ad_comp_aliases["/admin/competition-aliases: CRUD + reorder"]:::admin
@@ -98,7 +98,7 @@ subgraph ADMIN["Admin groups (JWT + Role: admin)"]
end
subgraph ROOT["Root endpoints"]
direction TB
direction LR
r_robots["GET /robots.txt"]:::root
r_sitemap["GET /sitemap.xml"]:::root
r_short["GET /s/:code"]:::root
+3 -1
View File
@@ -1,4 +1,4 @@
%%{init: {"theme":"forest","flowchart":{"curve":"linear"},"themeCSS":".edgePath path { stroke-dasharray: 6 4; animation: dash 16s linear infinite; } @keyframes dash { to { stroke-dashoffset: -1000; } }" }}%%
%%{init: {"theme":"forest","securityLevel":"loose","flowchart":{"curve":"linear","useMaxWidth":true,"nodeSpacing":35,"rankSpacing":45},"themeCSS":".edgePath path { stroke-opacity:.6 } .cluster rect { rx:8; ry:8 }" }}%%
flowchart TD
%% Routes to Pages Mapping (from App.lazy.tsx)
classDef page fill:#fff7ed,stroke:#f59e0b,color:#7c2d12;
@@ -7,6 +7,7 @@ flowchart TD
Router[BrowserRouter]:::route --> Routes:::route
subgraph PublicRoutes[Public Routes]
direction LR
R0["/"]:::route --> HomeRoute:::route --> HomePage:::page
R1["/blog"]:::route --> BlogRoute:::route --> BlogPage:::page
R2["/hledat"]:::route --> SearchPage:::page
@@ -60,6 +61,7 @@ flowchart TD
end
subgraph AdminRoutes[Admin Routes - guarded by ProtectedRoute]
direction LR
A0["/admin"]:::route --> AdminDashboardPage:::page
A1["/admin/docs"]:::route --> AdminDocsPage:::page
A2["/admin/o-klubu"]:::route --> AboutAdminPage:::page
+28 -16
View File
@@ -20,7 +20,7 @@
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
.btn.ghost{background:transparent}
main{padding:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(460px,1fr));gap:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(640px,1fr));gap:16px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card header{display:flex;align-items:center;gap:8px;justify-content:space-between;background:#0f131f;border-bottom:1px solid var(--border);padding:10px 12px;position:static}
.title{display:flex;flex-direction:column;gap:4px}
@@ -39,14 +39,15 @@
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({ startOnLoad:false, securityLevel:'loose', theme:'dark', flowchart:{ curve:'basis', useMaxWidth:true } });
mermaid.initialize({ startOnLoad:false, securityLevel:'loose', theme:'dark', flowchart:{ curve:'linear', useMaxWidth:true } });
async function renderMermaidFile(mmdPath, container){
try{
container.innerHTML = '<div style="padding:16px;color:#9aa3b2">Loading '+mmdPath+'…</div>';
const res = await fetch(mmdPath, { cache: 'no-store' });
const res = await fetch(mmdPath + '?v=' + Date.now(), { cache: 'no-store' });
if(!res.ok) throw new Error('Failed to load '+mmdPath+': '+res.status);
const code = await res.text();
const raw = await res.text();
const code = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const id = 'm-'+Math.random().toString(36).slice(2);
const { svg } = await mermaid.render(id, code);
container.innerHTML = svg;
@@ -82,10 +83,13 @@
if(!source.match(/^<svg[^>]+xmlns=/)) source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
source = '<?xml version="1.0" standalone="no"?>\n'+source;
// Inject white background and readable styles for new tab view
const firstGt = source.indexOf('>');
if(firstGt > 0){
const svgStart = source.indexOf('<svg');
if(svgStart !== -1){
const svgTagEnd = source.indexOf('>', svgStart);
if(svgTagEnd !== -1){
const inject = '<rect width="100%" height="100%" fill="#ffffff"/><style>text{fill:#111827}.edgePath path,.flowchart-link{stroke:#334155}</style>';
source = source.slice(0, firstGt+1) + inject + source.slice(firstGt+1);
source = source.slice(0, svgTagEnd+1) + inject + source.slice(svgTagEnd+1);
}
}
const blob = new Blob([source], { type:'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
@@ -95,8 +99,7 @@
const ALL_DIAGRAMS = [
// System & DB
{ id:'system-clean', label:'System Overview (Clean)', file:'system-overall-clean.mmd', cat:'System', tags:['overview','recommended','big'] },
{ id:'system', label:'System Overview (Classic)', file:'system-overall.mmd', cat:'System', tags:['overview','big'], defaultWires:'faint' },
{ id:'system-clean', label:'System Overview', file:'system-overall-clean.mmd', cat:'System', tags:['overview','recommended','big'] },
{ id:'db-er', label:'Database ER', file:'db-er.mmd', cat:'System', tags:['db'] },
{ id:'db-models', label:'Database Models', file:'db-models.mmd', cat:'System', tags:['db'] },
// Backend
@@ -107,15 +110,13 @@
{ id:'auth', label:'Auth Flow', file:'auth-flow.mmd', cat:'Backend', tags:['auth','flow'] },
{ id:'err-flow', label:'Error Tracking Flow', file:'error-tracking-flow.mmd', cat:'Backend', tags:['errors','flow'] },
// Frontend
{ id:'fe-everything', label:'Frontend — Everything (Big)', file:'frontend-everything.mmd', cat:'Frontend', tags:['overview','big'], defaultWires:'faint' },
{ id:'fe-overall', label:'Frontend — Overall', file:'frontend-overall.mmd', cat:'Frontend', tags:['architecture'] },
{ id:'fe-everything', label:'Frontend — Everything (Big)', file:'frontend-everything.mmd', cat:'Frontend', tags:['overview','big','recommended'], defaultWires:'faint' },
{ id:'fe-overall', label:'Frontend — Overall', file:'frontend-overall.mmd', cat:'Frontend', tags:['architecture','recommended'] },
{ id:'fe-routes', label:'Frontend — Routes', file:'frontend-routes.mmd', cat:'Frontend', tags:['routes'] },
{ id:'fe-home', label:'Frontend — Homepage', file:'frontend-homepage.mmd', cat:'Frontend', tags:['homepage'] },
{ id:'fe-modules', label:'Frontend — Modules', file:'frontend-modules.mmd', cat:'Frontend', tags:['modules'] },
{ id:'fe-arch', label:'Frontend — Provider Tree', file:'frontend-architecture.mmd', cat:'Frontend', tags:['providers'] },
{ id:'fe-api', label:'Frontend — API Map', file:'frontend-api-map.mmd', cat:'Frontend', tags:['api'] },
// Admin
{ id:'admin-overall', label:'Admin — Overall', file:'admin-overall.mmd', cat:'Admin', tags:['admin','overview'], defaultWires:'faint' },
{ id:'admin-overall', label:'Admin — Overall', file:'admin-overall.mmd', cat:'Admin', tags:['admin','overview','recommended'], defaultWires:'faint' },
{ id:'scoreboard', label:'Scoreboard Flow', file:'scoreboard-flow.mmd', cat:'Admin', tags:['scoreboard','flow'] },
{ id:'newsletter', label:'Newsletter Flow', file:'newsletter-flow.mmd', cat:'Admin', tags:['newsletter','flow'] },
{ id:'comments', label:'Comments Flow', file:'comments-flow.mmd', cat:'Admin', tags:['comments','flow'] },
@@ -145,7 +146,8 @@
const tb = document.createElement('div'); tb.className='toolbar';
tb.innerHTML = `
<label><input type="checkbox" class="fit" checked> Fit width</label>
<label style="display:inline-flex;align-items:center;gap:8px"><input type="checkbox" class="fit" checked> Fit width</label>
<label style="display:inline-flex;align-items:center;gap:6px">Zoom <input class="zoom" type="range" min="50" max="300" value="100" style="width:140px"></label>
<a class="btn ghost src" href="${d.file}" target="_blank">Source</a>
<span class="sp"></span>
<button class="btn open">Open SVG in new tab</button>
@@ -161,9 +163,17 @@
const svg = container?.querySelector('svg');
if(!svg) return;
const fit = card.querySelector('.fit');
if(fit && fit.checked){ svg.style.width='100%'; svg.style.height='auto'; } else { svg.style.width=''; svg.style.height=''; }
const zoom = card.querySelector('.zoom');
if(fit && fit.checked){
svg.style.width='100%'; svg.style.height='auto';
svg.style.transformOrigin = '';
svg.style.transform = '';
} else {
svg.style.width=''; svg.style.height='';
const z = Math.max(50, Math.min(300, parseInt(zoom?.value || '100', 10)));
svg.style.transformOrigin = 'top left';
svg.style.transform = 'scale('+(z/100)+')';
}
}
function wireCardControls(card, file){
@@ -173,7 +183,9 @@
const openBtn = card.querySelector('.open');
const refresh = card.querySelector('.refresh');
const download = card.querySelector('.download');
const zoom = card.querySelector('.zoom');
fit.addEventListener('change', () => applyFitZoomFor(card));
zoom.addEventListener('input', () => applyFitZoomFor(card));
openBtn.addEventListener('click', () => openSVGInNewTab(diag));
refresh.addEventListener('click', async () => { diag.dataset.rendered=''; await renderMermaidFile(file, diag); diag.dataset.rendered='1'; applyFitZoomFor(card); });
download.addEventListener('click', () => downloadSVGOf(diag, (file.replace('.mmd','')||'diagram')+'.svg'));
+20 -13
View File
@@ -1,19 +1,26 @@
%%{init: {"theme":"forest","flowchart":{"curve":"linear"},"themeCSS":".edgePath path { stroke-dasharray: 6 4; animation: dash 16s linear infinite; } @keyframes dash { to { stroke-dashoffset: -1000; } }" }}%%
%%{init: {"theme":"forest","securityLevel":"loose","flowchart":{"curve":"linear","useMaxWidth":true,"nodeSpacing":40,"rankSpacing":50},"themeCSS":".edgePath path { stroke-opacity:.6 } .cluster rect { rx:8; ry:8 }" }}%%
flowchart LR
classDef client fill:#f1f5f9,stroke:#334155,color:#0f172a;
classDef fe fill:#fff7ed,stroke:#f59e0b,color:#7c2d12;
classDef be fill:#ecfdf5,stroke:#16a34a,color:#065f46;
classDef db fill:#e3f2fd,stroke:#1e88e5,color:#0c4a6e;
classDef ext fill:#f5f3ff,stroke:#8b5cf6,color:#4c1d95;
classDef stat fill:#e2e8f0,stroke:#475569,color:#111827;
classDef client fill:#f1f5f9,stroke:#334155,color:#0f172a
classDef fe fill:#fff7ed,stroke:#f59e0b,color:#7c2d12
classDef be fill:#ecfdf5,stroke:#16a34a,color:#065f46
classDef db fill:#e3f2fd,stroke:#1e88e5,color:#0c4a6e
classDef ext fill:#f5f3ff,stroke:#8b5cf6,color:#4c1d95
classDef stat fill:#e2e8f0,stroke:#475569,color:#111827
U((User Browser)):::client
FE[Frontend (React app)]:::fe
API[Backend API (Go + Gin)\n/api/v1]:::be
DB[(PostgreSQL DB)]:::db
STATIC[Static & Uploads\n/assets, /uploads]:::stat
EXT[External Services\n(SMTP, Error Receiver, Umami, FACR, Zonerama, YouTube)]:::ext
U(("User Browser"))
FE["Frontend (React app)"]
API["Backend API (Go + Gin)<br/>/api/v1"]
DB[(PostgreSQL DB)]
STATIC["Static & Uploads<br/>/assets, /uploads"]
EXT["External Services<br/>(SMTP, Error Receiver, Umami, FACR, Zonerama, YouTube)"]
class U client
class FE fe
class API be
class DB db
class STATIC stat
class EXT ext
U --> FE
FE ==>|HTTP| API
+40 -40
View File
@@ -1,4 +1,4 @@
%%{init: {"theme":"forest","flowchart":{"curve":"linear"},"themeCSS":"svg { font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; } .edgePath path { stroke-dasharray: 6 4; animation: dash 16s linear infinite; } .animated-edge path { stroke-dasharray: 6 4; animation: dash 16s linear infinite; } @keyframes dash { to { stroke-dashoffset: -1000; } } .ext > rect, .ext > polygon, .ext > path { stroke: #7e57c2; } .db > rect, .db > polygon, .db > path { fill: #e3f2fd; stroke: #1e88e5; } .svc > rect, .svc > polygon, .svc > path { fill: #e8f5e9; stroke: #43a047; } .fe > rect, .fe > polygon, .fe > path { fill: #fff8e1; stroke: #f9a825; } .ctrl > rect, .ctrl > polygon, .ctrl > path { fill: #f3e5f5; stroke: #8e24aa; } .mid > rect, .mid > polygon, .mid > path { fill: #e0f2f1; stroke: #00897b; } .model > rect, .model > polygon, .model > path { fill: #ede7f6; stroke: #5e35b1; } .route > rect, .route > polygon, .route > path { fill: #e8eaf6; stroke: #3f51b5; }" }}%%
%%{init: {"theme":"forest","securityLevel":"loose","flowchart":{"curve":"linear","useMaxWidth":true,"nodeSpacing":40,"rankSpacing":50},"themeCSS":"svg { font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; } .edgePath path { stroke-opacity:.6 } .ext > rect, .ext > polygon, .ext > path { stroke: #7e57c2; } .db > rect, .db > polygon, .db > path { fill: #e3f2fd; stroke: #1e88e5; } .svc > rect, .svc > polygon, .svc > path { fill: #e8f5e9; stroke: #43a047; } .fe > rect, .fe > polygon, .fe > path { fill: #fff8e1; stroke: #f9a825; } .ctrl > rect, .ctrl > polygon, .ctrl > path { fill: #f3e5f5; stroke: #8e24aa; } .mid > rect, .mid > polygon, .mid > path { fill: #e0f2f1; stroke: #00897b; } .model > rect, .model > polygon, .model > path { fill: #ede7f6; stroke: #5e35b1; } .route > rect, .route > polygon, .route > path { fill: #e8eaf6; stroke: #3f51b5; } .cluster rect { rx:8; ry:8 }" }}%%
flowchart TB
%% ========================= Docker & Runtime =========================
@@ -37,13 +37,13 @@ subgraph DOCKER["Docker Compose (Local Dev/Prod)"]
end
user_browser((User Browser)):::ext
user_browser ==>|HTTP 80| docker_frontend:::animated-edge
user_browser -.->|dev direct (HTTP 8080)| docker_backend
user_browser ==>|HTTP 80| fe_3000
user_browser -.->|dev direct :8080| be_8080
%% ========================= Backend (Go/Gin) =========================
subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
direction TB
cfg[Config (internal/config.Config)\n- APP_ENV/PORT/DEBUG\n- DATABASE_URL (GORM)\n- JWT_SECRET/EXP\n- ALLOWED_ORIGINS (CORS)\n- UPLOAD_DIR/MAX_UPLOAD_SIZE\n- SMTP_* (Email)\n- FRONTEND_BASE_URL\n- PUBLIC_API_BASE_URL\n- ERROR_INGEST_URL/TOKEN\n- FACR_SCRAPER_BASE_URL\n- UMAMI_*\n- CLAMAV_* (optional)]
cfg["Config (internal/config.Config)<br/>- APP_ENV/PORT/DEBUG<br/>- DATABASE_URL (GORM)<br/>- JWT_SECRET/EXP<br/>- ALLOWED_ORIGINS (CORS)<br/>- UPLOAD_DIR/MAX_UPLOAD_SIZE<br/>- SMTP_* (Email)<br/>- FRONTEND_BASE_URL<br/>- PUBLIC_API_BASE_URL<br/>- ERROR_INGEST_URL/TOKEN<br/>- FACR_SCRAPER_BASE_URL<br/>- UMAMI_*<br/>- CLAMAV_* (optional)"]
logger[Logger (pkg/logger)]
db_init[[InitDB() + AutoMigrate()]]:::db
email_svc[EmailService (pkg/email)]:::svc
@@ -77,42 +77,42 @@ subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
subgraph controllers[Controllers]
direction TB
c_auth[AuthController\n/login,/logout,/register,/me\n/password-reset]
c_contact[ContactController\n/contact + newsletter + admin forwarding]
c_auth["AuthController<br/>/login,/logout,/register,/me<br/>/password-reset"]
c_contact["ContactController<br/>/contact + newsletter + admin forwarding"]
c_pass[PasswordController]
c_ai[AIController\n/ai/blog,/ai/about,/ai/css,/ai/instagram]
c_score[ScoreboardController\n/public + admin timer/sponsors/qr]
c_ai["AIController<br/>/ai/blog,/ai/about,/ai/css,/ai/instagram"]
c_score["ScoreboardController<br/>/public + admin timer/sponsors/qr"]
c_about[AboutController]
c_gallery[GalleryController\n/Zonerama profile/albums/picks]
c_files[FilesController\n/list/unused/duplicates/usage\n/scan/refresh-tracking/delete]
c_gallery["GalleryController<br/>/Zonerama profile/albums/picks"]
c_files["FilesController<br/>/list/unused/duplicates/usage<br/>/scan/refresh-tracking/delete"]
c_notify[NotificationsController]
c_email[EmailController\n/open.gif/click/unsubscribe/stats]
c_prefetch[PrefetchController\n/status/trigger]
c_seo[SEOController\n/seo (public) + robots.txt + sitemap]
c_nav[NavigationController\n/navigation + social-links + admin CRUD]
c_poll[PollController\n/public vote/results + admin]
c_sw[SweepstakesController\n/public current/visual + admin CRUD/finalize]
c_cloth[ClothingController\n/public + admin CRUD]
c_pec[PageElementConfigController\n/public + admin CRUD/batch]
c_article[ArticleController\n/create + match-link]
c_base[BaseController\n/health, uploads, categories, teams, players, matches, standings, zonerama, settings, shortlinks(public)]
c_myu[MyUIbrixController\n/validate,/preview,/optimize]
c_editor[EditorPreviewController\n/preview state + variants]
c_short[ShortLinkController\n/public create + admin + redirect /s/:code]
c_comment[CommentController\n/public list + CRUD + reactions\nban/unban/report (admin)]
c_eng[EngagementController\n/rewards/leaderboard/profile/actions]
c_facr[FACRController\n/facr club search/info/table]
c_yt[YouTubeController\n/youtube/videos]
c_umami[UmamiController\n/config + admin initialize/stats]
c_error[ErrorController\n/errors ingest + admin + external]
c_email["EmailController<br/>/open.gif/click/unsubscribe/stats"]
c_prefetch["PrefetchController<br/>/status/trigger"]
c_seo["SEOController<br/>/seo (public) + robots.txt + sitemap"]
c_nav["NavigationController<br/>/navigation + social-links + admin CRUD"]
c_poll["PollController<br/>/public vote/results + admin"]
c_sw["SweepstakesController<br/>/public current/visual + admin CRUD/finalize"]
c_cloth["ClothingController<br/>/public + admin CRUD"]
c_pec["PageElementConfigController<br/>/public + admin CRUD/batch"]
c_article["ArticleController<br/>/create + match-link"]
c_base["BaseController<br/>/health, uploads, categories, teams, players, matches, standings, zonerama, settings, shortlinks(public)"]
c_myu["MyUIbrixController<br/>/validate,/preview,/optimize"]
c_editor["EditorPreviewController<br/>/preview state + variants"]
c_short["ShortLinkController<br/>/public create + admin + redirect /s/:code"]
c_comment["CommentController<br/>/public list + CRUD + reactions<br/>ban/unban/report (admin)"]
c_eng["EngagementController<br/>/rewards/leaderboard/profile/actions"]
c_facr["FACRController<br/>/facr club search/info/table"]
c_yt["YouTubeController<br/>/youtube/videos"]
c_umami["UmamiController<br/>/config + admin initialize/stats"]
c_error["ErrorController<br/>/errors ingest + admin + external"]
end
subgraph services[Services & Jobs]
direction TB
s_errrep[ErrorReporter]
s_prefetch[Prefetcher\nStartPrefetcher(target)]
s_prefetch["Prefetcher<br/>StartPrefetcher(target)"]
s_nlsched[NewsletterScheduler]
s_nlauto[NewsletterAutomation\nweekly, reminders, results]
s_nlauto["NewsletterAutomation<br/>weekly, reminders, results"]
s_sweep[SweepstakesScheduler]
s_umami[UmamiService]
s_facr[FACRService]
@@ -213,8 +213,8 @@ subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
errors_admin["Error Review Admin UI/API: errors.tdvorak.dev"]:::ext
umami_ext["Umami Analytics server"]:::ext
s_facr <---> facr_ext:::animated-edge
s_errrep --> errors_ingest:::animated-edge
s_facr <---> facr_ext
s_errrep --> errors_ingest
c_error <---> errors_admin
s_umami <---> umami_ext
@@ -228,7 +228,7 @@ subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
prometheus --- user_browser
end
user_browser ==>|HTTP /api/v1| api_grp:::animated-edge
user_browser ==>|HTTP /api/v1| api_grp
user_browser ==>|HTTP /robots.txt, /sitemap.xml, /s/:code| root_grp
%% ========================= Frontend (React) =========================
@@ -241,7 +241,7 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
p_home[HomePage /]
p_blog[BlogPage /blog]
p_newslist[ArticlesListPage]
p_article[ArticleDetailPage /news/:slug | /articles/:id]
p_article["ArticleDetailPage /news/:slug | /articles/:id"]
p_about[AboutPage /o-klubu]
p_club[ClubPage /klub]
p_calendar[CalendarPage /kalendar]
@@ -315,9 +315,9 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
end
%% FE -> BE API mappings (high level)
fe_router -->|services/api.ts| api_grp:::animated-edge
fe_router -->|services/api.ts| api_grp
p_blog -->|GET /articles| api_grp
p_article -->|GET /articles/slug/:slug, /articles/:id\nPOST /articles/:id/read| api_grp
p_article -->|GET /articles/slug/:slug, /articles/:id<br/>POST /articles/:id/read| api_grp
p_home -->|GET /articles/featured, /matches, /standings, /settings, /navigation| api_grp
p_matches -->|GET /matches,/standings| api_grp
p_match -->|GET /matches/:id| api_grp
@@ -334,7 +334,7 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
p_short -->|GET /s/:code (root)| root_grp
%% Admin flows
a_articles[ArticlesAdminPage] -->|POST/PUT/DELETE /articles\n/link-match| api_grp
a_articles[ArticlesAdminPage] -->|POST/PUT/DELETE /articles<br/>/link-match| api_grp
a_matches -->|GET /admin/matches| api_grp
a_comments -->|GET/PATCH /admin/comments| api_grp
a_navigation -->|CRUD /admin/navigation| api_grp
@@ -347,7 +347,7 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
a_analytics -->|/admin/umami| api_grp
%% FE error reporting & analytics
fe_router -->|POST /errors (ErrorReporter)| api_grp:::animated-edge
fe_router -->|POST /errors (ErrorReporter)| api_grp
fe_router -->|GET /umami/config| api_grp
end
@@ -358,7 +358,7 @@ subgraph PORTS[Ports & CORS]
port_be[Backend :8080]
port_fe[Frontend :3000 -> :80]
port_db[Postgres :5432]
cors[CORS AllowedOrigins\n- http://localhost:3000\n- http://localhost:8080\n+ FrontendBaseURL origin\n+ "*" optional in dev]
cors["CORS AllowedOrigins<br/>- http://localhost:3000<br/>- http://localhost:8080<br/>+ FrontendBaseURL origin<br/>+ * optional in dev"]
end
port_be --- docker_backend
port_fe --- docker_frontend
+5
View File
@@ -6,8 +6,11 @@ services:
target: builder # Build only the builder stage
cache_from:
- type=local,src=/tmp/.buildx-cache
cache_to:
- type=local,dest=/tmp/.buildx-cache,mode=max
args:
BUILDKIT_INLINE_CACHE: 1
REMBG_ENABLED: ${REMBG_ENABLED:-true}
container_name: myclub-backend
env_file:
- .env
@@ -53,6 +56,8 @@ services:
dockerfile: Dockerfile
cache_from:
- type=local,src=/tmp/.buildx-cache-frontend
cache_to:
- type=local,dest=/tmp/.buildx-cache-frontend,mode=max
args:
BUILDKIT_INLINE_CACHE: 1
shm_size: '512m' # Increase shared memory for build
+200
View File
@@ -0,0 +1,200 @@
# Error Check & Completion Tracker
This file tracks the final QA passes, defects, and enhancements. Each item includes acceptance criteria and tasks. Well work through these incrementally, closing items as theyre verified in UI and with basic E2E checks.
## Legend
- [ ] TODO
- [~] In progress
- [x] Done
---
## Dashboard (Nástěnka)
- Status: [x] Fully working (verify basic KPIs render, no console errors)
## Analytika
- Status: [x] Fully working (Umami connected when enabled; no data leak on admin pages)
## Týmy
- Status: [x] Fully working
## Zápasy
- Status: [x] Fully working
## Hráči
- Status: [x] Fully working
## Alias soutěží
- Status: [x] Fully working
## Tabule (Scoreboard)
- Status: [~] Enhancements only
- Tasks:
- [ ] Minor UI polish and responsiveness
## Scoreboard Remote
- Status: [~] Enhancements only
- Tasks:
- [ ] Minor UI polish and responsiveness
## Rich Text Editor
- Status: [x] Fully working
- Issues:
- [x] Image resize not working reliably
- [x] Moving images in content not working
- [x] Edge dragging must be disabled (keeps breaking behavior)
- [x] Transformations (rotate/flip/filters) not applied/persisted
- [x] Bullet list style variants not visible (“odrážkový seznam”)
- Acceptance criteria:
- [x] Resize via corner handles works smoothly; no edge-drag behavior
- [x] Move image by cut/paste or drag within editor without duplication
- [x] Rotation/flip/filters apply immediately and persist to HTML (via data attributes or style)
- [x] Bullet and ordered list markers render (disc/circle/square/decimal)
- Tasks:
- [x] Remove any fallback edge drag handlers
- [x] Implement safe image transform pipeline (style + data-filters)
- [x] Add editor CSS to force list-style-type visibility
- [x] Tests: paste content with lists + images and verify rendering
## Články
- Status: [x] Fully working
- Issues:
- [x] AI output: use richer HTML (ul/li, strong, etc.)
- [x] “Vybrat z alba” is laggy (all images load; scroll blocks)
- Acceptance criteria:
- [x] AI generates medium-length content with structured HTML and variety
- [x] Album picker uses virtualized list or paging and is responsive
## Blog page (Zonerama attribution)
- Status: [x] Fixed
- Issue:
- [x] Duplicate Zonerama attribution visible
- Acceptance criteria:
- [ ] Only keep:
<div class="css-s2uf1z"><p class="chakra-text css-ovl0lz">© Fotografie z <a target="_blank" rel="noopener noreferrer" class="chakra-link css-1ur71p2" href="https://eu.zonerama.com/FKKofolaKrnov/1470757">Zonerama</a></p></div>
## Přílohy
- Status: [x] Fully working
- Issues:
- [x] Long filenames overflow; download button does not fit
- Acceptance criteria:
- [x] Truncate filenames with ellipsis; button stays within container on all sizes
## Komentáře Reakce
- Status: [x] Fully working
- Issue:
- [x] Switching reaction duplicates counts (previous type not decremented)
- Acceptance criteria:
- [x] Optimistic update decrements previous and increments new; no duplicates; disabled during mutation
## Admin Ankety
- Status: [x] Fully working
- Issues:
- [x] “AI text” button label+icon overflow (n/a no AI button present)
- [x] Remove “Veřejná” toggle polls always public (n/a no such toggle)
- Acceptance criteria:
- [x] Button content fits within button; no overflow
- [x] Toggle removed; backend defaults respected
## Videa
- Status: [x] Fully working
- Issues:
- [x] Add manual custom video insert (not overwritten by auto JSON)
- [x] Add allow/hide toggle for all videos
- Acceptance criteria:
- [x] Custom videos persist and render; not removed by sync
- [x] Allow/hide respected across all renderers
## Galerie
- Status: [x] Fully working
## Soubory
- Status: [x] Fully working
## Zprávy (Kontakt)
- Status: [x] Fully working
- Issue:
- [x] “Přeposlat všechny zprávy” automatic forwarding not sent to configured email unless manual
- Acceptance criteria:
- [x] Auto-forwarding works based on settings; messages delivered without manual step
## Zpravodaj (Newsletter)
- Status: [x] Fully working
- Issues:
- [x] After subscribe, send styled welcome email
- [x] Auto-create account; send working preferences link (no 404)
- Acceptance criteria:
- [x] Welcome email uses branded template
- [x] Preferences page opens and updates subscriptions
## Bannery
- Status: [~] Fixing
- Issue:
- [ ] Postranní banner style/position broken; appears under hero with side gaps
- Acceptance criteria:
- [ ] Banner anchors to left/right side as configured; no extra gaps; not under hero
## Oblečení
- Status: [x] Fully working
## Ankety (public)
- Status: [x] Fully working
## Soutěže
- Status: [x] Fully working
- Issues:
- [x] "Nová soutěž" button too small; overlaps text
- [x] Modal layout overlaps "Vytvořit stránku"
- Acceptance criteria:
- [x] Buttons sized properly; no overlaps across viewports
- [x] Modal content spaced and scrollable
## Odměny & Úspěchy
- Status: [~] Fixing
- Issues:
- [ ] Remove avatar templates (wont use)
- [ ] Add digitální odměna
- [ ] Image uploads for all variants
- [ ] Rename SKU → Množství/Sklad; -1 = neomezeně
- [ ] Remove avatar typy (statický/animovaný/odemknutí vlastního) cannot be created/disabled
- Acceptance criteria:
- [ ] Admin UI simplified; types and fields as requested
## Zkrácené odkazy
- Status: [~] Fixing
- Issues:
- [ ] 400 errors on /api/v1/shortlinks and /api/v1/admin/shortlinks
- [x] 404 on YouTube thumbnail
- [ ] Console noise (service worker messages ok; others quiet)
- [ ] Specific shortlink not working (e.g., to zeusport)
- Acceptance criteria:
- [ ] API endpoints return 2xx; create/list works; redirects resolve
- [ ] Missing thumbnails handled gracefully (fallback)
## Prefetch & Cache
- Status: [x] Fully working
## Nastavení
- Status: [x] Fully working
## Uživatelé / Role
- Status: [~] Fixing
- Issues:
- [ ] Editor cannot access admin; should access selected pages by admin configuration
- [ ] Avoid 403 for allowed pages
- Acceptance criteria:
- [ ] Role-based per-page access; configurable; editor can view allowed pages
## Navigace (Admin)
- Status: [~] Fixing
- Issue:
- [ ] Drag between subcategories makes item primary (loses category)
- Acceptance criteria:
- [ ] Drag-and-drop across categories preserves/updates category correctly
---
## Notes
- Backend: Go/Gin with GORM and migrations
- Frontend: React + Chakra UI + React Query; Rich editor uses react-quill
- Test strategy: lightweight E2E via manual flows + debug console, with targeted unit/integration tests for fixes where feasible.
+25
View File
@@ -18,6 +18,7 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@tinymce/tinymce-react": "^6.3.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^18.2.45",
@@ -48,6 +49,7 @@
"react-scripts": "5.0.1",
"react-simple-maps": "^3.0.0",
"react-syntax-highlighter": "^15.6.6",
"tinymce": "^8.2.2",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"yup": "^1.3.3"
@@ -4193,6 +4195,24 @@
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tinymce/tinymce-react": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-6.3.0.tgz",
"integrity": "sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==",
"dependencies": {
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0",
"tinymce": "^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1"
},
"peerDependenciesMeta": {
"tinymce": {
"optional": true
}
}
},
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -19442,6 +19462,11 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tinymce": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-8.2.2.tgz",
"integrity": "sha512-CFDSZwciMvFGW2czK/Xig1HcOGpXI0qcQMIqaIcG2F4RuuTdf+LQTreyEZunAJoFTQ9L0KAugOqL7OA5TJkoAA=="
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
+2
View File
@@ -19,6 +19,7 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@tinymce/tinymce-react": "^6.3.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/react": "^18.2.45",
@@ -49,6 +50,7 @@
"react-scripts": "5.0.1",
"react-simple-maps": "^3.0.0",
"react-syntax-highlighter": "^15.6.6",
"tinymce": "^8.2.2",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4",
"yup": "^1.3.3"
@@ -179,6 +179,7 @@ const AdminSidebar = ({
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const seedFixRef = useRef<{ about: boolean }>({ about: false });
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
@@ -202,6 +203,23 @@ const AdminSidebar = ({
const hasSweepstakes = useMemo(() => hasItemDeep(it => (it.page_type === 'sweepstakes') || (it.url === '/admin/sweepstakes')), [hasItemDeep]);
const hasCompetitionAliases = useMemo(() => hasItemDeep(it => (it.page_type === 'competition_aliases') || (it.url === '/admin/aliasy-soutezi')), [hasItemDeep]);
const hasClothing = useMemo(() => hasItemDeep(it => (it.page_type === 'clothing') || (it.url === '/admin/obleceni')), [hasItemDeep]);
const hasAbout = useMemo(() => hasItemDeep(it => (it.page_type === 'about') || (it.url === '/admin/o-klubu')), [hasItemDeep]);
const hasVideos = useMemo(() => hasItemDeep(it => (it.page_type === 'videos') || (it.url === '/admin/videa')), [hasItemDeep]);
const hasGallery = useMemo(() => hasItemDeep(it => (it.page_type === 'gallery') || (it.url === '/admin/galerie')), [hasItemDeep]);
const hasScoreboard = useMemo(() => hasItemDeep(it => (it.page_type === 'scoreboard') || (it.url === '/admin/scoreboard')), [hasItemDeep]);
const hasScoreboardRemote = useMemo(() => hasItemDeep(it => (it.page_type === 'scoreboard_remote') || (it.url === '/admin/scoreboard/remote')), [hasItemDeep]);
const hasSponsors = useMemo(() => hasItemDeep(it => (it.page_type === 'sponsors') || (it.url === '/admin/sponzori')), [hasItemDeep]);
const hasBanners = useMemo(() => hasItemDeep(it => (it.page_type === 'banners') || (it.url === '/admin/bannery')), [hasItemDeep]);
const hasMessages = useMemo(() => hasItemDeep(it => (it.page_type === 'messages') || (it.url === '/admin/zpravy')), [hasItemDeep]);
const hasContacts = useMemo(() => hasItemDeep(it => (it.page_type === 'contacts') || (it.url === '/admin/kontakty')), [hasItemDeep]);
const hasNewsletter = useMemo(() => hasItemDeep(it => (it.page_type === 'newsletter') || (it.url === '/admin/newsletter')), [hasItemDeep]);
const hasPolls = useMemo(() => hasItemDeep(it => (it.page_type === 'polls') || (it.url === '/admin/ankety')), [hasItemDeep]);
const hasFiles = useMemo(() => hasItemDeep(it => (it.page_type === 'files') || (it.url === '/admin/soubory')), [hasItemDeep]);
const hasNavigation = useMemo(() => hasItemDeep(it => (it.page_type === 'navigation') || (it.url === '/admin/navigace')), [hasItemDeep]);
const hasUsers = useMemo(() => hasItemDeep(it => (it.page_type === 'users') || (it.url === '/admin/uzivatele')), [hasItemDeep]);
const hasSettingsPage = useMemo(() => hasItemDeep(it => (it.page_type === 'settings') || (it.url === '/admin/nastaveni')), [hasItemDeep]);
const hasAnalytics = useMemo(() => hasItemDeep(it => (it.page_type === 'analytics') || (it.url === '/admin/analytika')), [hasItemDeep]);
const hasPrefetch = useMemo(() => hasItemDeep(it => (it.page_type === 'prefetch') || (it.url === '/admin/prefetch')), [hasItemDeep]);
// Collapsed state for admin categories (dropdown items)
@@ -294,8 +312,32 @@ const AdminSidebar = ({
setNavItems(adminItems);
}
} else {
// If admin navigation exists but specific required items are missing (e.g., 'about'),
// trigger idempotent seed to backfill missing ones and reload once.
const hasAboutItem = adminItems.some(it => {
if (it.page_type === 'about') return true;
if (Array.isArray(it.children)) {
return it.children.some(c => c.page_type === 'about' || c.url === '/admin/o-klubu');
}
return false;
});
if (!hasAboutItem && isAdmin && !seedFixRef.current.about) {
try {
seedFixRef.current.about = true;
await seedDefaultNavigation();
const reloaded = await getAllNavigationItems();
if (active && Array.isArray(reloaded)) {
const reloadedAdmin = reloaded.filter(item => item.requires_admin);
setNavItems(reloadedAdmin);
}
} catch (e) {
console.warn('Seed backfill for about failed:', e);
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
}
}
}
} catch (error) {
console.error('Failed to load admin navigation:', error);
@@ -538,6 +580,161 @@ const AdminSidebar = ({
Oblečení
</NavItem>
)}
{/* Ensure About page (O klubu) and other core admin pages are present (admins only) */}
{isAdmin && !hasAbout && (
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
)}
{isAdmin && !hasVideos && (
<NavItem
icon={FaVideo}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
)}
{isAdmin && !hasGallery && (
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
)}
{isAdmin && !hasScoreboard && (
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
)}
{isAdmin && !hasScoreboardRemote && (
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
)}
{isAdmin && !hasSponsors && (
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
)}
{isAdmin && !hasBanners && (
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
)}
{isAdmin && !hasMessages && (
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
)}
{isAdmin && !hasContacts && (
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
)}
{isAdmin && !hasNewsletter && (
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
)}
{isAdmin && !hasPolls && (
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
)}
{isAdmin && !hasAnalytics && (
<NavItem
icon={FaChartBar}
to="/admin/analytika"
onClick={onClose}
>
Analytika
</NavItem>
)}
{isAdmin && !hasNavigation && (
<NavItem
icon={FaBars}
to="/admin/navigace"
onClick={onClose}
>
Navigace
</NavItem>
)}
{isAdmin && !hasUsers && (
<NavItem
icon={FaUsers}
to="/admin/uzivatele"
onClick={onClose}
>
Uživatelé
</NavItem>
)}
{isAdmin && !hasFiles && (
<NavItem
icon={FaFolder}
to="/admin/soubory"
onClick={onClose}
>
Soubory
</NavItem>
)}
{isAdmin && !hasSettingsPage && (
<NavItem
icon={FaPalette}
to="/admin/nastaveni"
onClick={onClose}
>
Nastavení
</NavItem>
)}
{isAdmin && !hasPrefetch && (
<NavItem
icon={FaSyncAlt}
to="/admin/prefetch"
onClick={onClose}
>
Prefetch & Cache
</NavItem>
)}
</>
) : (
// Fallback to hardcoded navigation
@@ -56,6 +56,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
const [loading, setLoading] = useState(false);
const [album, setAlbum] = useState<Album | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
const [visibleCount, setVisibleCount] = useState<number>(60);
const toast = useToast();
const handleFetchAlbum = async () => {
@@ -117,6 +118,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
photos: mappedPhotos,
});
setSelectedPhotos(new Set());
setVisibleCount(60);
toast({
title: 'Album načteno',
@@ -176,6 +178,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
setAlbumLink('');
setAlbum(null);
setSelectedPhotos(new Set());
setVisibleCount(60);
onClose();
};
@@ -269,7 +272,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
{/* Photos Grid */}
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{album.photos.map((photo) => (
{album.photos.slice(0, visibleCount).map((photo) => (
<Box
key={photo.id}
position="relative"
@@ -288,6 +291,8 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
w="100%"
h="150px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
<Checkbox
position="absolute"
@@ -301,6 +306,11 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
</Box>
))}
</SimpleGrid>
{album.photos.length > visibleCount && (
<HStack justify="center" pt={2}>
<Button size="sm" onClick={() => setVisibleCount((c) => c + 60)}>Načíst další</Button>
</HStack>
)}
</>
)}
</VStack>
@@ -5,7 +5,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
import { Article, getArticleMatchLink } from '../../services/articles';
import { API_URL } from '../../services/api';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml } from '../../services/instagram';
import { composeInstagramPostFromArticle, composeInstagramPostFromActivity, MatchSnapshot, stripHtml, formatDateTime, cleanVenue } from '../../services/instagram';
import { generateInstagramAI } from '../../services/ai';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -159,12 +159,13 @@ const InstagramGeneratorButton: React.FC<Props> = ({
content: stripHtml(article.content),
club_name: clubName,
link: sUrl || fullUrl,
category: (article as any)?.category?.name || (article as any)?.category_name,
match: resolvedMatch ? {
home: resolvedMatch.home,
away: resolvedMatch.away,
competition: resolvedMatch.competition,
date_time: resolvedMatch.date_time,
venue: resolvedMatch.venue,
date_time: resolvedMatch.date_time ? formatDateTime(resolvedMatch.date_time) : undefined,
venue: resolvedMatch.venue ? cleanVenue(resolvedMatch.venue) : undefined,
score: resolvedMatch.score,
} : undefined,
});
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButton, useColorModeValue, Spinner, Link as ChakraLink, Badge } from '@chakra-ui/react';
import { Box, VStack, HStack, Text, Heading, Textarea, Button, Avatar, IconButton, useColorModeValue, Spinner, Link as ChakraLink, Badge, Tooltip } from '@chakra-ui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
import { useAuth } from '../../contexts/AuthContext';
@@ -87,7 +87,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const reactMut = useMutation({
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
onSuccess: async () => {
onMutate: async ({ id, type }) => {
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
}
next.reactions[type] = (next.reactions[type] || 0) + 1;
next.my_reaction = type;
return next;
});
return { ...page, items };
});
return { ...oldData, pages };
});
return { previous };
},
onError: (_err, _vars, ctx) => {
const qk = ['comments', targetType, targetId] as const;
if ((ctx as any)?.previous) {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -95,7 +125,36 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
const unreactMut = useMutation({
mutationFn: (id: number) => unreactComment(id),
onSuccess: async () => {
onMutate: async (id: number) => {
const qk = ['comments', targetType, targetId] as const;
await queryClient.cancelQueries({ queryKey: qk });
const previous = queryClient.getQueryData<any>(qk);
queryClient.setQueryData(qk, (oldData: any) => {
if (!oldData) return oldData;
const pages = (oldData.pages || []).map((page: any) => {
const items = (page.items || []).map((it: any) => {
if (it.id !== id) return it;
const next = { ...it, reactions: { ...(it.reactions || {}) } };
const prevType = next.my_reaction as string | undefined;
if (prevType && typeof next.reactions[prevType] === 'number') {
next.reactions[prevType] = Math.max(0, (next.reactions[prevType] || 0) - 1);
}
next.my_reaction = '';
return next;
});
return { ...page, items };
});
return { ...oldData, pages };
});
return { previous };
},
onError: (_err, _vars, ctx) => {
const qk = ['comments', targetType, targetId] as const;
if ((ctx as any)?.previous) {
queryClient.setQueryData(qk, (ctx as any).previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
},
@@ -136,24 +195,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
}, [allItems]);
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
const options: { key: string; label: string }[] = [
{ key: 'thumbs_up', label: '👍' },
{ key: 'heart', label: '❤️' },
{ key: 'smile', label: '😀' },
{ key: 'surprised', label: '😮' },
{ key: 'thumbs_down', label: '👎' },
const options: { key: string; label: string; color: string; name: string }[] = [
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
];
const counts = c.reactions || {};
const active = c.my_reaction;
const isBusy = reactMut.isPending || unreactMut.isPending;
return (
<HStack spacing={2} mt={1}>
{options.map((o) => (
<Button key={o.key} size="xs" variant={active === o.key ? 'solid' : 'outline'} onClick={() => {
<Tooltip key={o.key} label={o.name} placement="top" hasArrow>
<Button
size="xs"
colorScheme={o.color}
variant={active === o.key ? 'solid' : 'outline'}
isDisabled={!isAuthenticated || isBusy}
aria-pressed={active === o.key}
onClick={() => {
if (!isAuthenticated) return;
if (active === o.key) unreactMut.mutate(c.id); else reactMut.mutate({ id: c.id, type: o.key });
}}>
<HStack spacing={1}><Text as="span">{o.label}</Text><Text as="span" fontSize="xs">{counts[o.key] || 0}</Text></HStack>
if (active === o.key) {
unreactMut.mutate(c.id);
} else {
reactMut.mutate({ id: c.id, type: o.key });
}
}}
>
<HStack spacing={1}>
<Text as="span">{o.label}</Text>
<Text as="span" fontSize="xs">{counts[o.key] || 0}</Text>
</HStack>
</Button>
</Tooltip>
))}
</HStack>
);
@@ -25,7 +25,7 @@ import {
import ReactQuill from 'react-quill';
import ReactCrop, { Crop } from 'react-image-crop';
import DOMPurify from 'dompurify';
import 'react-quill/dist/quill.snow.css';
import 'quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
import '../../styles/custom-editor.css';
import {
@@ -74,7 +74,6 @@ 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);
@@ -99,7 +98,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [cropFile, setCropFile] = useState<File | null>(null);
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
const [cropQuality, setCropQuality] = useState<number>(85);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1920);
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1600);
const [cropProcessing, setCropProcessing] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
@@ -137,24 +136,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [imageWidth, setImageWidth] = useState<number>(0);
const [manualWidth, setManualWidth] = useState<string>('');
const [widthPercent, setWidthPercent] = useState<number>(0);
const [isListStyleOpen, setIsListStyleOpen] = useState(false);
// Helper: wait for Quill editor/root to exist in DOM before manipulating toolbar or attaching listeners
const withEditor = useCallback((fn: (ed: any) => void) => {
let attempts = 0;
const tryRun = () => {
const ed = quillRef.current?.getEditor();
if (ed && ed.root && typeof document !== 'undefined' && document.contains(ed.root)) {
try { fn(ed); } catch {}
return;
}
if (attempts < 40) {
attempts++;
setTimeout(tryRun, 25);
}
};
tryRun();
}, []);
// Define toolbar configurations
const toolbarConfigs = {
@@ -162,7 +143,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['blockquote'],
@@ -171,8 +152,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
basic: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image'],
['clean'],
@@ -254,92 +234,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setIsLinkOpen(true);
}, []);
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).style.listStyleType = style;
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
// Toggle bullet style through toolbar handler
const toggleListStyle = useCallback(() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
let el: HTMLElement | null = (line as any)?.domNode as HTMLElement | null;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
const current = (el.style.listStyleType || '').toLowerCase();
const next: 'disc' | 'circle' | 'square' = current === 'disc' ? 'circle' : current === 'circle' ? 'square' : 'disc';
applyBulletStyle(next);
} else {
quill.format('list', 'bullet');
setTimeout(() => {
try {
const [ln] = quill.getLine(range.index);
let n: HTMLElement | null = (ln as any)?.domNode as HTMLElement | null;
while (n && n.tagName !== 'UL' && n !== quill.root) n = n.parentElement;
if (n && n.tagName === 'UL') {
(n as HTMLElement).style.listStyleType = 'disc';
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
} catch {}
}, 0);
}
}, [applyBulletStyle]);
const quillModules = useMemo(() => ({
toolbar: {
container: toolbarConfig,
handlers: {
image: onImageUpload ? handleImageUpload : undefined,
link: handleLinkToolbar,
liststyle: toggleListStyle,
list: (value: any) => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
quill.format('list', value);
if (value === 'bullet') {
setTimeout(() => setIsListStyleOpen(true), 0);
}
},
},
},
clipboard: {
matchVisual: false,
},
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar, toggleListStyle]);
useEffect(() => {
if (!isMounted) return;
let active = true;
withEditor((ed) => {
if (!active) return;
try {
const toolbarEl = ed.root.parentElement?.previousElementSibling as HTMLElement | null;
const btn = toolbarEl?.querySelector('.ql-liststyle') as HTMLButtonElement | null;
if (btn) btn.setAttribute('title', 'Styl odrážek');
} catch {}
});
return () => { active = false; };
}, [isMounted, toolbarConfig, withEditor]);
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
const quillFormats = useMemo(
() => [
@@ -363,9 +269,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Localize Quill toolbar tooltips/labels to Czech
useEffect(() => {
if (!isMounted) return;
let active = true;
withEditor((editor) => {
if (!active) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
@@ -401,62 +306,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Colors and background
setTitle('.ql-color .ql-picker-label', 'Barva textu');
setTitle('.ql-background .ql-picker-label', 'Barva 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');
setTitle('.ql-header .ql-picker-item[data-value="1"]', 'Nadpis 1');
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
setTitle('button.ql-liststyle', 'Styl odrážek');
});
return () => { active = false; };
}, [isMounted, toolbar, withEditor]);
// (Removed) Previously injected custom bullet-style group; now using a single toolbar button 'liststyle'.
}, [isMounted, toolbar]);
// Get cropped blob
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
@@ -592,13 +448,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
try { targetImg.setAttribute('width', String(px)); } catch {}
}
} catch {}
try {
if (document.contains(quill.root)) {
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(index + 1, 0, 'api'); } catch {} }, 0);
}
} catch {}
// Persist content so default width is saved
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
@@ -627,7 +478,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setCropFile(null);
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
setCropQuality(85);
setCropMaxWidth(1920);
setCropMaxWidth(1600);
}
};
@@ -635,6 +486,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
const enableDragReposition = true;
let selectedImage: HTMLImageElement | null = null;
let resizeHandle: HTMLDivElement | null = null;
@@ -658,7 +510,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
`;
// Position relative to Quill container (parent of .ql-editor)
const editorContainer = editor.root?.parentElement as HTMLElement | null;
const editorContainer = editor.root.parentElement as HTMLElement | null;
if (!editorContainer) return null;
const sizeLabel = document.createElement('div');
sizeLabel.style.cssText = `
@@ -678,18 +530,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
try {
const edW = editor.root.clientWidth || w || 1;
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)`;
const idAttr = img.getAttribute('data-img-id') || '';
sizeLabel.textContent = `${Math.round(w)} px (${pct}%)${idAttr ? `${idAttr}` : ''}`;
} catch {
sizeLabel.textContent = `${Math.round(w)} px`;
const idAttr = img.getAttribute('data-img-id') || '';
sizeLabel.textContent = `${Math.round(w)} px${idAttr ? `${idAttr}` : ''}`;
}
};
// Create edge handles (right, bottom, left, top)
// Only corner handles (edge dragging disabled)
const handles = [
{ position: 'right', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'bottom', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'left', cursor: 'ew-resize', width: '12px', height: '60%' },
{ position: 'top', cursor: 'ns-resize', width: '60%', height: '12px' },
{ position: 'bottom-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
@@ -791,27 +641,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const aspectRatio = startWidth / startHeight;
let lastWidth = startWidth;
// Reduce selection/paint costs during resize
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let frame = 0;
let pendingWidth: number | null = null;
const flush = () => {
frame = 0;
if (pendingWidth == null) return;
const newWidth = pendingWidth;
pendingWidth = null;
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(flush);
};
const onPointerMove = (ev: PointerEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -825,23 +654,23 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
newWidth = startWidth + (deltaY * aspectRatio);
}
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
lastWidth = newWidth;
pendingWidth = newWidth;
schedule();
img.style.width = `${newWidth}px`;
img.style.maxWidth = '100%';
img.style.height = 'auto';
try { img.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(newWidth.toString());
try {
const editorWidth = editor.root.clientWidth || newWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
updateHandlePositions();
updateSizeLabel(newWidth);
};
const onPointerUp = () => {
isResizing = false;
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
if (frame) cancelAnimationFrame(frame);
if (pendingWidth != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
const id = selectedImageIdRef.current;
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
@@ -890,7 +719,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
// Prevent default drag behavior to avoid duplication
img.setAttribute('draggable', 'false');
img.setAttribute('draggable', enableDragReposition ? 'true' : 'false');
createResizeHandle(img);
@@ -976,20 +805,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const handleImageClick = (e: Event) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
// Support images wrapped in anchors or other elements (e.g., Zonerama links)
const imgEl = target.tagName === 'IMG' ? (target as HTMLImageElement) : (target.closest('img') as HTMLImageElement | null);
if (imgEl) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
// In read-only mode, show preview instead of selecting
if (readOnly) {
const imgSrc = (target as HTMLImageElement).src;
const imgSrc = imgEl.src;
setPreviewImage(imgSrc);
setIsPreviewOpen(true);
return;
}
selectImage(target as HTMLImageElement);
selectImage(imgEl);
return; // Important: return early to prevent further processing
}
@@ -1013,13 +844,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const handleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG' && selectedImage === target) {
if (enableDragReposition) {
return;
}
// Allow edge-drag fallback resize if overlay handle doesn't catch it
const rect = target.getBoundingClientRect();
const nearLeft = e.clientX < rect.left + 16;
const nearRight = e.clientX > rect.right - 16;
const nearTop = e.clientY < rect.top + 16;
const nearBottom = e.clientY > rect.bottom - 16;
if (nearLeft || nearRight || nearTop || nearBottom) {
if (false) {
e.preventDefault();
e.stopPropagation();
isResizing = true;
@@ -1029,22 +863,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const startHeight = (target as HTMLImageElement).offsetHeight;
const aspectRatio = startWidth / Math.max(1, startHeight);
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
let lastWidth = startWidth;
try { (editor.root as HTMLElement).style.userSelect = 'none'; } catch {}
let raf = 0;
let queued: number | null = null;
const flush = () => {
raf = 0;
if (queued == null) return;
const newWidth = queued; queued = null;
const imgEl = target as HTMLImageElement;
imgEl.style.width = `${newWidth}px`;
imgEl.style.maxWidth = '100%';
imgEl.style.height = 'auto';
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
handleScroll();
};
const schedule = () => { if (!raf) raf = requestAnimationFrame(flush); };
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
if (!isResizing) return;
const deltaX = ev.clientX - startX;
@@ -1054,27 +873,29 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
else if (edge === 'left') newWidth = startWidth - deltaX;
else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio);
else if (edge === 'top') newWidth = startWidth - (deltaY * aspectRatio);
const maxW = editor.root.clientWidth - 40;
const maxW = (editor?.root?.clientWidth ?? (startWidth || 1200)) - 40;
newWidth = Math.max(50, Math.min(newWidth, maxW));
lastWidth = newWidth;
queued = newWidth;
schedule();
const imgEl = target as HTMLImageElement;
imgEl.style.width = `${newWidth}px`;
imgEl.style.maxWidth = '100%';
imgEl.style.height = 'auto';
try { imgEl.setAttribute('width', String(Math.round(newWidth))); } catch {}
setImageWidth(newWidth);
setManualWidth(String(Math.round(newWidth)));
try {
const editorWidth = editor?.root?.clientWidth ?? newWidth ?? 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((newWidth / editorWidth) * 100))));
} catch {}
handleScroll();
};
const onMouseUp = () => {
isResizing = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (raf) cancelAnimationFrame(raf);
if (queued != null) flush();
try { (editor.root as HTMLElement).style.userSelect = ''; } catch {}
setImageWidth(lastWidth);
setManualWidth(String(Math.round(lastWidth)));
try {
const editorWidth = editor.root.clientWidth || lastWidth || 1;
setWidthPercent(Math.max(1, Math.min(100, Math.round((lastWidth / editorWidth) * 100))));
} catch {}
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
if (editor) { onChangeRef.current(cleanEditorHTML(editor.root.innerHTML)); }
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
@@ -1176,20 +997,69 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
};
// Prevent default drag behavior on images
// Drag & drop repositioning for images inside editor
let draggedImage: HTMLImageElement | null = null;
const handleDragStart = (e: DragEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'IMG') {
e.preventDefault();
e.stopPropagation();
return false;
if (target && target.tagName === 'IMG') {
draggedImage = target as HTMLImageElement;
try {
e.dataTransfer?.setData('text/plain', (target as HTMLImageElement).src || '');
e.dataTransfer!.effectAllowed = 'move';
} catch {}
}
};
const handleDragOver = (e: DragEvent) => {
if (draggedImage) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
}
};
const handleDrop = (e: DragEvent) => {
if (!draggedImage) return;
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
// Place caret to drop coordinates
try {
const sel = window.getSelection();
const anyDoc: any = document as any;
const docWithCaretRange = document as Document & {
caretRangeFromPoint?: (x: number, y: number) => Range;
};
const range = docWithCaretRange.caretRangeFromPoint
? docWithCaretRange.caretRangeFromPoint.call(document, e.clientX, e.clientY)
: typeof anyDoc.caretPositionFromPoint === 'function'
? (() => { const pos = anyDoc.caretPositionFromPoint(e.clientX, e.clientY); const r = document.createRange(); r.setStart(pos.offsetNode, pos.offset); r.setEnd(pos.offsetNode, pos.offset); return r; })()
: null;
if (range && sel) {
sel.removeAllRanges();
sel.addRange(range);
}
} catch {}
const dropRange = q.getSelection(true) || { index: q.getLength(), length: 0 };
const src = draggedImage.src;
// Remove original image node
try { draggedImage.remove(); } catch {}
// Insert at new location
q.insertEmbed(dropRange.index, 'image', src, 'user');
q.setSelection(dropRange.index + 1, 0, 'user');
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
// Reposition overlay if same image was selected
const id = selectedImageIdRef.current;
if (id) { setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30); }
draggedImage = null;
};
editor.root.addEventListener('click', handleImageClick);
editor.root.addEventListener('mousedown', handleMouseDown);
editor.root.addEventListener('scroll', handleScroll);
editor.root.addEventListener('dragstart', handleDragStart);
const root = editor.root as HTMLElement;
root.addEventListener('click', handleImageClick);
root.addEventListener('scroll', handleScroll);
if (enableDragReposition) {
root.addEventListener('dragstart', handleDragStart);
root.addEventListener('dragover', handleDragOver);
root.addEventListener('drop', handleDrop);
}
document.addEventListener('keydown', handleKeyDown);
// Also reposition on window resize and any document scroll (capture phase)
window.addEventListener('resize', handleScroll);
@@ -1197,9 +1067,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return () => {
editor.root.removeEventListener('click', handleImageClick);
editor.root.removeEventListener('mousedown', handleMouseDown);
editor.root.removeEventListener('scroll', handleScroll);
editor.root.removeEventListener('dragstart', handleDragStart);
const root = editor.root as HTMLElement;
root.removeEventListener('scroll', handleScroll);
if (enableDragReposition) {
root.removeEventListener('dragstart', handleDragStart);
root.removeEventListener('dragover', handleDragOver);
root.removeEventListener('drop', handleDrop);
}
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
@@ -1209,6 +1083,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
};
}, [readOnly, toast, isMounted]);
// Auto-resize very large images (e.g., pasted/Zonerama) to a comfortable width for editing
useEffect(() => {
const editor = quillRef.current?.getEditor();
if (!editor || readOnly) return;
const root = editor.root as HTMLElement;
const COMFORTABLE_MAX = 1600; // px
const processImg = async (img: HTMLImageElement) => {
try {
if (!img || img.getAttribute('data-auto-resized') === '1') return;
const doResize = async () => {
// Skip if we've already processed or if image is already small
const natW = img.naturalWidth || 0;
if (natW > COMFORTABLE_MAX) {
try {
toast({ title: 'Optimalizace velkého obrázku…', status: 'info', duration: 1500 });
} catch {}
try {
const res = await quickEditImage({ image_url: img.src, width: COMFORTABLE_MAX, quality: 85 });
if (res?.url) {
const newUrl = assetUrl(res.url) || res.url;
img.src = newUrl;
img.setAttribute('data-auto-resized', '1');
img.style.maxWidth = '100%';
img.style.height = 'auto';
const q = quillRef.current?.getEditor();
if (q) {
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
// If this image is selected, reselect to reposition overlay
const id = selectedImageIdRef.current;
if (id) setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30);
}
}
} catch (e) {
console.error('Auto-resize failed', e);
}
}
};
if (img.complete) doResize();
else img.addEventListener('load', () => doResize(), { once: true });
} catch {}
};
// Initial scan
root.querySelectorAll('img').forEach((n) => processImg(n as HTMLImageElement));
// Observe changes
const mo = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node instanceof HTMLImageElement) processImg(node);
else if (node instanceof HTMLElement) node.querySelectorAll?.('img').forEach((el) => processImg(el as HTMLImageElement));
});
});
});
mo.observe(root, { childList: true, subtree: true });
return () => mo.disconnect();
}, [readOnly, isMounted, toast]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
const filterString = `
@@ -1229,6 +1162,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
img.style.filter = filterString;
img.style.transform = transform;
img.style.transformOrigin = "center center";
img.setAttribute('data-filters', JSON.stringify(filters));
}, []);
@@ -1265,6 +1199,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active and overlay positioned after DOM update
const id = selectedImageIdRef.current;
if (id) {
setTimeout(() => { try { selectImageByIdRef.current?.(id); } catch {} }, 30);
}
}
return newFilters;
@@ -1387,6 +1327,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
// Keep selection active for subsequent operations (e.g., 50% → 75%)
reselectAfterContentUpdate();
@@ -1458,11 +1399,140 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}, [selectedImageElement, toast]);
// Defer heavy sanitization to submit time to prevent selection glitches; keep minimal cleanup only
// Sanitize HTML on change and keep author-selected colors intact
const handleChange = (content: string) => {
onChangeRef.current(content);
// First sanitize
let cleaned = DOMPurify.sanitize(content, {
USE_PROFILES: { html: true },
ADD_TAGS: ['iframe'],
ADD_ATTR: ['target', 'rel', 'allow', 'allowfullscreen', 'style', 'data-filters', 'data-img-id', 'data-bullets', 'data-list'],
});
onChangeRef.current(cleanEditorHTML(cleaned));
};
// Apply bullet style (disc | circle | square) to the current list
const applyBulletStyle = useCallback((style: 'disc' | 'circle' | 'square') => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const range = quill.getSelection();
if (!range) return;
const [line] = quill.getLine(range.index);
const node = (line as any)?.domNode as HTMLElement | null;
if (!node) return;
// find nearest UL and set custom data attribute for CSS-based bullet override (Quill v2)
let el: HTMLElement | null = node;
while (el && el.tagName !== 'UL' && el !== quill.root) {
el = el.parentElement;
}
if (el && el.tagName === 'UL') {
(el as HTMLElement).setAttribute('data-bullets', style);
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
}
}, [onChangeRef]);
// Enhance toolbar: add bullet-style popover and color reset buttons
useEffect(() => {
if (!isMounted) return;
const editor = quillRef.current?.getEditor();
if (!editor) return;
const container = editor.root?.parentElement; // .ql-container
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
if (!toolbarEl) return;
// Add reset buttons next to color/background pickers
const addResetButton = (selector: string, className: string, formatName: 'color' | 'background') => {
const picker = toolbarEl.querySelector(selector) as HTMLElement | null;
if (picker && !toolbarEl.querySelector(`button.${className}`)) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = className;
btn.setAttribute('title', formatName === 'color' ? 'Reset barvy textu' : 'Reset barvy pozadí');
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
q.format(formatName, false, 'user');
onChangeRef.current(cleanEditorHTML(q.root.innerHTML));
});
(picker.parentElement as HTMLElement)?.insertBefore(btn, picker.nextSibling);
}
};
addResetButton('.ql-color .ql-picker', 'ql-colorreset', 'color');
addResetButton('.ql-background .ql-picker', 'ql-bgreset', 'background');
// Create bullet styles popover and attach to bullet list button
const bulletBtn = toolbarEl.querySelector('button.ql-list[value="bullet"]') as HTMLButtonElement | null;
if (!bulletBtn) return;
let popover = toolbarEl.querySelector('.bullet-style-popover') as HTMLDivElement | null;
if (!popover) {
popover = document.createElement('div');
popover.className = 'bullet-style-popover';
popover.style.cssText = 'position:absolute;display:none;background:#fff;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.15);padding:6px;gap:6px;z-index:1000;';
const mk = (label: string, st: 'disc'|'circle'|'square') => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ql-bulletstyle';
b.textContent = label;
b.style.cssText = 'min-width:32px;height:28px;padding:0 8px;border-radius:6px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;';
b.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const q = quillRef.current?.getEditor();
if (!q) return;
const range = q.getSelection(true);
if (range) {
q.format('list', 'bullet', 'user');
applyBulletStyle(st);
}
if (popover) popover.style.display = 'none';
});
b.addEventListener('mouseenter', () => { b.style.background = '#f7fafc'; });
b.addEventListener('mouseleave', () => { b.style.background = '#fff'; });
return b;
};
popover.appendChild(mk('•', 'disc'));
popover.appendChild(mk('○', 'circle'));
popover.appendChild(mk('▪', 'square'));
toolbarEl.appendChild(popover);
}
let hideTimer: number | null = null;
const show = () => {
if (!popover) return;
const rect = bulletBtn.getBoundingClientRect();
const tRect = toolbarEl.getBoundingClientRect();
popover.style.left = `${rect.left - tRect.left}px`;
popover.style.top = `${rect.bottom - tRect.top + 6}px`;
popover.style.display = 'flex';
};
const toggle = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!popover) return;
if (popover.style.display === 'flex') {
popover.style.display = 'none';
} else {
show();
}
};
const scheduleHide = () => {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => { if (popover) popover.style.display = 'none'; }, 200);
};
const cancelHide = () => { if (hideTimer) { window.clearTimeout(hideTimer); hideTimer = null; } };
bulletBtn.addEventListener('mouseenter', show);
bulletBtn.addEventListener('click', toggle);
bulletBtn.addEventListener('mouseleave', scheduleHide);
popover.addEventListener('mouseenter', cancelHide);
popover.addEventListener('mouseleave', scheduleHide);
return () => {
bulletBtn.removeEventListener('mouseenter', show);
bulletBtn.removeEventListener('click', toggle);
bulletBtn.removeEventListener('mouseleave', scheduleHide);
popover && popover.removeEventListener('mouseenter', cancelHide);
popover && popover.removeEventListener('mouseleave', scheduleHide);
};
}, [isMounted, applyBulletStyle]);
const insertOrUpdateLink = useCallback(() => {
const quill = quillRef.current?.getEditor();
@@ -1479,22 +1549,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Replace selected text with provided text and link
quill.deleteText(range.index, range.length, 'user');
quill.insertText(range.index, text || url, 'link', url, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
} else {
quill.insertText(range.index, text || url, 'link', url, 'user');
try {
if (document.contains(quill.root)) {
quill.setSelection(range.index + (text || url).length, 0, 'user');
} else {
setTimeout(() => { try { if (document.contains(quill.root)) quill.setSelection(range.index + (text || url).length, 0, 'user'); } catch {} }, 0);
}
} catch {}
}
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
@@ -1522,7 +1580,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</Text>
</HStack>
)}
<Box display="none" />
</VStack>
)}
@@ -1533,7 +1590,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderRadius="md"
overflow="visible"
bg={bgColor}
ref={containerRef}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
@@ -1587,11 +1643,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '8px',
},
'& .ql-liststyle::before': {
content: '"•◦▪"',
fontSize: '14px',
fontWeight: 'bold',
},
},
'.ql-container': {
fontSize: '16px',
@@ -1675,12 +1726,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
height: 'auto',
display: 'block',
margin: '12px 0',
transition: 'box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease',
transition: 'all 0.2s ease',
borderRadius: '4px',
userSelect: 'none',
pointerEvents: 'auto',
WebkitUserDrag: 'none',
userDrag: 'none',
'&:hover': {
opacity: 0.95,
transform: 'scale(1.01)',
@@ -1704,18 +1753,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
ref={quillRef}
modules={quillModules}
formats={quillFormats}
onBlur={(_prev, _source, editor) => {
try {
const ed = quillRef.current?.getEditor();
const html = editor?.getHTML ? editor.getHTML() : (ed?.root?.innerHTML || value);
const cleaned = cleanEditorHTML(html);
if (cleaned !== value) {
setTimeout(() => {
try { onChangeRef.current(cleaned); } catch {}
}, 0);
}
} catch {}
}}
/>
)}
</Box>
@@ -2111,47 +2148,11 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={() => setIsLinkOpen(false)}>Zrušit</Button>
<Button
variant="outline"
colorScheme="red"
mr={3}
onClick={() => {
const quill = quillRef.current?.getEditor();
if (!quill) return;
const r = quill.getSelection() || linkRangeRef.current || { index: quill.getLength(), length: 0 };
quill.format('link', false);
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
setIsLinkOpen(false);
setLinkText('');
setLinkUrl('');
}}
>
Odstranit odkaz
</Button>
<Button colorScheme="blue" onClick={insertOrUpdateLink}>Vložit</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Bullet Style Modal */}
<Modal isOpen={isListStyleOpen} onClose={() => setIsListStyleOpen(false)} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>Styl odrážek</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={2}>
<Button onClick={() => { applyBulletStyle('disc'); setIsListStyleOpen(false); }}> Plné tečky</Button>
<Button onClick={() => { applyBulletStyle('circle'); setIsListStyleOpen(false); }}> Kroužky</Button>
<Button onClick={() => { applyBulletStyle('square'); setIsListStyleOpen(false); }}> Čtverečky</Button>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setIsListStyleOpen(false)}>Zavřít</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Crop Modal */}
{/* Image Preview Modal */}
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
+63 -18
View File
@@ -3,13 +3,12 @@ import {
Button,
HStack,
Icon,
Link as ChakraLink,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import {
FiExternalLink,
FiDownload,
FiFile,
FiFileText,
FiImage,
@@ -24,6 +23,7 @@ export interface FilePreviewProps {
mimeType?: string;
size?: number;
showInline?: boolean;
buttonOnly?: boolean;
}
const FilePreview: React.FC<FilePreviewProps> = ({
@@ -31,10 +31,20 @@ const FilePreview: React.FC<FilePreviewProps> = ({
name,
mimeType = '',
size,
buttonOnly = false,
}) => {
const fullUrl = assetUrl(url) || url;
const fileName = name || url.split('/').pop() || 'file';
const shortenName = (n: string, max = 34) => {
const base = String(n || '').trim();
if (base.length <= max) return base;
const dot = base.lastIndexOf('.');
const ext = dot > 0 ? base.slice(dot) : '';
const keep = Math.max(10, max - (ext.length + 3));
return `${base.slice(0, keep)}${ext}`;
};
const displayName = shortenName(fileName);
const mime = mimeType.toLowerCase();
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -72,7 +82,51 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
// Simplified preview: only provide an "Open in new window" action
// Action button handler
const handleDownload = async () => {
const fallback = () => {
try {
const a = document.createElement('a');
a.href = fullUrl;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} catch {}
};
try {
const res = await fetch(fullUrl);
if (!res.ok) return fallback();
const blob = await res.blob();
const urlObj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = urlObj;
a.setAttribute('download', fileName);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(urlObj), 2000);
} catch {
fallback();
}
};
// Button-only compact variant (for tight sidebars like Přílohy)
if (buttonOnly) {
return (
<Button
size="xs"
leftIcon={<FiDownload />}
colorScheme="blue"
variant="outline"
onClick={handleDownload}
>
Stáhnout
</Button>
);
}
// Default: row with icon, truncated name and action
return (
<HStack
justify="space-between"
@@ -81,30 +135,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
flexWrap="wrap"
w="100%"
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text
fontWeight="medium"
isTruncated
maxW="100%"
>
{fileName}
<Text fontWeight="medium" isTruncated maxW="100%">
{displayName}
</Text>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
<HStack spacing={2} flexShrink={0}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiExternalLink />}
colorScheme="blue"
>
Otevřít v novém okně
<Button size="sm" leftIcon={<FiDownload />} colorScheme="blue" onClick={handleDownload}>
Stáhnout
</Button>
</HStack>
</HStack>
+5 -78
View File
@@ -10,12 +10,10 @@ import {
HStack,
Button,
Text,
useToast,
IconButton,
VStack,
Link,
} from '@chakra-ui/react';
import { Download, ExternalLink } from 'lucide-react';
import { API_URL } from '../../services/api';
import { ExternalLink } from 'lucide-react';
interface PhotoModalProps {
isOpen: boolean;
@@ -32,47 +30,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
pageUrl,
albumTitle,
}) => {
const toast = useToast();
const getProxyUrl = (url: string) => {
return `${API_URL}/gallery/proxy-image?url=${encodeURIComponent(url)}`;
};
const handleDownload = async () => {
try {
const response = await fetch(getProxyUrl(photoUrl));
if (!response.ok) {
throw new Error('Failed to fetch image');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fotka-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
toast({
title: 'Stahování zahájeno',
description: 'Fotka se stahuje',
status: 'success',
duration: 2000,
isClosable: true,
});
} catch (error) {
console.error('Failed to download image:', error);
toast({
title: 'Chyba',
description: 'Nepodařilo se stáhnout obrázek',
status: 'error',
duration: 2000,
isClosable: true,
});
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
@@ -107,6 +65,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
objectFit="contain"
loading="lazy"
/>
</Box>
{/* Controls */}
@@ -125,16 +84,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
</Text>
)}
<HStack spacing={2} justify="space-between" flexWrap="wrap">
<HStack spacing={2}>
<Button
leftIcon={<Download size={18} />}
onClick={handleDownload}
colorScheme="green"
size="sm"
>
Stáhnout
</Button>
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
<Button
as="a"
href={pageUrl}
@@ -147,31 +97,8 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
Zobrazit originál
</Button>
</HStack>
</HStack>
{/* Zonerama Copyright */}
<Box
pt={2}
borderTopWidth="1px"
borderColor="gray.200"
>
<HStack spacing={2} fontSize="xs" color="gray.500">
<Text>
© Fotografie z{' '}
<Text
as="a"
href="https://zonerama.com"
target="_blank"
rel="noopener noreferrer"
color="blue.500"
fontWeight="600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</HStack>
</Box>
{/* Attribution moved into image overlay */}
</VStack>
</Box>
</VStack>
@@ -167,7 +167,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
</Button>
</HStack>
{/* Zonerama Attribution */}
{/* Zonerama Attribution (single source of truth) */}
<Box
bg={infoBg}
borderWidth="1px"
@@ -177,7 +177,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
py={2}
>
<Text fontSize="xs" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
© Fotografie z{' '}
<Text
as="a"
href={zoneramaUrl || profileUrl || 'https://zonerama.com'}
@@ -74,26 +74,6 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
</Box>
)}
{/* Zonerama Attribution */}
{albums.length > 0 && (
<Box bg="blue.50" borderWidth="1px" borderColor="blue.200" color="blue.800" p={2} borderRadius="md" mb={3} fontSize="xs">
<Text>
📸 Fotografie z{' '}
<Text
as="a"
href={zoneramaUrl || 'https://zonerama.com'}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
)}
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
@@ -191,6 +191,26 @@ const VideosSection: React.FC<Props> = ({ videos, variant }) => {
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
data-fallback-idx={0 as any}
onError={(e: any) => {
try {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = it.videoId || '';
const chain = id
? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/dist/img/logo-club-empty.svg',
]
: ['/dist/img/logo-club-empty.svg'];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
} catch {}
}}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
+12 -1
View File
@@ -19,6 +19,8 @@ interface EmbeddedPollProps {
title?: string;
showTitle?: boolean;
maxPolls?: number;
// When true, render without outer background/padding so parent wrapper controls layout
unstyled?: boolean;
}
/**
@@ -32,6 +34,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
title = 'Hlasování',
showTitle = true,
maxPolls,
unstyled = false,
}) => {
const bgSection = useColorModeValue('gray.50', 'gray.900');
@@ -100,8 +103,13 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
return null;
}
// Wrapper styling: allow transparent/compact when unstyled
const wrapperProps = unstyled
? { bg: 'transparent', py: 0, px: 0, borderRadius: 'none' as any, my: 0 }
: { bg: bgSection, py: 8, px: 4, borderRadius: 'xl' as any, my: 8 };
return (
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
<Box {...wrapperProps}>
<VStack spacing={6} maxW="6xl" mx="auto">
{showTitle && (
<Heading size="md" textAlign="center">
@@ -139,6 +147,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
);
@@ -153,6 +162,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
))}
@@ -168,6 +178,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
hasVoted={pollResponse.has_voted}
isActive={pollResponse.is_active}
canShowResults={pollResponse.can_show_results}
flat={unstyled}
/>
</Box>
))}
+13 -10
View File
@@ -40,6 +40,8 @@ interface PollCardProps {
isActive: boolean;
canShowResults: boolean;
onVoteSuccess?: () => void;
// When true, render transparent card without own bg/border/shadow
flat?: boolean;
}
const PollCard: React.FC<PollCardProps> = ({
@@ -48,6 +50,7 @@ const PollCard: React.FC<PollCardProps> = ({
isActive,
canShowResults: initialCanShowResults,
onVoteSuccess,
flat = false,
}) => {
const toast = useToast();
const queryClient = useQueryClient();
@@ -254,12 +257,12 @@ const PollCard: React.FC<PollCardProps> = ({
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -319,12 +322,12 @@ const PollCard: React.FC<PollCardProps> = ({
// Show voting form
return (
<Box
bg={bgCard}
borderWidth="1px"
borderColor={borderColor}
bg={flat ? 'transparent' : bgCard}
borderWidth={flat ? '0' : '1px'}
borderColor={flat ? 'transparent' : borderColor}
borderRadius="xl"
p={6}
boxShadow="md"
p={flat ? 0 : 6}
boxShadow={flat ? 'none' : 'md'}
>
<VStack spacing={4} align="stretch">
{poll.image_url && (
@@ -53,13 +53,10 @@ const styleBlock = `
.scoreboard { display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.75); color: #ffffff; padding: 18px 28px; font-size: 32px; font-weight: 700; border-radius: 14px; width: min(90vw, 900px); margin: 24px auto; gap: 20px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); backdrop-filter: blur(6px); border: 1px solid rgba(255,255,255,0.15); }
.scoreboard.pill { background: var(--pill-bg, #f8fafc); color: var(--pill-text, #0f172a); border: 1px solid #e5e7eb; box-shadow: 0 10px 30px rgba(2,6,23,0.18); border-radius: 999px; padding: 4px 6px; width: max-content; margin: 0 auto; gap: 6px; backdrop-filter: none; font-size: 15px; transform: scale(var(--pill-scale, 1.7)); transform-origin: center; will-change: transform; }
.scoreboard.pill .seg { display: flex; align-items: center; justify-content: center; height: 36px; }
.scoreboard.pill .seg.timer { font-variant-numeric: tabular-nums; font-weight: 800; background: linear-gradient(180deg, #eef2f7 0%, #e2e8f0 100%); padding: 0 8px; border-radius: 999px; font-size: 15px; color: #0f172a; }
.scoreboard.pill .seg.team { color: #ffffff; padding: 0 10px; border-radius: 10px; font-weight: 800; letter-spacing: 0.5px; min-width: 46px; text-transform: uppercase; position: relative; overflow: visible; }
.scoreboard.pill .seg.team.home { background: linear-gradient(90deg, var(--home-dark), var(--home-light)); }
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); }
.scoreboard.pill .seg.team.home::before, .scoreboard.pill .seg.team.away::after { position: absolute; top: 0; width: 12px; height: 100%; background: inherit; content: ''; }
.scoreboard.pill .seg.team.home::before { left: -6px; border-top-left-radius: 999px; border-bottom-left-radius: 999px; }
.scoreboard.pill .seg.team.away::after { right: -6px; border-top-right-radius: 999px; border-bottom-right-radius: 999px; }
.scoreboard.pill .seg.timer { font-variant-numeric: tabular-nums; font-weight: 800; background: linear-gradient(180deg, #eef2f7 0%, #e2e8f0 100%); padding: 0 12px 0 8px; border-radius: 999px; font-size: 15px; color: #0f172a; }
.scoreboard.pill .seg.team { padding: 0 10px; border-radius: 10px; font-weight: 800; letter-spacing: 0.5px; min-width: 46px; text-transform: uppercase; position: relative; overflow: visible; }
.scoreboard.pill .seg.team.home { background: linear-gradient(90deg, var(--home-dark), var(--home-light)); color: var(--home-text, #ffffff); }
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); color: var(--away-text, #ffffff); }
.scoreboard.pill .seg.score { background: linear-gradient(180deg, #ffffff 0%, #f3f4f6 100%); border: 1px solid #e5e7eb; border-radius: 10px; padding: 0 10px; font-weight: 800; color: #0f172a; min-width: 58px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.9); font-size: 15px; }
.scoreboard.pill .divider { width: 2px; height: 14px; background: rgba(15,23,42,0.35); border-radius: 1px; align-self: center; }
.scoreboard.pill .team .logo { width: 24px; height: 24px; object-fit: contain; margin-right: 6px; filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2)); }
@@ -104,6 +101,10 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
'--away-dark': right.color,
// @ts-ignore
'--away-light': shade(right.color, 20),
// @ts-ignore
'--home-text': (state as any).homeTextColor || '#ffffff',
// @ts-ignore
'--away-text': (state as any).awayTextColor || '#ffffff',
} as any;
if (theme !== 'pill') {
@@ -113,7 +114,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
@@ -147,7 +147,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
<div className="pill-wrapper" style={cssVars as any}>
<div className="scoreboard pill">
<div className="seg timer"><span>{timer}</span></div>
<span className="divider" aria-hidden="true"></span>
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
<span>{left.short}</span>
</div>
@@ -89,6 +89,66 @@ export const MatchesWidget: React.FC<{
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
const byNameMap: Record<string, string> = (overrides as any)?.by_name || {} as Record<string, string>;
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex = React.useMemo(() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byNameMap || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
}, [byId, byNameMap]);
const nameIndex = React.useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg };
}
} catch {}
return idx;
}, [byId]);
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getLogo = (teamName?: string, original?: string) => {
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
@@ -153,8 +213,8 @@ export const MatchesWidget: React.FC<{
id: m.match_id,
date_time: m.date_time || m.date,
competitionName: m.competitionName,
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
away: (m.away_id && byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : (m.away || m.away_team),
home: getOverrideName(m.home || m.home_team, m.home_id),
away: getOverrideName(m.away || m.away_team, m.away_id),
score: m.score,
venue: m.venue,
home_logo_url: (m.home_id && byId?.[m.home_id]?.logo_url) ? String(byId[m.home_id].logo_url) : getLogo(m.home || m.home_team, m.home_logo_url),
+33 -8
View File
@@ -60,8 +60,11 @@ export function useAutoSave<T extends Record<string, any>>({
const [draftAge, setDraftAge] = useState<number | null>(null);
const saveTimerRef = useRef<NodeJS.Timeout>();
const lastDataRef = useRef<string>('');
const lastLocalDataRef = useRef<string>('');
const lastBackendDataRef = useRef<string>('');
const isSavingRef = useRef(false);
const lastDataObjRef = useRef<T | null>(null);
const localSaveTimerRef = useRef<NodeJS.Timeout>();
// Check for existing draft on mount
useEffect(() => {
@@ -153,18 +156,26 @@ export function useAutoSave<T extends Record<string, any>>({
// Main auto-save effect
useEffect(() => {
if (!enabled) return;
const dataString = JSON.stringify(data);
// Skip if data hasn't changed
if (dataString === lastDataRef.current) {
if (lastDataObjRef.current === data) {
return;
}
lastDataRef.current = dataString;
lastDataObjRef.current = data;
// Save to localStorage immediately
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
localSaveTimerRef.current = setTimeout(() => {
try {
const dataString = JSON.stringify(data);
if (dataString !== lastLocalDataRef.current) {
lastLocalDataRef.current = dataString;
saveToLocalStorage(data);
}
} catch (err) {
console.error('Local draft serialize error:', err);
}
}, 300);
// Debounce backend save
if (saveTimerRef.current) {
@@ -172,13 +183,24 @@ export function useAutoSave<T extends Record<string, any>>({
}
saveTimerRef.current = setTimeout(() => {
try {
const dataString = JSON.stringify(data);
if (dataString !== lastBackendDataRef.current) {
lastBackendDataRef.current = dataString;
saveToBackend(data);
}
} catch (err) {
console.error('Backend draft serialize error:', err);
}
}, debounceMs);
return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
@@ -211,6 +233,9 @@ export function useAutoSave<T extends Record<string, any>>({
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
if (localSaveTimerRef.current) {
clearTimeout(localSaveTimerRef.current);
}
};
}, []);
+1 -1
View File
@@ -6,7 +6,7 @@ import './styles/admin-enhancements.css';
import './styles/home-style-pack.css';
import './styles/sparta-styles.css';
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
import 'react-quill/dist/quill.snow.css';
import 'quill/dist/quill.snow.css';
import 'react-image-crop/dist/ReactCrop.css';
// Custom editor styles AFTER quill base styles to ensure proper override
import './styles/custom-editor.css';
File diff suppressed because it is too large Load Diff
+64 -3
View File
@@ -118,8 +118,12 @@ const BlogPage: React.FC = () => {
const matchId = searchParams.get('match_id') || '';
const qParam = searchParams.get('q') || '';
const [qInput, setQInput] = React.useState<string>(qParam);
const [matchInput, setMatchInput] = React.useState<string>('');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textColor = useColorModeValue('gray.500', 'gray.400');
const suggestBg = useColorModeValue('white','gray.800');
const suggestBorder = useColorModeValue('gray.200','gray.700');
const suggestHoverBg = useColorModeValue('gray.50','gray.700');
React.useEffect(() => {
(async () => {
@@ -151,6 +155,24 @@ const BlogPage: React.FC = () => {
React.useEffect(() => {
setQInput(qParam);
}, [qParam]);
// Match suggestions: only show matches that already have a blog (articles with match_snapshot)
const matchSuggestQ = useQuery<Paginated<Article>>(
['blog-match-suggest', { q: matchInput }],
() => getArticles({ page: 1, page_size: 50, published: true, q: matchInput }),
{ enabled: matchInput.trim().length >= 2 }
);
const matchSuggestions = React.useMemo(() => {
const items = matchSuggestQ.data?.data || [];
const uniq = new Map<string, any>();
items.forEach((a: any) => {
const ms = (a as any)?.match_snapshot;
const id = String(ms?.external_match_id || '') || '';
if (!id) return;
if (!uniq.has(id)) uniq.set(id, { id, title: a.title, date: ms?.date_time || ms?.date, home: ms?.home, away: ms?.away, comp: ms?.competition || ms?.competitionName });
});
return Array.from(uniq.values()).slice(0, 10);
}, [matchSuggestQ.data]);
const featuredQ = useQuery<Paginated<Article>>(
['articles-featured', { page_size: 3 }],
() => getFeaturedArticles({ page_size: 3 }),
@@ -266,9 +288,9 @@ const BlogPage: React.FC = () => {
{/* Header like blog.html */}
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
<Container maxW="7xl">
<HStack justify="space-between" align="center" spacing={4}>
<HStack justify="space-between" align="center" spacing={4} wrap="wrap">
<Heading as="h1" size={{ base: 'xl', md: '2xl' }}>Blog</Heading>
<HStack spacing={3} w={{ base: '56%', md: '520px' }}>
<HStack spacing={3} w={{ base: '100%', md: '620px' }}>
<Box flex="1">
<InputGroup>
<InputLeftElement pointerEvents="none">
@@ -301,7 +323,7 @@ const BlogPage: React.FC = () => {
</Box>
{!!categories.length && (
<Select
maxW={{ base: '44%', md: '240px' }}
maxW={{ base: '48%', md: '220px' }}
placeholder="Všechny kategorie"
value={categoryId}
onChange={(e) => {
@@ -320,6 +342,45 @@ const BlogPage: React.FC = () => {
))}
</Select>
)}
<Box flex={{ base: '1', md: '0 0 220px' }} position="relative">
<InputGroup>
<Input
placeholder="Hledat zápas…"
value={matchInput}
onChange={(e) => setMatchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const s = matchInput.trim();
if (/^\d+$/.test(s)) {
const next: Record<string, string> = {};
next.match_id = s;
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (qParam) next.q = qParam;
setSearchParams(next);
}
}
}}
/>
</InputGroup>
{(matchInput.trim().length >= 2 && matchSuggestions.length > 0) && (
<Box position="absolute" top="100%" left={0} right={0} bg={suggestBg} borderWidth="1px" borderColor={suggestBorder} borderRadius="md" mt={1} zIndex={10} maxH="260px" overflowY="auto" boxShadow="lg">
{matchSuggestions.map((m: any) => (
<Box key={m.id} px={3} py={2} _hover={{ bg: suggestHoverBg }} cursor="pointer" onClick={() => {
const next: Record<string, string> = { match_id: String(m.id) };
if (categoryId) next.category_id = String(categoryId);
if (month) next.month = month;
if (qParam) next.q = qParam;
setSearchParams(next);
setMatchInput('');
}}>
<Text fontSize="sm" fontWeight="600" noOfLines={1}>{m.home} vs {m.away}</Text>
<Text fontSize="xs" color={textColor} noOfLines={1}>{m.date || ''}{m.comp ? `${m.comp}` : ''}</Text>
</Box>
))}
</Box>
)}
</Box>
</HStack>
</HStack>
</Container>
+61 -4
View File
@@ -232,6 +232,63 @@ const CalendarPage: React.FC = () => {
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex: Record<string, string> = (() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byName || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
})();
const nameIndex: Record<string, { id: string; name: string; logo_url: string }> = (() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
return idx;
})();
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
// Prefer admin override by ID
if (teamId && byId?.[teamId]?.logo_url) {
@@ -270,8 +327,8 @@ const CalendarPage: React.FC = () => {
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '00:00').slice(0,5);
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
const homeName = getOverrideName(m.home, m.home_id);
const awayName = getOverrideName(m.away, m.away_id);
return {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
@@ -321,8 +378,8 @@ const CalendarPage: React.FC = () => {
id: m.match_id || `${cIdx}-${idx}`,
date: isoDate,
time,
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
home: getOverrideName(m.home, m.home_id),
away: getOverrideName(m.away, m.away_id),
home_id: m.home_id,
away_id: m.away_id,
venue: m.venue,
-24
View File
@@ -145,30 +145,6 @@ const GalleryPage: React.FC = () => {
<Heading size="2xl" color={textPrimary}>
Fotogalerie
</Heading>
{/* Zonerama Attribution */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
p={4}
>
<Text fontSize="sm" color={infoText}>
📸 Všechny fotografie jsou z platformy{' '}
<Text
as="a"
href={zoneramaProfileUrl}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
</VStack>
{/* Loading State */}
+20 -7
View File
@@ -1548,8 +1548,9 @@ const HomePage: React.FC = () => {
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{isVisible('matches', true) ? (
facrCompetitions.length > 0 ? (
upcomingCompIndices.length > 0 ? (
(() => {
// Only render when the currently selected competition has an upcoming match
if (upcomingCompIndices.length === 0) return null;
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
const comp = facrCompetitions[effectiveIndex];
const items = Array.isArray(comp?.matches) ? comp.matches : [];
@@ -1557,7 +1558,8 @@ const HomePage: React.FC = () => {
.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 || null;
if (!upcoming) return null;
const show = upcoming;
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
// Compute prev/next among competitions that actually have upcoming matches
const pos = upcomingCompIndices.indexOf(effectiveIndex);
@@ -1592,21 +1594,32 @@ const HomePage: React.FC = () => {
/>
);
})()
) : null
) : (
(() => {
// Fallback without FACR: show only if there is an upcoming match in the fallback list
if (!matches || matches.length === 0) return null;
const future = matches
.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);
const next = future[0]?.m;
if (!next) return null;
return (
<div className="card">
<NextMatch
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
data={{
home: matches[0]?.homeTeam || clubName,
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
away: matches[0]?.awayTeam || 'Soupeř',
away_logo_url: matches[0]?.awayLogoURL,
home: next?.homeTeam || clubName,
home_logo_url: next?.homeLogoURL || clubLogo,
away: next?.awayTeam || 'Soupeř',
away_logo_url: next?.awayLogoURL,
}}
countdown={countdown}
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
/>
</div>
);
})()
)
) : null}
+59 -2
View File
@@ -296,6 +296,63 @@ const MatchesPage: React.FC = () => {
return acc;
}, {});
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex: Record<string, string> = (() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byName || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
})();
const nameIndex: Record<string, { id: string; name: string; logo_url: string }> = (() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
return idx;
})();
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getFallbackLogo = (teamName?: string, original?: string) => {
if (teamName) {
@@ -370,8 +427,8 @@ const MatchesPage: React.FC = () => {
const [day, month, year] = (d || '').split('.');
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
const time = (t || '18:00').slice(0,5);
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
const homeName = getOverrideName(m.home, m.home_id);
const awayName = getOverrideName(m.away, m.away_id);
// Check if match is in the future - if so, ignore score
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
+46
View File
@@ -5,6 +5,7 @@ import './styles/MagazineHome.css';
import './styles/ProHome.css';
import { useNavigate } from 'react-router-dom';
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
import { getRembgStatus, startRembgBatch } from '../services/rembg';
import { updateSeoSettings } from '../services/seo';
import { API_URL } from '../services/api';
import { assetUrl } from '../utils/url';
@@ -123,6 +124,9 @@ const SetupPage: React.FC = () => {
const [isDomainHost, setIsDomainHost] = useState(false);
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
const [apiUrlTouched, setApiUrlTouched] = useState(false);
const [processingLogos, setProcessingLogos] = useState(false);
const [rembgTotal, setRembgTotal] = useState(0);
const [rembgDone, setRembgDone] = useState(0);
const toast = useToast();
const navigate = useNavigate();
@@ -378,6 +382,38 @@ const SetupPage: React.FC = () => {
});
} catch {}
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
// Start background removal only if backend allows it; otherwise skip waiting UI
let allowRembg = false;
try {
const resp = await startRembgBatch().catch(() => null as any);
allowRembg = !!resp && (resp.started || resp.status?.running || (resp.status?.total || 0) > 0);
} catch {}
if (allowRembg) {
setProcessingLogos(true);
try {
// Wait for batch to actually start or totals to appear (prefetch must finish first)
const deadline = Date.now() + 120000; // 2 minutes max
let started = false;
while (Date.now() < deadline) {
const s0 = await getRembgStatus();
setRembgTotal(s0?.total || 0);
setRembgDone(s0?.done || 0);
if (s0?.running || (s0?.total || 0) > 0 || (s0?.done || 0) > 0) { started = true; break; }
await new Promise((r) => setTimeout(r, 1000));
}
if (started) {
// Poll progress until finished
for (;;) {
const s = await getRembgStatus();
setRembgTotal(s?.total || 0);
setRembgDone(s?.done || 0);
if (!s?.running) break;
await new Promise((r) => setTimeout(r, 1200));
}
}
} catch {}
setProcessingLogos(false);
}
try {
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
let ab = (apiBaseUrl || '').trim();
@@ -982,6 +1018,16 @@ const SetupPage: React.FC = () => {
<Button type="submit" colorScheme="blue" mt={8} isLoading={submitting} loadingText="Ukládám…">Dokončit nastavení</Button>
</Box>
{processingLogos && (
<Box position="fixed" top={0} left={0} right={0} bottom={0} bg="rgba(0,0,0,0.6)" zIndex={9999} display="flex" alignItems="center" justifyContent="center">
<VStack spacing={3} bg={bg} p={8} borderRadius="xl" boxShadow="xl">
<Spinner size="xl" />
<Heading size="md">Připravuji klubová loga</Heading>
<Text>Odstraňuji pozadí: {rembgDone}/{rembgTotal}</Text>
<Text fontSize="sm" color="gray.500">Prosím vyčkejte, dokončuji přípravu webu</Text>
</VStack>
</Box>
)}
</Box>
);
};
+20
View File
@@ -185,6 +185,26 @@ const VideosPage: React.FC = () => {
decoding="async"
referrerPolicy="origin-when-cross-origin"
style={{ objectFit: 'cover' }}
data-fallback-idx={0 as any}
onError={(e: any) => {
try {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = item.videoId || '';
const chain = id
? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/dist/img/logo-club-empty.svg',
]
: ['/dist/img/logo-club-empty.svg'];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
} catch {}
}}
/>
) : (
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
@@ -262,7 +262,7 @@ const AdminActivitiesPage: React.FC = () => {
if (localDraft) {
setEditing(localDraft);
} else {
setEditing({ title: '', description: '', type: 'other', is_public: false } as any);
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
}
setLocationLat(undefined);
setLocationLng(undefined);
@@ -504,7 +504,7 @@ const AdminActivitiesPage: React.FC = () => {
end_time: (endISO as any) || null,
location: (editing.location || '').trim(),
type: (editing.type || 'other') as any,
is_public: !!editing.is_public,
is_public: true,
image_url: imageUrl || undefined,
file_url: (editing as any).file_url || undefined,
category_name: (editing as any)?.category_name || undefined,
@@ -538,7 +538,6 @@ const AdminActivitiesPage: React.FC = () => {
<Th>Začátek</Th>
<Th>Konec</Th>
<Th>Místo</Th>
<Th>Veřejná</Th>
<Th w="140px">Akce</Th>
</Tr>
</Thead>
@@ -594,7 +593,7 @@ const AdminActivitiesPage: React.FC = () => {
</Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
<Tr key={ev.id}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
@@ -623,7 +622,6 @@ const AdminActivitiesPage: React.FC = () => {
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
<Td>{ev.location || '-'}</Td>
<Td>{ev.is_public ? 'Ano' : 'Ne'}</Td>
<Td>
<HStack>
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
@@ -708,7 +706,16 @@ const AdminActivitiesPage: React.FC = () => {
<Switch isChecked={aiOverwrite} onChange={(e)=> setAiOverwrite(e.target.checked)} />
</FormControl>
<Tooltip label="AI doplní titul a popis podle zadaných informací." hasArrow>
<Button onClick={generateWithAI} isLoading={aiLoading} leftIcon={<FiPlus />} bg="brand.primary" color="text.onPrimary" _hover={{ filter: 'brightness(0.95)' }}>
<Button
size="sm"
onClick={generateWithAI}
isLoading={aiLoading}
leftIcon={<FiPlus size={14} />}
bg="brand.primary"
color="text.onPrimary"
_hover={{ filter: 'brightness(0.95)' }}
whiteSpace="nowrap"
>
AI text
</Button>
</Tooltip>
@@ -1063,10 +1070,7 @@ const AdminActivitiesPage: React.FC = () => {
))}
</Select>
</FormControl>
<FormControl display="flex" alignItems="center" mt={3}>
<FormLabel mb="0">Veřejná</FormLabel>
<Switch isChecked={!!editing?.is_public} onChange={(e) => setEditing(prev => ({ ...(prev || {}), is_public: e.target.checked }))} />
</FormControl>
{/* ... (rest of the code remains the same) */}
<HStack mt={4} align="flex-start">
+52 -2
View File
@@ -443,7 +443,27 @@ const AdminVideosPage: React.FC = () => {
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Image
src={v.thumbnail_url}
alt={v.title}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idx = Number(el.dataset.fallbackIdx || '0');
const id = v.video_id;
const chain = [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
];
if (idx < chain.length) {
el.src = chain[idx];
el.dataset.fallbackIdx = String(idx + 1);
}
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
@@ -484,7 +504,37 @@ const AdminVideosPage: React.FC = () => {
{items.map((it, idx) => (
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={it.thumbnail_url || getThumbFromUrl(it.url)} alt={it.title || `Video ${idx+1}`} borderRadius="md" />
<Image
src={it.thumbnail_url || getThumbFromUrl(it.url)}
alt={it.title || `Video ${idx+1}`}
borderRadius="md"
data-fallback-idx={0 as any}
onError={(e) => {
const el = e.currentTarget as HTMLImageElement & { dataset: { fallbackIdx?: string } };
const idxFb = Number(el.dataset.fallbackIdx || '0');
// Try to parse video id from URL; fallback to placeholder
let id: string | undefined;
try {
const u = (it.url || '').trim();
if (u.includes('youtu.be/')) {
id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
} else if (u.includes('youtube.com')) {
const url = new URL(u);
id = url.searchParams.get('v') || undefined;
}
} catch {}
const chain = id ? [
`https://i.ytimg.com/vi/${id}/mqdefault.jpg`,
`https://i.ytimg.com/vi/${id}/sddefault.jpg`,
`https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
'/images/sponsors/placeholder.png',
] : ['/images/sponsors/placeholder.png'];
if (idxFb < chain.length) {
el.src = chain[idxFb];
el.dataset.fallbackIdx = String(idxFb + 1);
}
}}
/>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
+218 -121
View File
@@ -6,7 +6,7 @@ import {
Textarea, Icon, useBreakpointValue, InputGroup, InputLeftElement,
ButtonGroup, Spinner, Heading, Td, Th, Thead, Tr, Tbody, Table, Switch,
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon, Checkbox
} from '@chakra-ui/react';
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -22,6 +22,8 @@ import { getPublicSettings } from '../../services/settings';
import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, saveAlbumToCache } from '../../services/zonerama';
import { facrApi } from '../../services/facr/facrApi';
import { API_URL } from '../../services/api';
import { triggerPrefetch } from '../../services/admin/prefetch';
import { saveArticleReliable } from '../../services/articleSave';
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
@@ -278,109 +280,27 @@ const ArticlesAdminPage = () => {
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
const { isOpen: isExistingAlbumsOpen, onOpen: onExistingAlbumsOpen, onClose: onExistingAlbumsClose } = useDisclosure();
const [zVisibleCount, setZVisibleCount] = useState<number>(60);
const [existingSelectedAlbum, setExistingSelectedAlbum] = useState<{ id: string; date: string; title?: string; photos: Array<{ id: string; image_1500: string; page_url: string }> } | null>(null);
const [existingSelectedPhotos, setExistingSelectedPhotos] = useState<Set<string>>(new Set());
const [existingVisibleCount, setExistingVisibleCount] = useState<number>(60);
// Auto-save hook - saves draft automatically
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
data: editing || {},
storageKey: draftKey,
onSave: async (data) => {
// If article has ID, update it as draft
if (data.id) {
try {
// Build safe minimal payload the backend expects
const attachmentsNorm = (() => {
const a: any = (data as any)?.attachments;
if (!Array.isArray(a) || a.length === 0) return undefined;
return a.map((it: any) => {
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = it?.url || '';
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
})();
const galleryIdsNorm = (() => {
const g: any = (data as any)?.gallery_photo_ids;
if (Array.isArray(g)) return g.map(String);
return undefined;
})();
const isPublished = !!(data as any)?.published;
const payload: UpdateArticlePayload = {
title: (data as any)?.title || '',
...(((typeof (data as any)?.content === 'string') && ((String((data as any)?.content || '').trim().length > 0) || !isPublished)) ? { content: (data as any)?.content || '' } : {}),
image_url: (data as any)?.image_url || '',
...(typeof (data as any)?.category_id === 'number' ? { category_id: (data as any).category_id } : {}),
category_name: (data as any)?.category_name || undefined,
slug: (data as any)?.slug || undefined,
seo_title: (data as any)?.seo_title || undefined,
seo_description: (data as any)?.seo_description || undefined,
og_image_url: (data as any)?.og_image_url || undefined,
featured: !!(data as any)?.featured,
// Gallery fields
gallery_album_id: (data as any)?.gallery_album_id || undefined,
gallery_album_url: (data as any)?.gallery_album_url || undefined,
...(galleryIdsNorm ? { gallery_photo_ids: galleryIdsNorm } : {}),
// YouTube fields
youtube_video_id: (data as any)?.youtube_video_id || undefined,
youtube_video_title: (data as any)?.youtube_video_title || undefined,
youtube_video_url: (data as any)?.youtube_video_url || undefined,
youtube_video_thumbnail: (data as any)?.youtube_video_thumbnail || undefined,
// Attachments
...(attachmentsNorm ? { attachments: attachmentsNorm } : {}),
} as UpdateArticlePayload;
return await updateArticle(data.id, payload);
} catch (e: any) {
const status = e?.response?.status;
if (status === 404 && data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
// Use centralized reliable saver (normalizes payload, retries, triggers cache refresh when published)
if ((data as any)?.id || (data as any)?.title?.trim()) {
const saved: any = await saveArticleReliable(data as any);
if (saved?.id && !(data as any)?.id) {
setEditing(prev => ({ ...(prev as any), id: saved.id } as any));
setDraftKey(`draft-article-${saved.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
return saved;
}
throw e;
}
}
// If no ID, create as draft
if (data.title?.trim()) {
const payload: CreateArticlePayload = {
title: data.title || 'Koncept článku',
content: data.content || '',
image_url: data.image_url || '',
category_name: data.category_name,
published: false,
slug: data.slug || '',
seo_title: data.seo_title || '',
seo_description: data.seo_description || '',
og_image_url: data.og_image_url || '',
featured: data.featured || false,
};
const created = await createArticle(payload);
if (created?.id) {
setEditing(prev => ({ ...prev, id: created.id } as any));
setDraftKey(`draft-article-${created.id}`);
try { localStorage.removeItem('draft-article-new'); } catch {}
}
return created;
}
// Don't save if no title
return {};
},
debounceMs: 2000,
@@ -467,6 +387,12 @@ const ArticlesAdminPage = () => {
}
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
React.useEffect(() => {
if (isExistingAlbumsOpen && cachedAlbums.length === 0 && !galleryLoading) {
fetchCachedGallery();
}
}, [isExistingAlbumsOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
const filteredYoutubeVideos = useMemo(() => {
const q = youtubeSearch.trim().toLowerCase();
if (!q) return youtubeVideos;
@@ -545,16 +471,19 @@ 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 (admins only)
if (isAdmin) {
// Save album to cache (admins only) with a sufficiently high photo limit to fetch the full album
if (isAdmin && albumInfo?.url) {
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
await saveAlbumToCache(albumInfo.url, photos.length);
const limit = Math.max(500, Number(albumInfo?.photos_count || 0) || photos.length || 100);
await saveAlbumToCache(albumInfo.url, limit);
}
// Store album info with article and append images to content
setEditing((prev) => {
const currentContent = (prev as any)?.content || '';
const photosHTML = photos.map(p => `<img src="${p.image_1500}" alt="Gallery photo" />`).join('\n');
const photosHTML = photos
.map(p => `<img src="${p.image_1500}" alt="Gallery photo" data-page-url="${p.page_url}" data-img-id="${p.id}" />`)
.join('\n');
return {
...(prev as any),
gallery_album_id: albumInfo.id,
@@ -733,13 +662,7 @@ const ArticlesAdminPage = () => {
const settings = await getPublicSettings();
const clubId = (settings as any)?.club_id || '';
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
let comps: Array<{ code?: string; name: string }> = [];
if (clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
comps = (club?.competitions || []).map((c: any) => ({ code: c.code, name: c.name || c.code }));
} catch {}
}
// Aliases
let amap: Record<string, string> = {};
try {
@@ -747,6 +670,27 @@ const ArticlesAdminPage = () => {
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
setAliasesList(list as any);
} catch {}
// Try cached prefetch JSON first
let comps: Array<{ code?: string; name: string }> = [];
try {
const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
const res = await fetch(`${origin}/cache/prefetch/facr_club_info.json`, { cache: 'no-cache' });
if (res.ok) {
const json = await res.json();
const arr = Array.isArray((json as any)?.competitions) ? (json as any).competitions : [];
comps = arr.map((c: any) => ({ code: c.code || c.id, name: c.name || c.code || c.id }));
}
} catch {}
// Fallback to live FACR API if cache is empty/unavailable
if (comps.length === 0 && clubId) {
try {
const club = await facrApi.getClub(String(clubId), clubType);
comps = (club?.competitions || []).map((c: any) => ({ code: c.code, name: c.name || c.code }));
} catch {}
}
// Apply aliases to names for display
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
setAliasesMap(amap);
@@ -759,7 +703,16 @@ const ArticlesAdminPage = () => {
mutationFn: () => {
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
const effective = Number.isFinite(parsed) && !isNaN(parsed) && parsed > 0 ? parsed : aiMinWords;
return generateBlogAI({ prompt: aiPrompt, audience: aiAudience, min_words: effective });
const base = String(aiPrompt || '').trim();
const htmlGuidelines = [
'Piš česky a strukturovaně pro blog fotbalového klubu.',
'Používej bohaté HTML prvky: rozděl článek do 24 sekcí s <h2>/<h3>, krátké odstavce <p>, alespoň jeden seznam <ul><li>, zvýraznění <strong>/<em>.',
'Pokud se to hodí, vlož krátký citát pomocí <blockquote>…</blockquote> (max. 1×).',
'Nevkládej <html>, <head> ani <body>. Vrať jen validní HTML části obsahu.',
'Zachovej fakta zadaná uživatelem, vyhýbej se hyperbolem a marketingovým frázím.',
].join(' ');
const finalPrompt = `${base}\n\n${htmlGuidelines}`.trim();
return generateBlogAI({ prompt: finalPrompt, audience: aiAudience, min_words: effective });
},
onSuccess: (res) => {
console.log('AI blog response:', res);
@@ -891,6 +844,7 @@ const ArticlesAdminPage = () => {
// Clear temporary storage
setTempMatchLink('');
setMatchIdInput('');
try { if (created?.published) { await triggerPrefetch(); } } catch {}
// Invalidate queries to refresh the list
qc.invalidateQueries({ queryKey: ['admin-articles'] });
@@ -916,9 +870,10 @@ const ArticlesAdminPage = () => {
mutationFn: ({ id, payload }: { id: number | string; payload: UpdateArticlePayload }) =>
// Forward the payload as-is so new fields (youtube, gallery) are persisted
updateArticle(id, payload),
onSuccess: (_, variables) => {
onSuccess: async (saved: any, variables) => {
const articleId = variables.id;
console.log('Article updated successfully in mutation callback:', articleId);
try { if (saved?.published) { await triggerPrefetch(); } } catch {}
// Invalidate queries to refresh the list
qc.invalidateQueries({ queryKey: ['admin-articles'] });
@@ -1110,11 +1065,6 @@ const ArticlesAdminPage = () => {
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
if (!editing) return;
// Require category selection by name (kategorie je povinná)
if (!String((editing as any)?.category_name || '').trim()) {
toast({ title: 'Vyberte kategorii', description: 'Nejprve vyberte kategorii článku (soutěž).', status: 'warning' });
return;
}
// Check if content contains raw AI JSON (invalid state)
const contentText = String(editing.content || '').trim();
@@ -1618,10 +1568,10 @@ const ArticlesAdminPage = () => {
/>
<FormHelperText>Automaticky generováno z názvu článku</FormHelperText>
</FormControl>
<FormControl isRequired>
<FormControl>
<FormLabel fontWeight="bold">Kategorie (soutěž)</FormLabel>
<Select
placeholder="Vyberte kategorii článku"
placeholder="Vyberte kategorii (volitelné)"
value={(editing as any)?.category_name || ''}
onChange={(e) => setEditing((prev) => ({ ...(prev as any), category_name: e.target.value }))}
size="lg"
@@ -1632,10 +1582,7 @@ const ArticlesAdminPage = () => {
</option>
))}
</Select>
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí</FormHelperText>
{!(editing as any)?.category_name && (
<Text color="orange.500" fontSize="sm" mt={1}> Kategorie je povinná</Text>
)}
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí (volitelné)</FormHelperText>
</FormControl>
{/* Featured toggle - prominent display */}
@@ -1831,6 +1778,14 @@ const ArticlesAdminPage = () => {
>
Vložit fotografie z alba
</Button>
<Button
size="sm"
variant="outline"
leftIcon={<FiSearch />}
onClick={onExistingAlbumsOpen}
>
Vybrat z alba
</Button>
</HStack>
{activeTabIndex === 2 && (
<RichTextEditor
@@ -1893,15 +1848,22 @@ const ArticlesAdminPage = () => {
<Text fontSize="sm" color="gray.500" mt={2}>Zadejte odkaz na Zonerama album a klikněte na "Načíst album"</Text>
)}
{zAlbumPhotos.length > 0 && (
<>
<SimpleGrid columns={{ base: 3, md: 6 }} spacing={2} mt={2}>
{zAlbumPhotos.map((p) => (
{zAlbumPhotos.slice(0, zVisibleCount).map((p) => (
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" _hover={{ boxShadow: 'md' }} cursor="pointer"
onClick={() => pickZoneramaImage({ id: p.id, album_id: '', album_url: zAlbumLink, page_url: p.page_url, image_url: p.image_1500 || '', title: p.title })}
>
<Image src={p.image_1500 || ''} alt={p.id} w="100%" h="100px" objectFit="cover" />
<Image src={p.image_1500 || ''} alt={p.id} w="100%" h="100px" objectFit="cover" loading="lazy" decoding="async" />
</Box>
))}
</SimpleGrid>
{zAlbumPhotos.length > zVisibleCount && (
<HStack justify="center" mt={2}>
<Button size="sm" onClick={() => setZVisibleCount((c) => c + 60)}>Načíst další</Button>
</HStack>
)}
</>
)}
</VStack>
</Box>
@@ -2246,6 +2208,139 @@ const ArticlesAdminPage = () => {
onPhotosSelected={handleAlbumPhotosSelected}
/>
<Modal isOpen={isExistingAlbumsOpen} onClose={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); onExistingAlbumsClose(); }} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat z existujících alb</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{!existingSelectedAlbum && (
<>
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
<Button size="sm" colorScheme="purple" onClick={() => { setExistingSelectedAlbum(album); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); }}>
Otevřít album
</Button>
</HStack>
<SimpleGrid columns={{ base: 6, md: 10 }} spacing={2}>
{album.photos.slice(0, 20).map((photo) => (
<AspectRatio key={photo.id} ratio={1}>
<Image src={photo.image_1500} alt={photo.id} objectFit="cover" loading="lazy" decoding="async" />
</AspectRatio>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">Žádná alba nebyla nalezena v cache.</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>Obnovit seznam</Button>
</VStack>
)}
</>
)}
{existingSelectedAlbum && (
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Button size="sm" variant="ghost" onClick={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); setExistingVisibleCount(60); }}> Zpět na seznam alb</Button>
<Checkbox
isChecked={existingSelectedPhotos.size === existingSelectedAlbum.photos.length}
isIndeterminate={existingSelectedPhotos.size > 0 && existingSelectedPhotos.size < existingSelectedAlbum.photos.length}
onChange={() => {
if (!existingSelectedAlbum) return;
if (existingSelectedPhotos.size === existingSelectedAlbum.photos.length) {
setExistingSelectedPhotos(new Set());
} else {
setExistingSelectedPhotos(new Set(existingSelectedAlbum.photos.map(p => p.id)));
}
}}
>
Vybrat vše ({existingSelectedPhotos.size}/{existingSelectedAlbum.photos.length})
</Checkbox>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
{existingSelectedAlbum.photos.slice(0, existingVisibleCount).map((photo) => {
const checked = existingSelectedPhotos.has(photo.id);
return (
<Box
key={photo.id}
position="relative"
cursor="pointer"
onClick={() => {
const next = new Set(existingSelectedPhotos);
if (next.has(photo.id)) next.delete(photo.id); else next.add(photo.id);
setExistingSelectedPhotos(next);
}}
borderRadius="md"
overflow="hidden"
borderWidth="2px"
borderColor={checked ? 'purple.500' : 'transparent'}
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)' }}
>
<Image src={photo.image_1500} alt={photo.id} w="100%" h="150px" objectFit="cover" loading="lazy" decoding="async" />
<Checkbox position="absolute" top={2} right={2} isChecked={checked} pointerEvents="none" bg="white" borderRadius="sm" />
</Box>
);
})}
</SimpleGrid>
{existingSelectedAlbum.photos.length > existingVisibleCount && (
<HStack justify="center" pt={2}>
<Button size="sm" onClick={() => setExistingVisibleCount(c => c + 60)}>Načíst další</Button>
</HStack>
)}
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={3}>
<Button variant="ghost" onClick={() => { setExistingSelectedAlbum(null); setExistingSelectedPhotos(new Set()); onExistingAlbumsClose(); }}>Zrušit</Button>
<Button
colorScheme="purple"
onClick={() => {
if (!existingSelectedAlbum || existingSelectedPhotos.size === 0) return;
const photos = existingSelectedAlbum.photos.filter(p => existingSelectedPhotos.has(p.id));
handleAlbumPhotosSelected(photos as any, {
id: existingSelectedAlbum.id,
title: existingSelectedAlbum.title || '',
url: '', // already cached; skip saveAlbumToCache
date: existingSelectedAlbum.date,
photos_count: existingSelectedAlbum.photos.length,
photos: existingSelectedAlbum.photos,
});
setExistingSelectedAlbum(null);
setExistingSelectedPhotos(new Set());
onExistingAlbumsClose();
}}
isDisabled={!existingSelectedAlbum || existingSelectedPhotos.size === 0}
>
Vložit vybrané ({existingSelectedPhotos.size || 0})
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* YouTube Video Picker Modal */}
<Modal isOpen={isYouTubeModalOpen} onClose={onYouTubeModalClose} size="6xl">
<ModalOverlay />
@@ -2402,6 +2497,8 @@ const ArticlesAdminPage = () => {
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
loading="lazy"
decoding="async"
/>
</AspectRatio>
</Box>
@@ -55,6 +55,15 @@ const BANNER_PRESETS: BannerPreset[] = [
aspectRatio: 8.09,
position: 'article'
},
{
value: 'article_sidebar',
label: 'Banner v článku (sidebar)',
description: 'Banner v pravém sloupci detailu článku',
width: 300,
height: 250,
aspectRatio: 1.2,
position: 'article'
},
{
value: 'homepage_under_table',
label: 'Pod tabulkou (Homepage)',
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack, Badge } from '@chakra-ui/react';
import AdminLayout from '@/layouts/AdminLayout';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, swapSides, startSecondHalf } from '@/services/scoreboard';
import { getAdminScoreboard, updateAdminScoreboard, ScoreboardState, startTimer, pauseTimer, resetTimer, startSecondHalf } from '@/services/scoreboard';
const MobileScoreboardControlPage: React.FC = () => {
const toast = useToast();
@@ -59,54 +59,57 @@ const MobileScoreboardControlPage: React.FC = () => {
<Box p={3}>
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
<VStack align="stretch" spacing={3}>
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
<SimpleGrid columns={3} spacing={2} alignItems="center">
<VStack spacing={2}>
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize="64px" objectFit="contain" /> : null}
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }}>
<VStack spacing={3} align="stretch">
<HStack justify="space-between" align="center" flexWrap="wrap">
<Text fontSize={{ base: '4xl', md: '5xl' }} fontWeight="black" lineHeight="1">{state.homeScore} : {state.awayScore}</Text>
<Text fontSize={{ base: '3xl', md: '4xl' }} fontFamily="mono" fontWeight="semibold">{mmss}</Text>
</HStack>
<HStack spacing={2} wrap="wrap">
<Button size="lg" colorScheme={state.running ? 'red' : 'green'} onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>
{state.running ? 'Stop' : 'Start'}
</Button>
<Button size="lg" variant="outline" onClick={handleResetTimer}>Reset</Button>
<Button size="lg" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>
Začít 2. poločas
</Button>
<Badge ml="auto" colorScheme="purple" fontSize={{ base: 'sm', md: 'md' }}>Poločas: {state.half || 1}</Badge>
</HStack>
</VStack>
</Box>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={3} alignItems="stretch">
<VStack spacing={3} borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }} align="stretch">
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize={{ base: '56px', md: '64px' }} objectFit="contain" alignSelf="center" /> : null}
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
<HStack>
<HStack justify="center">
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
</HStack>
<HStack>
<HStack justify="center">
<Button size="sm" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.homeFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
<VStack spacing={2}>
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
<HStack>
<Button onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>{state.running ? 'Stop' : 'Start'}</Button>
<Button variant="outline" onClick={handleResetTimer}>Reset</Button>
</HStack>
<Text fontSize="2xl" fontFamily="mono">{mmss}</Text>
<HStack>
<Badge colorScheme="purple">Poločas: {state.half || 1}</Badge>
</HStack>
<HStack>
<Button size="sm" variant="outline" onClick={async ()=>{ try { await swapSides(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Strany prohozeny', status: 'success' }); } catch { toast({ title: 'Prohození selhalo', status: 'error' }); } }}>Prohodit strany</Button>
<Button size="sm" colorScheme="purple" onClick={async ()=>{ try { await startSecondHalf(); await qc.invalidateQueries({ queryKey: ['admin-scoreboard-mobile'] }); toast({ title: 'Začal 2. poločas', status: 'success' }); } catch { toast({ title: 'Akce selhala', status: 'error' }); } }}>Začít 2. poločas</Button>
</HStack>
</VStack>
<VStack spacing={2}>
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize="64px" objectFit="contain" /> : null}
<VStack spacing={3} borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }} align="stretch">
{state.awayLogo ? <Image src={state.awayLogo} alt="HOS" boxSize={{ base: '56px', md: '64px' }} objectFit="contain" alignSelf="center" /> : null}
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
<HStack>
<HStack justify="center">
<Button size="lg" onClick={() => setPartial({ awayScore: Math.max(0, (state.awayScore || 0) - 1) })}></Button>
<Button size="lg" colorScheme="green" onClick={() => setPartial({ awayScore: (state.awayScore || 0) + 1 })}>+</Button>
</HStack>
<HStack>
<HStack justify="center">
<Button size="sm" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) - 1)) })}> Faul</Button>
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.awayFouls || 0))}</Text>
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ awayFouls: Math.max(0, Math.min(5, (state.awayFouls || 0) + 1)) })}>+ Faul</Button>
</HStack>
</VStack>
</SimpleGrid>
</Box>
</VStack>
{/* Removed 'Vybraný zápas' section for remote managed on main Tabule page */}
</VStack>
</Box>
</AdminLayout>
);
+167 -40
View File
@@ -18,6 +18,14 @@ import {
Text,
Switch,
Badge,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
useDisclosure,
Tabs,
TabList,
TabPanels,
@@ -49,12 +57,13 @@ import {
prefillSponsorsFromPage,
getQr,
uploadQr,
deleteQr,
} from '@/services/scoreboard';
import { useFacrApi } from '@/hooks/useFacrApi';
import { SearchResult } from '@/services/facr/types';
import { API_URL } from '@/services/api';
import { useQuery } from '@tanstack/react-query';
import { AdminMatch, fetchAdminMatches } from '@/services/adminMatches';
import { AdminMatch, fetchAdminMatches, fetchTeamLogoOverrides } from '@/services/adminMatches';
import { getFacrClubInfoCache } from '@/services/facr/cache';
import { createSponsor } from '@/services/sponsors';
@@ -85,6 +94,8 @@ const ScoreboardAdminPage: React.FC = () => {
const [sUploadBusy, setSUploadBusy] = useState(false);
const [qrUrl, setQrUrl] = useState<string>('');
const [qrBusy, setQrBusy] = useState(false);
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]);
// Club search inline (home/away target)
const [clubQuery, setClubQuery] = useState('');
@@ -126,6 +137,101 @@ const ScoreboardAdminPage: React.FC = () => {
staleTime: 60_000,
});
// Load team overrides (names + logos)
const { data: teamOverrides = {} } = useQuery<any>({
queryKey: ['team-logo-overrides-admin'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const byId: Record<string, { name?: string; logo_url?: string }> = (teamOverrides as any)?.by_id || {};
const byNameMap: Record<string, string> = (teamOverrides as any)?.by_name || {} as Record<string, string>;
const normName = (s?: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/\bn\.?\b/g, ' nad ')
.replace(/\bp\.?\b/g, ' pod ')
.replace(/[\,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '')
.replace(/[\.,!;:()\[\]{}]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const aliasNameIndex = React.useMemo(() => {
const urlToName: Record<string, string> = {};
for (const v of Object.values(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (nm && lg) urlToName[lg] = nm;
}
const idx: Record<string, string> = {};
for (const [alias, url] of Object.entries(byNameMap || {})) {
const canon = urlToName[String(url)] || '';
const key = normName(alias);
if (canon && key) idx[key] = canon;
}
return idx;
}, [byId, byNameMap]);
const nameIndex = React.useMemo(() => {
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
try {
for (const [id, v] of Object.entries(byId || {})) {
const nm = String((v as any)?.name || '').trim();
const lg = String((v as any)?.logo_url || '').trim();
if (!nm) continue;
const key = normName(nm);
if (!key) continue;
idx[key] = { id, name: nm, logo_url: lg } as any;
}
} catch {}
return idx;
}, [byId]);
const getOverrideName = (teamName?: string, teamId?: string) => {
const tid = teamId ? String(teamId) : '';
if (tid && byId?.[tid]?.name && String(byId[tid].name).trim()) {
return String(byId[tid].name).trim();
}
try {
const n = normName(teamName);
if (aliasNameIndex[n]) return aliasNameIndex[n];
let hit: any = nameIndex[n];
if (!hit) {
for (const [k, v] of Object.entries(nameIndex)) {
if (!k) continue;
if (n.endsWith(k) || k.endsWith(n)) { hit = v; break; }
}
}
if (hit && (hit as any).name) return String((hit as any).name);
} catch {}
return String(teamName || '');
};
const getLogo = (teamName?: string, original?: string) => {
const byName = (teamOverrides as any)?.by_name || {} as Record<string, string>;
const norm = (s: string) => String(s || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
const stripPrefixes = (s: string) => {
let x = norm(s);
x = x.replace(/\b(mestsky|m\.?f\.?k\.?|mfk|tj|sk|sokol|fotbalovy|fotbalový|fotbalovy\s+klub|fotbalovy\s+klub)\b/g, '');
return x.replace(/\s+/g, ' ').trim();
};
const byNameNorm: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k) => { acc[norm(k)] = byName[k]; return acc; }, {});
const strippedPairs = Object.keys(byName || {}).map((k) => ({ key: stripPrefixes(k), url: byName[k] }));
const pick = (name?: string, orig?: string) => {
if (!name) return orig;
const exact = byName[name];
let candidate = exact || byNameNorm[norm(name)];
if (!candidate) {
const s = stripPrefixes(name);
for (const { key, url } of strippedPairs) { if (key && (s.endsWith(key) || key.endsWith(s))) { candidate = url; break; } }
}
return candidate || orig;
};
return pick(teamName, original);
};
// Load competitions/matches from cached FACR blob
const { data: facrCache } = useQuery<any>({
queryKey: ['facr-club-info-cache'],
@@ -229,10 +335,17 @@ const ScoreboardAdminPage: React.FC = () => {
const applyMatch = async (m: AdminMatch) => {
if (!state) return;
// Populate names, logos and short codes
const homeName = String(m.home || m.home_team || '').trim();
const awayName = String(m.away || m.away_team || '').trim();
const homeLogo = resolveLogoUrl(m.home_logo_url || '') || '';
const awayLogo = resolveLogoUrl(m.away_logo_url || '') || '';
const rawHomeName = String(m.home || (m as any).home_team || '').trim();
const rawAwayName = String(m.away || (m as any).away_team || '').trim();
const homeTeamId = String((m as any).home_id || (m as any).homeTeamId || (m as any).home_team_id || '');
const awayTeamId = String((m as any).away_id || (m as any).awayTeamId || (m as any).away_team_id || '');
const homeName = getOverrideName(rawHomeName, homeTeamId) || rawHomeName;
const awayName = getOverrideName(rawAwayName, awayTeamId) || rawAwayName;
// Prefer ID-based logo override, then name-based, then original logo URL
const homeLogoOverride = (homeTeamId && byId?.[homeTeamId]?.logo_url) ? String(byId[homeTeamId].logo_url) : getLogo(rawHomeName, m.home_logo_url || (m as any).homeLogoURL || '');
const awayLogoOverride = (awayTeamId && byId?.[awayTeamId]?.logo_url) ? String(byId[awayTeamId].logo_url) : getLogo(rawAwayName, m.away_logo_url || (m as any).awayLogoURL || '');
const homeLogo = resolveLogoUrl(homeLogoOverride || '') || '';
const awayLogo = resolveLogoUrl(awayLogoOverride || '') || '';
const updates: Partial<ScoreboardState> = {
homeName,
awayName,
@@ -444,30 +557,6 @@ const ScoreboardAdminPage: React.FC = () => {
}}
/>
</FormControl>
<FormControl>
<FormLabel>Skóre domácích</FormLabel>
<NumberInput value={state.homeScore} min={0} onChange={async (_, n) => setPartial({ homeScore: Number.isFinite(n) ? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Skóre hostů</FormLabel>
<NumberInput value={state.awayScore} min={0} onChange={async (_, n) => setPartial({ awayScore: Number.isFinite(n) ? n : 0 })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Fauly domácích</FormLabel>
<NumberInput value={state.homeFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ homeFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Fauly hostů</FormLabel>
<NumberInput value={state.awayFouls || 0} min={0} max={5} onChange={async (_, n) => setPartial({ awayFouls: Math.max(0, Math.min(5, Number.isFinite(n) ? n : 0)) })}>
<NumberInputField />
</NumberInput>
</FormControl>
<FormControl>
<FormLabel>Délka poločasu (min)</FormLabel>
<NumberInput value={state.halfLength} min={1} max={60} onChange={async (_, n) => setPartial({ halfLength: Number.isFinite(n) ? n : 45 })}>
@@ -503,6 +592,14 @@ const ScoreboardAdminPage: React.FC = () => {
<FormLabel>Barva hostů</FormLabel>
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Barva textu domácích</FormLabel>
<Input type="color" value={state.homeTextColor || '#ffffff'} onChange={async (e) => setPartial({ homeTextColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>Barva textu hostů</FormLabel>
<Input type="color" value={state.awayTextColor || '#ffffff'} onChange={async (e) => setPartial({ awayTextColor: e.target.value })} />
</FormControl>
<FormControl>
<FormLabel>QR interval (minuty)</FormLabel>
<NumberInput value={state.qrEvery || 5} min={1} max={120} onChange={async (_, n) => setPartial({ qrEvery: Math.max(1, Number.isFinite(n) ? n : 5) })}>
@@ -578,17 +675,8 @@ const ScoreboardAdminPage: React.FC = () => {
try {
const urls = (res?.files || []).filter(Boolean) as string[];
if (urls.length > 0) {
const want = window.confirm('Chcete přidat nahraná loga i jako nové sponzory na web?');
if (want) {
for (const u of urls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
toast({ title: 'Sponzoři přidáni', status: 'success' });
}
setUploadedSponsorUrls(urls);
openSponsorModal();
}
} catch {}
} catch (err: any) {
@@ -623,6 +711,42 @@ const ScoreboardAdminPage: React.FC = () => {
</SimpleGrid>
</Box>
{/* Modal: Add uploaded logos as sponsors */}
<Modal isOpen={isSponsorModalOpen} onClose={closeSponsorModal} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Přidat loga jako sponzory?</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text mb={3}>Chcete přidat nahraná loga i jako nové sponzory na web?</Text>
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={3}>
{uploadedSponsorUrls.map((u)=> (
<Image key={u} src={u} alt="logo" boxSize="64px" objectFit="contain" borderWidth="1px" borderRadius="md" />
))}
</SimpleGrid>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={closeSponsorModal}>Ne</Button>
<Button colorScheme="blue" onClick={async ()=>{
try {
for (const u of uploadedSponsorUrls) {
const fname = (u.split('/').pop() || '').replace(/\.[a-z0-9]+$/i, '');
const name = window.prompt('Název sponzora pro logo '+fname, fname) || '';
if (!name.trim()) continue;
const website = window.prompt('Web sponzora (volitelné, včetně https://)', '') || '';
try { await createSponsor({ name: name.trim(), logo_url: u, website_url: website.trim() || undefined, is_active: true }); } catch {}
}
setSponsors(await listSponsorsAdmin());
toast({ title: 'Sponzoři přidáni', status: 'success' });
} finally {
closeSponsorModal();
setUploadedSponsorUrls([]);
}
}}>Ano, přidat</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
<Heading size="md" mb={3}>QR kód</Heading>
<HStack spacing={4} align="flex-start" flexWrap="wrap">
@@ -652,6 +776,9 @@ const ScoreboardAdminPage: React.FC = () => {
}} />
</Button>
<Button variant="ghost" onClick={async ()=>{ try { setQrUrl(await getQr()); toast({ title: 'Obnoveno', status: 'info' }); } catch {} }}>Obnovit</Button>
<Button variant="outline" colorScheme="red" isDisabled={!qrUrl} onClick={async ()=>{
try { await deleteQr(); setQrUrl(''); toast({ title: 'QR smazán', status: 'info' }); } catch { toast({ title: 'Smazání selhalo', status: 'error' }); }
}}>Smazat QR</Button>
</HStack>
</Box>
@@ -55,7 +55,10 @@ const SettingsAdminPage: React.FC = () => {
getAdminSettings()
.then((data) => {
const s = data || {};
setSettings(s);
const normalized: any = { ...s };
if (!normalized.storage_warn_threshold || normalized.storage_warn_threshold <= 0) normalized.storage_warn_threshold = 80;
if (!normalized.storage_critical_threshold || normalized.storage_critical_threshold <= 0) normalized.storage_critical_threshold = 95;
setSettings(normalized);
})
.catch(() => {
toast({ title: 'Chyba', description: 'Nepodařilo se načíst nastavení', status: 'error' });
@@ -208,8 +211,8 @@ const SettingsAdminPage: React.FC = () => {
api_base_url: (settings as any).api_base_url,
// homepage matches display
finished_match_display_days: (settings as any).finished_match_display_days as any,
storage_warn_threshold: (settings as any).storage_warn_threshold as any,
storage_critical_threshold: (settings as any).storage_critical_threshold as any,
storage_warn_threshold: (((settings as any).storage_warn_threshold ?? 0) > 0 ? (settings as any).storage_warn_threshold : 80) as any,
storage_critical_threshold: (((settings as any).storage_critical_threshold ?? 0) > 0 ? (settings as any).storage_critical_threshold : 95) as any,
// error-review integration (domain managed via .env; only tokens are saved)
error_review_admin_token: (settings as any).error_review_admin_token,
error_review_ingest_token: (settings as any).error_review_ingest_token,
@@ -302,7 +305,7 @@ const SettingsAdminPage: React.FC = () => {
type="number"
min={0}
max={100}
value={(settings as any).storage_warn_threshold ?? 80}
value={((settings as any).storage_warn_threshold ?? 0) > 0 ? (settings as any).storage_warn_threshold : 80}
onChange={handleNumChange('storage_warn_threshold' as any)}
/>
</FormControl>
@@ -312,7 +315,7 @@ const SettingsAdminPage: React.FC = () => {
type="number"
min={0}
max={100}
value={(settings as any).storage_critical_threshold ?? 95}
value={((settings as any).storage_critical_threshold ?? 0) > 0 ? (settings as any).storage_critical_threshold : 95}
onChange={handleNumChange('storage_critical_threshold' as any)}
/>
</FormControl>
@@ -55,13 +55,17 @@ const ShortlinksAdminPage: React.FC = () => {
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
try {
setCreating(true);
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: code.trim() || undefined, active: true });
// sanitize code early for UX
const rawCode = code.trim();
const safeCode = rawCode ? rawCode.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16) : undefined;
const res = await createShortLink({ target_url: t, title: title.trim() || undefined, code: safeCode, active: true });
await navigator.clipboard.writeText(res.short_url);
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
setTargetUrl(''); setTitle(''); setCode('');
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
} catch (e: any) {
toast({ title: 'Vytvoření selhalo', description: e?.message || 'Zkuste to znovu', status: 'error' });
const msg = e?.response?.data?.error || e?.response?.data?.details || e?.message || 'Zkuste to znovu';
toast({ title: 'Vytvoření selhalo', description: String(msg), status: 'error' });
} finally {
setCreating(false);
}
@@ -93,7 +97,7 @@ const ShortlinksAdminPage: React.FC = () => {
<HStack spacing={2} flexWrap="wrap">
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
<Input placeholder="Titulek (volitelný)" value={title} onChange={(e)=>setTitle(e.target.value)} flex={2} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} />
<Input placeholder="Vlastní kód (volitelné)" value={code} onChange={(e)=>setCode(e.target.value)} flex={1} maxLength={16} pattern="[A-Za-z0-9_-]+" title="Povoleno: písmena, čísla, -, _ (max 16 znaků)" />
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
</HStack>
</Box>
@@ -266,9 +266,9 @@ const SweepstakesAdminPage: React.FC = () => {
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<HStack justify="space-between" mb={4}>
<HStack justify="space-between" mb={4} flexWrap="wrap">
<Heading size="lg">Soutěže</Heading>
<HStack>
<HStack flexWrap="wrap">
<Select value={status} onChange={(e)=>setStatus(e.target.value)} size="sm" maxW="220px">
<option value="">Všechny</option>
<option value="draft">Koncepty</option>
@@ -277,7 +277,7 @@ const SweepstakesAdminPage: React.FC = () => {
<option value="finalized">Dokončené</option>
<option value="archived">Archiv</option>
</Select>
<Button colorScheme="blue" onClick={openCreate}>Nová soutěž</Button>
<Button colorScheme="blue" onClick={openCreate} minW="max-content">Nová soutěž</Button>
</HStack>
</HStack>
@@ -332,7 +332,7 @@ const SweepstakesAdminPage: React.FC = () => {
)}
{/* Create/Edit Modal with tabs */}
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<Modal isOpen={isOpen} onClose={onClose} size="3xl" scrollBehavior="inside" isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
@@ -373,7 +373,7 @@ const SweepstakesAdminPage: React.FC = () => {
<FormControl>
<FormLabel>Pravidla</FormLabel>
<VStack align="start" spacing={2}>
<HStack>
<HStack flexWrap="wrap" spacing={2}>
<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])} />
+6 -4
View File
@@ -1,4 +1,5 @@
import api from './api';
const AI_TIMEOUT = Number(process.env.REACT_APP_AI_TIMEOUT_MS || '') || 90000;
export interface AIGenerateBlogReq {
prompt: string;
@@ -13,7 +14,7 @@ export interface AIGenerateBlogResp {
}
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<AIGenerateBlogResp> {
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload);
const { data } = await api.post<AIGenerateBlogResp>('/ai/blog/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
@@ -47,13 +48,14 @@ export interface AIGenerateInstagramReq {
hashtags?: string[];
audience?: string;
tone?: string;
category?: string;
match?: AIGenerateInstagramMatch | null;
}
export interface AIGenerateInstagramResp { text: string }
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload);
const { data } = await api.post<AIGenerateInstagramResp>('/ai/instagram/generate', payload, { timeout: AI_TIMEOUT });
let parsed: any = data;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
@@ -77,7 +79,7 @@ export interface AIGenerateCSSResp {
}
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<AIGenerateCSSResp> {
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload);
const { data } = await api.post<AIGenerateCSSResp>('/ai/css/generate', payload, { timeout: AI_TIMEOUT });
let parsed = data as any;
if (typeof parsed === 'string') {
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
@@ -101,7 +103,7 @@ export interface AIGenerateAboutResp {
}
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<AIGenerateAboutResp> {
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload);
const { data } = await api.post<AIGenerateAboutResp>('/ai/about/generate', payload, { timeout: AI_TIMEOUT });
// Handle potential JSON string response from AI (defensive parsing)
let parsedData = data;
+114
View File
@@ -0,0 +1,114 @@
import { triggerPrefetch } from './admin/prefetch';
import { Article, CreateArticlePayload, UpdateArticlePayload, createArticle, updateArticle } from './articles';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function normStr(v: any): string {
return String(v ?? '').trim();
}
function normalizeAttachments(aRaw: any): Array<{ name: string; url: string; mime_type?: string; size?: number }> | undefined {
try {
const arr = Array.isArray(aRaw) ? aRaw : (typeof aRaw === 'string' ? JSON.parse(aRaw) : []);
if (!Array.isArray(arr) || arr.length === 0) return undefined;
return arr.map((it: any) => {
if (typeof it === 'string') {
const name = it.split('/').pop() || 'soubor';
return { name, url: it };
}
const name = it?.name || (String(it?.url || '').split('/').pop() || 'soubor');
const url = String(it?.url || '');
const mime_type = it?.mime_type || it?.type;
const size = typeof it?.size === 'number' ? it.size : undefined;
return { name, url, mime_type, size };
});
} catch {
return undefined;
}
}
function normalizeGalleryIds(raw: any): string[] | undefined {
if (Array.isArray(raw)) return raw.map(String);
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed.map(String);
} catch {}
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
return undefined;
}
function buildUpdatePayload(editing: Partial<Article>): UpdateArticlePayload {
const attachments = normalizeAttachments((editing as any)?.attachments);
const galleryIds = normalizeGalleryIds((editing as any)?.gallery_photo_ids);
return {
title: normStr((editing as any)?.title),
content: typeof (editing as any)?.content === 'string' ? (editing as any).content : '',
image_url: normStr((editing as any)?.image_url),
...(typeof (editing as any)?.category_id === 'number' ? { category_id: (editing as any).category_id } : {}),
category_name: normStr((editing as any)?.category_name),
slug: normStr((editing as any)?.slug),
seo_title: normStr((editing as any)?.seo_title),
seo_description: normStr((editing as any)?.seo_description),
og_image_url: normStr((editing as any)?.og_image_url),
featured: !!(editing as any)?.featured,
// Gallery
gallery_album_id: normStr((editing as any)?.gallery_album_id),
gallery_album_url: normStr((editing as any)?.gallery_album_url),
...(galleryIds ? { gallery_photo_ids: galleryIds } : {}),
// YouTube
youtube_video_id: normStr((editing as any)?.youtube_video_id),
youtube_video_title: normStr((editing as any)?.youtube_video_title),
youtube_video_url: normStr((editing as any)?.youtube_video_url),
youtube_video_thumbnail: normStr((editing as any)?.youtube_video_thumbnail),
// Attachments
...(attachments ? { attachments } : {}),
} as UpdateArticlePayload;
}
function buildCreatePayload(editing: Partial<Article>): CreateArticlePayload {
const u = buildUpdatePayload(editing);
return u as unknown as CreateArticlePayload;
}
export async function saveArticleReliable(editing: Partial<Article>): Promise<Article> {
const id = (editing as any)?.id;
const isUpdate = !!id;
const payloadU = buildUpdatePayload(editing);
const payloadC = buildCreatePayload(editing);
const maxAttempts = 3;
let lastErr: any;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
let saved: Article;
if (isUpdate) {
try {
saved = await updateArticle(id as any, payloadU);
} catch (e: any) {
if (e?.response?.status === 404) {
saved = await createArticle(payloadC);
} else {
throw e;
}
}
} else {
saved = await createArticle(payloadC);
}
if (saved?.published) {
try { await triggerPrefetch(); } catch {}
}
return saved;
} catch (e) {
lastErr = e;
if (attempt < maxAttempts) {
await sleep(attempt * 400);
continue;
}
throw e;
}
}
throw lastErr;
}
+3 -2
View File
@@ -199,7 +199,7 @@ export async function deleteArticle(id: number | string) {
export async function getArticleBySlug(slug: string) {
try {
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
return res.data;
return normalizeArticle(res.data);
} catch (e) {
// Fallback: attempt list query through normalized helper and return first match
const list = await getArticles({ slug });
@@ -239,7 +239,8 @@ export async function uploadFile(file: File) {
export async function trackArticleView(id: number | string) {
try {
await api.post(`/articles/${id}/track-view`);
// Send an explicit empty JSON body to satisfy backend Content-Type validation
await api.post(`/articles/${id}/track-view`, {});
} catch (e) {
console.debug('Failed to track article view:', e);
}
+36 -9
View File
@@ -29,7 +29,8 @@ export function composeInstagramPostFromArticle(params: {
}): string {
const { article, trackingUrl, clubName, hashtags = [], match } = params;
const title = article.title?.trim() || '';
const plain = stripHtml(article.content).slice(0, 280);
const catName = (article as any)?.category?.name || (article as any)?.category_name || '';
const snippet = stripHtml(article.content).slice(0, 160);
const defaultTags = hashtags.length ? hashtags : [
`#${normalizeTag(clubName || 'FKKrnov')}`,
'#fotbal',
@@ -43,15 +44,15 @@ export function composeInstagramPostFromArticle(params: {
const date = match.date_time ? formatDateTime(match.date_time) : '';
const score = match.score && /\d/.test(match.score) ? match.score : '';
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
match.venue ? `Místo: ${match.venue}` : '',
match.venue ? `Místo: ${cleanVenue(String(match.venue))}` : '',
'',
plain ? `${plain}${plain.length === 280 ? '…' : ''}` : '',
snippet ? `${snippet}${snippet.length === 160 ? '…' : ''}` : '',
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -63,11 +64,11 @@ export function composeInstagramPostFromArticle(params: {
}
// Informative/general article
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
const lines = [
header,
'',
plain,
snippet,
'',
'📸 Celý článek najdeš tady 👇',
`🔗 ${trackingUrl}`,
@@ -112,12 +113,38 @@ export function composeInstagramPostFromActivity(params: {
return lines.join('\n');
}
function formatDateTime(dt: string): string {
export function formatDateTime(dt: string): string {
const s = String(dt || '').trim();
// Handle FAČR format: dd.mm.yyyy or dd.mm.yyyy HH:MM
const m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{2}))?$/);
if (m) {
const dd = parseInt(m[1], 10);
const MM = parseInt(m[2], 10);
const yyyy = parseInt(m[3], 10);
const hh = m[4] ? parseInt(m[4], 10) : 0;
const min = m[5] ? parseInt(m[5], 10) : 0;
const d = new Date(yyyy, MM - 1, dd, hh, min);
const dateStr = d.toLocaleDateString('cs-CZ');
const timeStr = (m[4] ? d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' }) : '');
return timeStr ? `${dateStr} ${timeStr}` : dateStr;
}
// ISO-like or other parseable formats
try {
const d = new Date(dt);
const d = new Date(s);
if (!isNaN(d.getTime())) {
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
}
} catch {}
return s;
}
export function cleanVenue(v: string): string {
try {
const base = String(v || '').trim();
// Prefer locality before first " - " (e.g., "Kobeřice - tráva" -> "Kobeřice")
return base.split(' - ')[0].trim();
} catch {
return dt;
return v;
}
}
+19
View File
@@ -0,0 +1,19 @@
import api from './api';
export type RembgStatus = {
running: boolean;
total: number;
done: number;
started_at?: string;
finished_at?: string | null;
};
export const getRembgStatus = async (): Promise<RembgStatus> => {
const res = await api.get('/rembg/status', { timeout: 20000 });
return res.data;
};
export const startRembgBatch = async (): Promise<{ started: boolean; status: RembgStatus }> => {
const res = await api.post('/rembg/start', null, { timeout: 20000 });
return res.data;
};
+27 -2
View File
@@ -12,6 +12,8 @@ export type ScoreboardState = {
awayShort?: string;
primaryColor?: string; // home color
secondaryColor?: string; // away color
homeTextColor?: string; // text color for home label/short
awayTextColor?: string; // text color for away label/short
homeScore: number;
awayScore: number;
homeFouls?: number;
@@ -286,15 +288,32 @@ export async function derivePrimaryFromLogo(logoUrl?: string): Promise<string |
// Helpers to map API payloads
function normalizeFromApi(d: any): Partial<ScoreboardState> {
if (!d) return {};
const absolutize = (u?: string) => {
try {
if (!u) return '';
const s = String(u);
if (s.startsWith('/uploads/') || s.startsWith('/dist/')) {
const base = new URL(API_URL || '', typeof window !== 'undefined' ? window.location.origin : undefined);
return `${base.protocol}//${base.host}${s}`;
}
return s;
} catch {
return u || '';
}
};
const rawHome = d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '';
const rawAway = d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '';
return {
homeName: d.homeName || d.home_name || d.HomeName || '',
awayName: d.awayName || d.away_name || d.AwayName || '',
homeLogo: d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '',
awayLogo: d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '',
homeLogo: absolutize(rawHome),
awayLogo: absolutize(rawAway),
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || undefined,
homeTextColor: d.homeTextColor || d.home_text_color || d.HomeTextColor || undefined,
awayTextColor: d.awayTextColor || d.away_text_color || d.AwayTextColor || undefined,
homeScore: typeof d.homeScore === 'number' ? d.homeScore : (typeof d.home_score === 'number' ? d.home_score : 0),
awayScore: typeof d.awayScore === 'number' ? d.awayScore : (typeof d.away_score === 'number' ? d.away_score : 0),
homeFouls: typeof d.homeFouls === 'number' ? d.homeFouls : (typeof d.home_fouls === 'number' ? d.home_fouls : 0),
@@ -322,6 +341,8 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.awayShort !== undefined) out.awayShort = p.awayShort;
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
if (p.homeTextColor !== undefined) out.homeTextColor = p.homeTextColor;
if (p.awayTextColor !== undefined) out.awayTextColor = p.awayTextColor;
if (p.homeScore !== undefined) out.homeScore = p.homeScore;
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
@@ -337,3 +358,7 @@ function toApiPayload(p: Partial<ScoreboardState>) {
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
return out;
}
export async function deleteQr(): Promise<void> {
await api.delete('/admin/scoreboard/qr');
}
+34 -13
View File
@@ -18,32 +18,53 @@ export interface ShortLinkResponse {
}
export async function createShortLink(payload: CreateShortLinkPayload): Promise<ShortLinkResponse> {
const normalized: CreateShortLinkPayload = { ...payload };
if (normalized.target_url && !/^https?:\/\//i.test(normalized.target_url)) {
normalized.target_url = `https://${normalized.target_url}`;
}
if (typeof normalized.code === 'string') {
const s = normalized.code.trim();
const filtered = s.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16);
normalized.code = filtered || undefined;
}
// Prefer admin endpoint in admin contexts to avoid 400/403 on public routes
try {
// Prefer editor-accessible endpoint
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
return res.data;
} catch (e: any) {
// Fallback to admin endpoint (for admin-only contexts)
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
return res2.data;
const resAdmin = await api.post<ShortLinkResponse>('/admin/shortlinks', normalized);
return resAdmin.data;
} catch (_) {
// Fallback to public/editor route if admin path is not available
try {
const resPublic = await api.post<ShortLinkResponse>('/shortlinks', normalized);
return resPublic.data;
} catch (e2: any) {
// Last resort: public-create endpoint (strict allowed-host policy)
const resPub = await api.post<ShortLinkResponse>('/shortlinks/public', {
target_url: normalized.target_url!,
title: normalized.title,
} as any);
return resPub.data;
}
}
}
// Public shortlink creation for visitors (no auth; backend validates allowed host)
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
const res = await api.post<ShortLinkResponse>('/shortlinks/public', payload);
const body = { ...payload };
if (body.target_url && !/^https?:\/\//i.test(body.target_url)) {
body.target_url = `https://${body.target_url}`;
}
const res = await api.post<ShortLinkResponse>('/shortlinks/public', body);
return res.data;
}
export async function listShortLinks(): Promise<{ items: any[] }> {
// Prefer editor-accessible endpoint
// Prefer admin endpoint first in admin context
try {
const resAdmin = await api.get<{ items: any[] }>('/admin/shortlinks');
return resAdmin.data;
} catch (_) {
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;
}
}
+37 -4
View File
@@ -171,12 +171,31 @@
align-items: center;
justify-content: center;
font-weight: 700;
width: 32px;
height: 32px;
border: 1px solid #e2e8f0;
border-radius: 4px;
color: #e53e3e;
position: relative;
}
.ql-toolbar.ql-snow button.ql-colorreset::before,
.ql-toolbar.ql-snow button.ql-bgreset::before {
content: "×";
font-size: 16px;
line-height: 1;
content: "";
position: absolute;
width: 18px;
height: 18px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 2px;
}
.ql-toolbar.ql-snow button.ql-colorreset::after,
.ql-toolbar.ql-snow button.ql-bgreset::after {
content: "";
position: absolute;
width: 20px;
height: 2px;
background: #e53e3e;
transform: rotate(45deg);
}
/* Center icons and enlarge align icon */
@@ -265,6 +284,20 @@
margin: 0.25em 0;
}
/* Quill v2 renders bullets via li[data-list=bullet] > .ql-ui::before. Allow switching marker type via parent UL[data-bullets] */
.ql-editor ul[data-bullets="disc"] li[data-list="bullet"] > .ql-ui::before { content: '\2022'; }
.ql-editor ul[data-bullets="circle"] li[data-list="bullet"] > .ql-ui::before { content: '\25E6'; }
.ql-editor ul[data-bullets="square"] li[data-list="bullet"] > .ql-ui::before { content: '\25AA'; }
/* Ensure our custom marker is visible and not overridden */
.ql-editor li[data-list] > .ql-ui::before {
display: inline-block;
width: 1.2em;
margin-left: -1.5em;
margin-right: 0.3em;
text-align: right;
}
.ql-editor blockquote {
border-left: 4px solid #3182ce;
padding-left: 16px;
@@ -425,7 +458,7 @@
.ql-editor {
background-color: white !important;
color: #2d3748 !important;
/* do not force color here; allow inline styles from the editor to apply */
}
/* Responsive Adjustments */
+1 -1
View File
@@ -26,5 +26,5 @@
},
"include": [
"src"
]
, "public/tinymce" ]
}
+6
View File
@@ -85,6 +85,9 @@ type Config struct {
ClamAVEnabled bool
ClamAVHost string
ClamAVPort int
// Feature flags
RembgEnabled bool
}
var AppConfig *Config
@@ -192,6 +195,9 @@ func LoadConfig() {
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
// Feature flags
RembgEnabled: getEnvAsBool("REMBG_ENABLED", true),
}
// Override allowed origins if specified in environment (comma-separated)
+198 -65
View File
@@ -6,10 +6,12 @@ import (
"fmt"
"html"
"net/http"
"os"
"regexp"
"strings"
"time"
"os"
"fotbal-club/pkg/httpclient"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -34,14 +36,20 @@ func (ac *AIController) GenerateCSS(c *gin.Context) {
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
rootSelector := strings.TrimSpace(req.RootSelector)
if rootSelector == "" {
en := strings.TrimSpace(req.ElementName)
if en == "" { en = "element" }
if en == "" {
en = "element"
}
rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en)
}
@@ -65,23 +73,41 @@ func (ac *AIController) GenerateCSS(c *gin.Context) {
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
if err != nil {
return "", http.StatusInternalServerError, err
}
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
reqHTTP.Header.Set("HTTP-Referer", ref)
}
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
reqHTTP.Header.Set("X-Title", ttl)
}
client := httpclient.SlowClient()
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
if err != nil {
return "", http.StatusBadGateway, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
var or struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
return "", http.StatusBadGateway, err
}
if len(or.Choices) == 0 {
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
}
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
@@ -90,9 +116,16 @@ func (ac *AIController) GenerateCSS(c *gin.Context) {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
return
}
if fbErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
return
}
}
@@ -317,6 +350,7 @@ type aiInstagramRequest struct {
Hashtags []string `json:"hashtags"`
Audience string `json:"audience"`
Tone string `json:"tone"`
Category string `json:"category"`
Match *aiInstaMatch `json:"match"`
}
@@ -333,40 +367,74 @@ func (ac *AIController) GenerateInstagram(c *gin.Context) {
}
// Normalize
t := strings.ToLower(strings.TrimSpace(req.Type))
if t == "" { t = "article" }
if t == "" {
t = "article"
}
club := strings.TrimSpace(req.ClubName)
if club == "" { club = "Náš klub" }
if club == "" {
club = "Náš klub"
}
audience := strings.TrimSpace(req.Audience)
if audience == "" { audience = "fanoušci klubu" }
if audience == "" {
audience = "fanoušci klubu"
}
tone := strings.TrimSpace(req.Tone)
if tone == "" { tone = "informativní, přátelský" }
if tone == "" {
tone = "informativní, přátelský"
}
// Build system and user messages
system := "Jsi zkušený český social media copywriter pro fotbalový klub. Píšeš poutavé, ale profesionální popisky na Instagram v gramaticky správné češtině (bez neologismů). Buď konkrétní, z textu vyber to nejdůležitější, vyhni se klišé. Výsledek vrať POUZE JSON: {\"text\": \"...\"}."
// Compose contextual notes
var notes []string
if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) }
if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) }
if req.Title != "" {
notes = append(notes, "Titulek: "+req.Title)
}
if strings.TrimSpace(req.Content) != "" {
notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content))
}
if strings.TrimSpace(req.Category) != "" {
notes = append(notes, "Kategorie: "+strings.TrimSpace(req.Category))
}
if req.Match != nil {
m := req.Match
line := []string{}
if m.Home != "" || m.Away != "" { line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away))) }
if strings.TrimSpace(m.Score) != "" { line = append(line, "Výsledek: "+strings.TrimSpace(m.Score)) }
if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) }
if strings.TrimSpace(m.DateTime) != "" { line = append(line, strings.TrimSpace(m.DateTime)) }
if strings.TrimSpace(m.Venue) != "" { line = append(line, "Místo: "+strings.TrimSpace(m.Venue)) }
if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) }
if m.Home != "" || m.Away != "" {
line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away)))
}
if strings.TrimSpace(m.Score) != "" {
line = append(line, "Výsledek: "+strings.TrimSpace(m.Score))
}
if strings.TrimSpace(m.Competition) != "" {
line = append(line, strings.TrimSpace(m.Competition))
}
if strings.TrimSpace(m.DateTime) != "" {
line = append(line, strings.TrimSpace(m.DateTime))
}
if strings.TrimSpace(m.Venue) != "" {
line = append(line, "Místo: "+strings.TrimSpace(m.Venue))
}
if len(line) > 0 {
notes = append(notes, "Zápas: "+strings.Join(line, " • "))
}
}
if strings.TrimSpace(req.Link) != "" {
notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link))
}
if len(req.Hashtags) > 0 {
notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", "))
}
if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) }
if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) }
// Hard requirements
requirements := []string{
"Délka 80140 slov, rozdělit do 23 krátkých odstavců.",
"Délka 5090 slov. Max. 2 krátké odstavce, max. 2 věty v odstavci.",
"Použij maximálně 6 emotikonů (žádné dlouhé řetězy).",
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem.",
"Nevkládej žádné obrázky ani popisy fotografií. Výstup je čistý text bez HTML.",
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem (jediný odkaz).",
"Přidej 46 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
"Pokud jsou v poznámkách údaje o zápase, uveď soutěž, datum (formátuj česky) a místo (bez detailů za ' - ').",
"Preferuj začít titulkem s názvem kategorie, pokud je v poznámkách (např. '[Kategorie] …' nebo 'Kategorie …').",
"Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.",
fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience),
}
@@ -381,9 +449,13 @@ func (ac *AIController) GenerateInstagram(c *gin.Context) {
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
@@ -398,23 +470,41 @@ func (ac *AIController) GenerateInstagram(c *gin.Context) {
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
if err != nil {
return "", http.StatusInternalServerError, err
}
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
reqHTTP.Header.Set("HTTP-Referer", ref)
}
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
reqHTTP.Header.Set("X-Title", ttl)
}
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
if err != nil {
return "", http.StatusBadGateway, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
var or struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
return "", http.StatusBadGateway, err
}
if len(or.Choices) == 0 {
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
}
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
@@ -423,9 +513,16 @@ func (ac *AIController) GenerateInstagram(c *gin.Context) {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
return
}
if fbErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
return
}
}
@@ -440,7 +537,9 @@ func (ac *AIController) GenerateInstagram(c *gin.Context) {
if strings.TrimSpace(out.Text) == "" {
// minimal fallback
txt := req.Title
if txt == "" { txt = "Novinky z klubu" }
if txt == "" {
txt = "Novinky z klubu"
}
out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link))
}
c.JSON(http.StatusOK, out)
@@ -457,9 +556,9 @@ func (ac *AIController) GenerateBlog(c *gin.Context) {
req.MinWords = 450
}
// Build instruction in Czech - emphasizing user text as primary source, but allow expansion if needed
system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy nebo negramatické tvary (např. místo 'nevděkovaný' použij 'nevděčný'). Píšeš srozumitelně a čtivě pro fotbalové fanoušky. HTML výstup bez inline stylů."
user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov.\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru, detaily kolem událostí z textu uživatele. Buď čtivý a zajímavý.\n3) Pokud je text dostatečně dlouhý, pouze ho strukturuj do HTML s nadpisy a odstavci.\n4) Vygeneruj výstižný titulek vycházející z obsahu textu uživatele.\n5) Vytvoř URL slug (3-5 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}\n7) HTML obsah = text uživatele + rozvinutí (pokud nutné) strukturovaný do HTML tagů (h2, p, ul, ol). BEZ inline stylů.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience))
// Build instruction in Czech - emphasize richer HTML output and medium length
system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců a používej bohaté HTML prvky: nadpisy h2/h3, odstavce p, seznamy ul/li (alespoň jeden), zvýraznění strong/em, případně krátký blockquote (max 1). DŮLEŽITÉ: Píšeš v GRAMATICKY SPRÁVNÉ češtině - používej pouze existující česká slova a správné tvary. Žádné neologismy ani negramatické tvary. HTML výstup BEZ inline stylů."
user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov (středně dlouhý článek).\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru a detaily okolo událostí z textu uživatele.\n3) Použij bohaté HTML: nadpisy h2/h3, odstavce p, seznamy ul/li (alespoň jeden), zvýraznění strong/em; volitelně 1× blockquote.\n4) Vygeneruj výstižný titulek z obsahu textu uživatele.\n5) Vytvoř URL slug (35 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}.\n7) HTML bez inline stylů, žádné <html>/<body> tagy.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience))
// Prepare OpenRouter request
baseURL := getOpenRouterBaseURL()
@@ -500,8 +599,12 @@ func (ac *AIController) GenerateBlog(c *gin.Context) {
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
// Optional but recommended headers for OpenRouter
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
reqHTTP.Header.Set("HTTP-Referer", ref)
}
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
reqHTTP.Header.Set("X-Title", ttl)
}
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
@@ -617,7 +720,9 @@ func getOpenRouterFallbackModel() string {
}
// Small utility wrappers to avoid importing os directly multiple times
func getenv(k string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", "")) }
func getenv(k string) string {
return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", ""))
}
// deriveTitle returns a readable title from user prompt
func deriveTitle(s string) string {
@@ -654,13 +759,23 @@ func slugify(s string) string {
// isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset
func isValidShortSlug(s string) bool {
s = strings.TrimSpace(s)
if s == "" { return false }
if len(s) > 40 { return false }
if s == "" {
return false
}
if len(s) > 40 {
return false
}
parts := strings.Split(s, "-")
// filter empty parts
w := 0
for _, p := range parts { if p != "" { w++ } }
if w < 3 || w > 5 { return false }
for _, p := range parts {
if p != "" {
w++
}
}
if w < 3 || w > 5 {
return false
}
// allowed chars: a-z0-9-
re := regexp.MustCompile(`^[a-z0-9-]+$`)
return re.MatchString(s)
@@ -669,30 +784,48 @@ func isValidShortSlug(s string) bool {
// shortSlugFromPrompt creates a compact, independent slug from the prompt text
func shortSlugFromPrompt(prompt string) string {
p := strings.ToLower(strings.TrimSpace(prompt))
if p == "" { return "clanek" }
if p == "" {
return "clanek"
}
// basic diacritics removal via slugify, then split to words
p = slugify(p)
parts := strings.Split(p, "-")
// simple Czech stopwords list (subset)
stop := map[string]struct{}{"a":{},"i":{},"v":{},"ve":{},"z":{},"za":{},"od":{},"do":{},"u":{},"o":{},"s":{},"se":{},"na":{},"po":{},"pod":{},"nad":{},"proti":{},"pri":{},"bez":{},"k":{},"ke":{},"ten":{},"ta":{},"to":{},"ty":{},"tento":{},"tato":{},"toto":{},"jak":{},"jako":{},"ze":{}}
stop := map[string]struct{}{"a": {}, "i": {}, "v": {}, "ve": {}, "z": {}, "za": {}, "od": {}, "do": {}, "u": {}, "o": {}, "s": {}, "se": {}, "na": {}, "po": {}, "pod": {}, "nad": {}, "proti": {}, "pri": {}, "bez": {}, "k": {}, "ke": {}, "ten": {}, "ta": {}, "to": {}, "ty": {}, "tento": {}, "tato": {}, "toto": {}, "jak": {}, "jako": {}, "ze": {}}
var kept []string
for _, w := range parts {
if w == "" { continue }
if _, ok := stop[w]; ok { continue }
kept = append(kept, w)
if len(kept) >= 5 { break }
if w == "" {
continue
}
if _, ok := stop[w]; ok {
continue
}
kept = append(kept, w)
if len(kept) >= 5 {
break
}
}
if len(kept) == 0 {
kept = parts
}
if len(kept) == 0 { kept = parts }
// prefer 3-5 words, trim to 4 if too many
if len(kept) > 5 { kept = kept[:5] }
if len(kept) >= 4 { kept = kept[:4] }
if len(kept) > 5 {
kept = kept[:5]
}
if len(kept) >= 4 {
kept = kept[:4]
}
s := strings.Join(kept, "-")
if len(s) > 40 { s = s[:40] }
if len(s) > 40 {
s = s[:40]
}
s = strings.Trim(s, "-")
if !isValidShortSlug(s) {
// final fallback
s = slugify(deriveTitle(prompt))
if len(s) > 40 { s = s[:40] }
if len(s) > 40 {
s = s[:40]
}
s = strings.Trim(s, "-")
}
return s
+173 -39
View File
@@ -45,6 +45,7 @@ type BaseController struct {
// generateTeamNameAliases returns alternative keys for a team name to improve matching on the frontend.
// Examples:
//
// "FK Hrtus & Partner Staré Město, z.s." -> ["FK Hrtus & Partner Staré Město", "FK H&P Staré Město"]
func generateTeamNameAliases(name string) []string {
base := strings.TrimSpace(name)
@@ -55,39 +56,61 @@ func generateTeamNameAliases(name string) []string {
seen := map[string]struct{}{}
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" || v == base { return }
if _, ok := seen[v]; ok { return }
if v == "" || v == base {
return
}
if _, ok := seen[v]; ok {
return
}
seen[v] = struct{}{}
out = append(out, v)
}
// Alias 1: trim common legal suffixes at the end (z.s., o.s.) and trailing comma/space
t := trimLegalSuffixes(base)
t = strings.TrimSpace(t)
if t != "" && t != base { add(t) }
if t != "" && t != base {
add(t)
}
// Alias 2: sponsor initials around '&' (e.g., "Hrtus & Partner" -> "H&P")
s := abbreviateAmpersand(t)
if s != "" && s != base && s != t { add(s) }
if s != "" && s != base && s != t {
add(s)
}
e := expandPNAbbrev(t)
if e != "" && e != base && e != t { add(e) }
if e != "" && e != base && e != t {
add(e)
}
es := abbreviateAmpersand(e)
if es != "" && es != base && es != t && es != e { add(es) }
if es != "" && es != base && es != t && es != e {
add(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 }
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 }
if len(sub) < 2 {
return m
}
letter := firstRuneUpper(sub[1])
if letter == "" { return m }
if letter == "" {
return m
}
if withFinalDot {
if withSpace { return repPrefix + " " + letter + "." }
if withSpace {
return repPrefix + " " + letter + "."
}
return repPrefix + letter + "."
}
if withSpace { return repPrefix + " " + letter }
if withSpace {
return repPrefix + " " + letter
}
return repPrefix + letter
})
}
@@ -108,36 +131,57 @@ func generateTeamNameAliases(name string) []string {
out := []string{}
addv := func(x string) {
x = strings.TrimSpace(x)
if x == "" || x == s { return }
if _, ok := seen[x]; ok { return }
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)
addv(a)
addv(b)
addv(c)
addv(d)
return out
}
for _, v := range []string{t, e} {
for _, p := range makePNAbbrevs(v) { add(p) }
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) }
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) }
for _, p := range makePNAbbrevs(v) {
add(p)
}
}
variants := []string{t, s, e, es, st, se}
for _, v := range variants {
if strings.TrimSpace(v) == "" { continue }
if strings.TrimSpace(v) == "" {
continue
}
nd := strings.ReplaceAll(v, ".", "")
nd = strings.TrimSpace(reMultiSpace.ReplaceAllString(nd, " "))
if nd != "" && nd != base { add(nd) }
if nd != "" && nd != base {
add(nd)
}
fa := foldAccents(v)
if fa != "" && fa != base { add(fa) }
if fa != "" && fa != base {
add(fa)
}
}
return out
}
@@ -164,17 +208,23 @@ var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj
func stripOrgPrefixes(s string) string {
x := strings.TrimSpace(s)
if x == "" { return x }
if x == "" {
return x
}
for {
nx := reLeadingOrg.ReplaceAllString(x, "")
nx = strings.TrimSpace(nx)
if nx == x || nx == "" { return nx }
if nx == x || nx == "" {
return nx
}
x = nx
}
}
func expandPNAbbrev(s string) string {
if s == "" { return s }
if s == "" {
return s
}
x := reAbbrevP.ReplaceAllString(s, "pod ")
x = reAbbrevN.ReplaceAllString(x, "nad ")
x = strings.TrimSpace(reMultiSpace.ReplaceAllString(x, " "))
@@ -263,12 +313,47 @@ func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
var art models.Article
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Fallback: try to locate the article in cached JSON so article pages work without DB seed
lookup := func(path string) (*models.Article, bool) {
b, e := os.ReadFile(path)
if e != nil {
return nil, false
}
// Try wrapper {items: []}
var wrap struct {
Items []models.Article `json:"items"`
}
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
for i := range wrap.Items {
if strings.TrimSpace(strings.ToLower(wrap.Items[i].Slug)) == strings.ToLower(slug) {
return &wrap.Items[i], true
}
}
}
// Fallback to raw array
var arr []models.Article
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
for i := range arr {
if strings.TrimSpace(strings.ToLower(arr[i].Slug)) == strings.ToLower(slug) {
return &arr[i], true
}
}
}
return nil, false
}
if a, ok := lookup(filepath.Join("cache", "blogs", "articles.json")); ok {
art = *a
} else if a2, ok2 := lookup(filepath.Join("cache", "prefetch", "articles.json")); ok2 {
art = *a2
} else {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
}
// Restrict unpublished article visibility
if !art.Published {
roleVal, hasRole := c.Get("userRole")
@@ -276,7 +361,9 @@ func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
uidVal, hasUID := c.Get("userID")
var uid uint
if hasUID {
if u, ok := uidVal.(uint); ok { uid = u }
if u, ok := uidVal.(uint); ok {
uid = u
}
}
isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid)
if !hasRole || (role != "admin" && role != "editor" && !isOwner) {
@@ -744,7 +831,9 @@ 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 {
if it.ExternalTeamID == "" { continue }
if it.ExternalTeamID == "" {
continue
}
tloByID[strings.ToLower(it.ExternalTeamID)] = it
}
for i := range rows {
@@ -1171,7 +1260,9 @@ func (bc *BaseController) GetArticle(c *gin.Context) {
uidVal, hasUID := c.Get("userID")
var uid uint
if hasUID {
if u, ok := uidVal.(uint); ok { uid = u }
if u, ok := uidVal.(uint); ok {
uid = u
}
}
isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid)
if !hasRole || (role != "admin" && role != "editor" && !isOwner) {
@@ -1632,18 +1723,24 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
// Save changes
if err := bc.DB.Save(&art).Error; err != nil {
tx := bc.DB.Begin()
if err := tx.Save(&art).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()})
return
}
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba transakce při ukládání článku"})
return
}
go func(a models.Article) {
ft := services.NewFileTracker(bc.DB)
ft.TrackArticleFiles(&a)
}(art)
if art.Published && !oldPublished {
go bc.triggerBlogNotification(&art)
// Always refresh cache after any edit to a published article so /cache/prefetch/articles.json reflects changes
if art.Published {
go func() {
var s models.Settings
if err := bc.DB.First(&s).Error; err == nil {
@@ -1657,6 +1754,10 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
}()
}
// Send blog notification only on first publish
if art.Published && !oldPublished {
go bc.triggerBlogNotification(&art)
}
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
if art.ImageURL == "" {
@@ -3438,14 +3539,21 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
// Run all setup operations in a single background goroutine
go func(settingsID uint, youtubeURL, galleryURL, adminEmail string, apiBase string) {
defer func() { _ = recover() }()
// 1. Trigger prefetch (matches, standings, etc.)
baseURL := strings.TrimSpace(apiBase)
if baseURL == "" {
baseURL = getPrefetchBaseURL()
}
services.PrefetchOnce(strings.TrimRight(baseURL, "/"))
logger.Info("Background prefetch completed")
if config.AppConfig != nil && config.AppConfig.RembgEnabled {
if services.StartFACRLogosBatch("cache/prefetch") {
logger.Info("FACR logos batch started (rembg)")
} else {
logger.Info("FACR logos batch not started (already running or nothing to process)")
}
} else {
logger.Info("FACR logos batch disabled by config")
}
// Auto-populate competition aliases from FACR data
bc.autoPopulateCompetitionAliases()
@@ -3872,13 +3980,35 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
if body.StorageCriticalThreshold != nil {
s.StorageCriticalThreshold = *body.StorageCriticalThreshold
}
if s.StorageWarnThreshold <= 0 {
s.StorageWarnThreshold = 80
}
if s.StorageCriticalThreshold <= 0 {
s.StorageCriticalThreshold = 95
}
if s.StorageWarnThreshold > s.StorageCriticalThreshold {
s.StorageWarnThreshold = s.StorageCriticalThreshold - 5
if s.StorageWarnThreshold < 0 {
s.StorageWarnThreshold = 0
}
}
// External error-review integration
if body.ErrorReviewIngestURL != nil { s.ErrorReviewIngestURL = strings.TrimSpace(*body.ErrorReviewIngestURL) }
if body.ErrorReviewIngestToken != nil { s.ErrorReviewIngestToken = strings.TrimSpace(*body.ErrorReviewIngestToken) }
if body.ErrorReviewAdminURL != nil { s.ErrorReviewAdminURL = strings.TrimSpace(*body.ErrorReviewAdminURL) }
if body.ErrorReviewAdminToken != nil { s.ErrorReviewAdminToken = strings.TrimSpace(*body.ErrorReviewAdminToken) }
if body.ErrorReviewUIURL != nil { s.ErrorReviewUIURL = strings.TrimSpace(*body.ErrorReviewUIURL) }
if body.ErrorReviewIngestURL != nil {
s.ErrorReviewIngestURL = strings.TrimSpace(*body.ErrorReviewIngestURL)
}
if body.ErrorReviewIngestToken != nil {
s.ErrorReviewIngestToken = strings.TrimSpace(*body.ErrorReviewIngestToken)
}
if body.ErrorReviewAdminURL != nil {
s.ErrorReviewAdminURL = strings.TrimSpace(*body.ErrorReviewAdminURL)
}
if body.ErrorReviewAdminToken != nil {
s.ErrorReviewAdminToken = strings.TrimSpace(*body.ErrorReviewAdminToken)
}
if body.ErrorReviewUIURL != nil {
s.ErrorReviewUIURL = strings.TrimSpace(*body.ErrorReviewUIURL)
}
// SMTP dynamic settings (if provided)
if body.SMTPHost != nil {
@@ -5047,7 +5177,9 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
defer src.Close()
buf := make([]byte, 2048)
n, _ := io.ReadFull(src, buf)
if n < 0 { n = 0 }
if n < 0 {
n = 0
}
dl := strings.ToLower(http.DetectContentType(buf[:n]))
validCT := false
@@ -5119,7 +5251,9 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
if h != "" {
host = h
}
}
}
if !strings.Contains(host, ":") {
+292 -87
View File
@@ -1,12 +1,12 @@
package controllers
import (
"net/http"
"strings"
"time"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -14,6 +14,7 @@ import (
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/validation"
)
type CommentController struct{ DB *gorm.DB }
@@ -25,25 +26,46 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
// Active = until is NULL (permanent) OR until > now
now := time.Now()
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.StatusInternalServerError, gin.H{"error": "Failed to load bans"})
return
}
// 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 }
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 }
for _, r := range rows {
users[r.ID] = r
}
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
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 } }
for _, p := range profs {
if strings.TrimSpace(p.Username) != "" {
usernameByID[p.UserID] = p.Username
}
}
}
type banOut struct {
ID uint `json:"id"`
@@ -63,7 +85,7 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
}
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 }
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
@@ -71,7 +93,9 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
o.User.Email = u.Email
o.User.Role = u.Role
}
if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v }
if v, ok := usernameByID[b.UserID]; ok {
o.User.Username = v
}
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
@@ -83,7 +107,8 @@ func (cc *CommentController) AdminLiftBan(c *gin.Context) {
id := c.Param("id")
now := time.Now()
if err := cc.DB.Model(&models.CommentBan{}).Where("id = ?", id).Update("until", now).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to lift ban"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to lift ban"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -96,7 +121,9 @@ func (cc *CommentController) ReportComment(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Reason string `json:"reason"` }
var body struct {
Reason string `json:"reason"`
}
_ = c.ShouldBindJSON(&body)
uid, _ := c.Get("userID")
// Prevent duplicate reports by same user
@@ -105,8 +132,11 @@ func (cc *CommentController) ReportComment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
rep := models.CommentReport{ CommentID: cm.ID, UserID: uid.(uint), Reason: strings.TrimSpace(body.Reason) }
if err := cc.DB.Create(&rep).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
rep := models.CommentReport{CommentID: cm.ID, UserID: uid.(uint), Reason: strings.TrimSpace(body.Reason)}
if err := cc.DB.Create(&rep).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -118,24 +148,38 @@ func (cc *CommentController) React(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Type string `json:"type"` }
var body struct {
Type string `json:"type"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
uid, _ := c.Get("userID")
// 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) }
// Ensure reactions table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.CommentReaction{})
// Validate reaction type against allowed values
rt := strings.TrimSpace(body.Type)
if err := validation.ValidateReactionType(rt); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uidv, _ := c.Get("userID")
userID := uidv.(uint)
// Atomic upsert: enforce single reaction per (comment_id, user_id)
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
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()}),
DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
// Award a small amount of points for reactions (capped per day in service)
svc := services.NewEngagementService(cc.DB)
_, _ = svc.AwardPointsCapped(uid.(uint), 1, "comment_reacted", map[string]interface{}{"comment_id": cm.ID})
_, _ = svc.AwardPointsCapped(userID, 1, "comment_reacted", map[string]interface{}{"comment_id": cm.ID})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -147,8 +191,10 @@ func (cc *CommentController) Unreact(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
uid, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// Ensure reactions table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.CommentReaction{})
uidv, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -158,25 +204,42 @@ func (cc *CommentController) AdminList(c *gin.Context) {
_ = cc.DB.AutoMigrate(&models.Comment{}, &models.CommentReport{}, &models.CommentReaction{})
var items []models.Comment
q := cc.DB.Preload("User").Model(&models.Comment{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("target_type")); v != "" { q = q.Where("target_type = ?", v) }
if v := strings.TrimSpace(c.Query("target_id")); v != "" { q = q.Where("target_id = ?", v) }
if v := strings.TrimSpace(c.Query("user_id")); v != "" { q = q.Where("user_id = ?", v) }
if v := strings.TrimSpace(c.Query("status")); v != "" {
q = q.Where("status = ?", v)
}
if v := strings.TrimSpace(c.Query("target_type")); v != "" {
q = q.Where("target_type = ?", v)
}
if v := strings.TrimSpace(c.Query("target_id")); v != "" {
q = q.Where("target_id = ?", v)
}
if v := strings.TrimSpace(c.Query("user_id")); v != "" {
q = q.Where("user_id = ?", v)
}
page := parseIntDefault(c.Query("page"), 1)
size := parseIntDefault(c.Query("page_size"), 50)
if size > 200 { size = 200 }
if size > 200 {
size = 200
}
var total int64
_ = q.Count(&total).Error
_ = q.Order("created_at DESC").Offset((page-1)*size).Limit(size).Find(&items).Error
_ = q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
// Preload reports counts
ids := make([]uint, 0, len(items))
for _, it := range items { ids = append(ids, it.ID) }
for _, it := range items {
ids = append(ids, it.ID)
}
repCounts := map[uint]int{}
if len(ids) > 0 {
type pr struct{ CommentID uint; Cnt int }
type pr struct {
CommentID uint
Cnt int
}
var rows []pr
_ = cc.DB.Table("comment_reports").Select("comment_id, COUNT(*) as cnt").Where("comment_id IN ?", ids).Group("comment_id").Scan(&rows).Error
for _, r := range rows { repCounts[r.CommentID] = r.Cnt }
for _, r := range rows {
repCounts[r.CommentID] = r.Cnt
}
}
// Compute admin likes (thumbs_up/like) per comment
adminLiked := map[uint]bool{}
@@ -189,7 +252,9 @@ func (cc *CommentController) AdminList(c *gin.Context) {
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
for _, r := range rows {
adminLiked[r.CommentID] = true
}
}
// Prepare target labels (titles) for admin visibility: articles and events
articleIDs := make([]uint, 0)
@@ -208,23 +273,37 @@ func (cc *CommentController) AdminList(c *gin.Context) {
}
articleTitleByID := map[uint]string{}
if len(articleIDs) > 0 {
type row struct{ ID uint; Title string }
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 }
for _, r := range rows {
articleTitleByID[r.ID] = r.Title
}
}
eventTitleByID := map[uint]string{}
if len(eventIDs) > 0 {
type row struct{ ID uint; Title string }
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 }
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 }
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":
@@ -262,34 +341,64 @@ func (cc *CommentController) AdminList(c *gin.Context) {
// Admin: update comment status (visible|hidden)
func (cc *CommentController) AdminUpdateStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Status string `json:"status"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
if body.Status != "visible" && body.Status != "hidden" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return }
var body struct {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid"})
return
}
if body.Status != "visible" && body.Status != "hidden" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid status"})
return
}
if err := cc.DB.Model(&models.Comment{}).Where("id = ?", id).Update("status", body.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}); return
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: ban user for period
func (cc *CommentController) AdminBanUser(c *gin.Context) {
var body struct { UserID uint `json:"user_id"`; Reason string `json:"reason"`; DurationHours int `json:"duration_hours"` }
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var body struct {
UserID uint `json:"user_id"`
Reason string `json:"reason"`
DurationHours int `json:"duration_hours"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid"})
return
}
var until *time.Time
if body.DurationHours > 0 { t := time.Now().Add(time.Duration(body.DurationHours) * time.Hour); until = &t }
if body.DurationHours > 0 {
t := time.Now().Add(time.Duration(body.DurationHours) * time.Hour)
until = &t
}
uid, _ := c.Get("userID")
ban := models.CommentBan{ UserID: body.UserID, Reason: strings.TrimSpace(body.Reason), Until: until, CreatedByID: uid.(uint) }
if err := cc.DB.Create(&ban).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
ban := models.CommentBan{UserID: body.UserID, Reason: strings.TrimSpace(body.Reason), Until: until, CreatedByID: uid.(uint)}
if err := cc.DB.Create(&ban).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Create unban request (auth)
func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
var body struct { Message string `json:"message"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var body struct {
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid"})
return
}
uid, _ := c.Get("userID")
req := models.UnbanRequest{ UserID: uid.(uint), Message: strings.TrimSpace(body.Message) }
if err := cc.DB.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
req := models.UnbanRequest{UserID: uid.(uint), Message: strings.TrimSpace(body.Message)}
if err := cc.DB.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -301,20 +410,40 @@ func (cc *CommentController) AdminListUnban(c *gin.Context) {
// 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 }
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 }
for _, r := range rows {
users[r.ID] = r
}
}
usernameByID := map[uint]string{}
if len(uids) > 0 {
type prof struct{ UserID uint; Username string }
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 } }
for _, p := range profs {
if strings.TrimSpace(p.Username) != "" {
usernameByID[p.UserID] = p.Username
}
}
}
type unbanOut struct {
ID uint `json:"id"`
@@ -336,7 +465,9 @@ func (cc *CommentController) AdminListUnban(c *gin.Context) {
out := make([]unbanOut, 0, len(items))
for _, it := range items {
var u userRow
if r, ok := users[it.UserID]; ok { u = r }
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,
}
@@ -345,7 +476,9 @@ func (cc *CommentController) AdminListUnban(c *gin.Context) {
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 }
if v, ok := usernameByID[it.UserID]; ok {
o.User.Username = v
}
out = append(out, o)
}
c.JSON(http.StatusOK, gin.H{"items": out})
@@ -354,15 +487,29 @@ func (cc *CommentController) AdminListUnban(c *gin.Context) {
// Admin: resolve unban request
func (cc *CommentController) AdminResolveUnban(c *gin.Context) {
id := c.Param("id")
var body struct { Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var body struct {
Action string `json:"action"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid"})
return
}
uid, _ := c.Get("userID")
var req models.UnbanRequest
if err := cc.DB.First(&req, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if body.Action != "approve" && body.Action != "reject" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return }
status := map[string]string{"approve":"approved","reject":"rejected"}[body.Action]
if err := cc.DB.First(&req, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
if body.Action != "approve" && body.Action != "reject" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action"})
return
}
status := map[string]string{"approve": "approved", "reject": "rejected"}[body.Action]
now := time.Now()
if err := cc.DB.Model(&req).Updates(map[string]interface{}{"status": status, "resolved_by_id": uid.(uint), "resolved_at": &now}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
if err := cc.DB.Model(&req).Updates(map[string]interface{}{"status": status, "resolved_by_id": uid.(uint), "resolved_at": &now}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"})
return
}
// If approved, remove bans (set until = now)
if status == "approved" {
_ = cc.DB.Model(&models.CommentBan{}).Where("user_id = ? AND (until IS NULL OR until > ?)", req.UserID, time.Now()).Update("until", now).Error
@@ -432,7 +579,9 @@ func toOutput(c models.Comment) commentOutput {
}
if strings.TrimSpace(c.SpamRules) != "" {
var arr []string
if err := json.Unmarshal([]byte(c.SpamRules), &arr); err == nil { out.SpamRules = arr }
if err := json.Unmarshal([]byte(c.SpamRules), &arr); err == nil {
out.SpamRules = arr
}
}
return out
}
@@ -452,8 +601,12 @@ func (cc *CommentController) GetComments(c *gin.Context) {
page := parseIntDefault(c.Query("page"), 1)
pageSize := parseIntDefault(c.Query("page_size"), 20)
if pageSize > 100 { pageSize = 100 }
if page < 1 { page = 1 }
if pageSize > 100 {
pageSize = 100
}
if page < 1 {
page = 1
}
var total int64
// Visibility rules:
@@ -483,7 +636,7 @@ func (cc *CommentController) GetComments(c *gin.Context) {
var rows []models.Comment
if err := cc.DB.Preload("User").Where(where, args...).
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
@@ -493,18 +646,31 @@ func (cc *CommentController) GetComments(c *gin.Context) {
out := make([]commentOutput, 0, len(rows))
ids := make([]uint, 0, len(rows))
userIDs := make([]uint, 0, len(rows))
for _, r := range rows { ids = append(ids, r.ID) }
for _, r := range rows {
ids = append(ids, r.ID)
}
seenU := map[uint]bool{}
for _, r := range rows { if r.UserID != 0 && !seenU[r.UserID] { userIDs = append(userIDs, r.UserID); seenU[r.UserID] = true } }
for _, r := range rows {
if r.UserID != 0 && !seenU[r.UserID] {
userIDs = append(userIDs, r.UserID)
seenU[r.UserID] = true
}
}
reactionCounts := make(map[uint]map[string]int)
if len(ids) > 0 {
type rc struct{ CommentID uint; Type string; Cnt int }
type rc struct {
CommentID uint
Type string
Cnt int
}
var agg []rc
// aggregate per type
if err := cc.DB.Table("comment_reactions").Select("comment_id, type, COUNT(*) as cnt").
Where("comment_id IN ?", ids).Group("comment_id, type").Scan(&agg).Error; err == nil {
for _, a := range agg {
if reactionCounts[a.CommentID] == nil { reactionCounts[a.CommentID] = map[string]int{} }
if reactionCounts[a.CommentID] == nil {
reactionCounts[a.CommentID] = map[string]int{}
}
reactionCounts[a.CommentID][a.Type] = a.Cnt
}
}
@@ -514,7 +680,9 @@ func (cc *CommentController) GetComments(c *gin.Context) {
var rs []models.CommentReaction
if err := cc.DB.Where("user_id = ? AND comment_id IN ?", uid, ids).Find(&rs).Error; err == nil {
myReactions = make(map[uint]string, len(rs))
for _, r := range rs { myReactions[r.CommentID] = r.Type }
for _, r := range rs {
myReactions[r.CommentID] = r.Type
}
}
}
// Admin liked map
@@ -528,10 +696,17 @@ func (cc *CommentController) GetComments(c *gin.Context) {
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
for _, r := range rows {
adminLiked[r.CommentID] = true
}
}
// Preload user profiles for username + avatar (prefer animated when available)
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string; Username string }
type up struct {
UserID uint
AvatarURL string
AnimatedAvatarURL string
Username string
}
profByUser := map[uint]up{}
if len(userIDs) > 0 {
var profs []up
@@ -548,13 +723,29 @@ func (cc *CommentController) GetComments(c *gin.Context) {
}
if co.User.ID != 0 {
if p, ok := profByUser[co.User.ID]; ok {
if strings.TrimSpace(p.Username) != "" { co.User.Username = p.Username }
if strings.TrimSpace(p.AnimatedAvatarURL) != "" { co.User.AvatarURL = p.AnimatedAvatarURL } else { co.User.AvatarURL = p.AvatarURL }
if strings.TrimSpace(p.Username) != "" {
co.User.Username = p.Username
}
if strings.TrimSpace(p.AnimatedAvatarURL) != "" {
co.User.AvatarURL = p.AnimatedAvatarURL
} else {
co.User.AvatarURL = p.AvatarURL
}
}
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
if adminLiked[r.ID] { co.AdminLiked = true }
}
if rc, ok := reactionCounts[r.ID]; ok {
co.Reactions = rc
} else {
co.Reactions = map[string]int{}
}
if myReactions != nil {
if t, ok := myReactions[r.ID]; ok {
co.MyReaction = t
}
}
if adminLiked[r.ID] {
co.AdminLiked = true
}
out = append(out, co)
}
@@ -619,7 +810,9 @@ func (cc *CommentController) CreateComment(c *gin.Context) {
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", userID, time.Now()).Order("created_at DESC").First(&activeBan).Error; err == nil && activeBan.ID != 0 {
// User is banned
until := "trvale"
if activeBan.Until != nil { until = activeBan.Until.Format(time.RFC3339) }
if activeBan.Until != nil {
until = activeBan.Until.Format(time.RFC3339)
}
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování.", "until": until})
return
}
@@ -711,7 +904,9 @@ func (cc *CommentController) UpdateComment(c *gin.Context) {
cm.IsEdited = true
cm.EditedAt = &now
cm.SpamScore = float32(score)
if b, err := json.Marshal(rules); err == nil { cm.SpamRules = string(b) }
if b, err := json.Marshal(rules); err == nil {
cm.SpamRules = string(b)
}
if err := cc.DB.Save(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update comment"})
@@ -751,10 +946,20 @@ func (cc *CommentController) DeleteComment(c *gin.Context) {
// helpers
func parseIntDefault(s string, def int) int {
if s == "" { return def }
if s == "" {
return def
}
n := 0
for _, ch := range s { if ch < '0' || ch > '9' { return def } }
for i := 0; i < len(s); i++ { n = n*10 + int(s[i]-'0') }
if n <= 0 { return def }
for _, ch := range s {
if ch < '0' || ch > '9' {
return def
}
}
for i := 0; i < len(s); i++ {
n = n*10 + int(s[i]-'0')
}
if n <= 0 {
return def
}
return n
}
+142 -33
View File
@@ -16,6 +16,9 @@ import (
"fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"crypto/rand"
"encoding/hex"
"github.com/gin-gonic/gin"
"gopkg.in/mail.v2"
"gorm.io/datatypes"
@@ -97,7 +100,7 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
// 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 {
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
}
@@ -217,7 +220,9 @@ func (cc *ContactController) UpdateNewsletterSubscriberStatus(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var body struct { IsActive bool `json:"is_active"` }
var body struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
@@ -327,14 +332,18 @@ func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
}
}
}
if len(types) == 0 { types = []string{"blogs", "matches"} }
if len(types) == 0 {
types = []string{"blogs", "matches"}
}
comps := []string{}
if input.Preferences != nil {
if raw, ok := input.Preferences["competitions"]; ok {
if s, ok2 := raw.(string); ok2 && strings.TrimSpace(s) != "" {
for _, p := range strings.Split(s, ",") {
v := strings.TrimSpace(p)
if v != "" { comps = append(comps, v) }
if v != "" {
comps = append(comps, v)
}
}
}
}
@@ -345,7 +354,9 @@ func (cc *ContactController) PreviewNewsletter(c *gin.Context) {
if strings.TrimSpace(html) == "" {
html = "<p>Pro zadané preference nyní nemáme novinky.</p>"
}
if strings.TrimSpace(subj) == "" { subj = "Newsletter náhled" }
if strings.TrimSpace(subj) == "" {
subj = "Newsletter náhled"
}
c.JSON(http.StatusOK, gin.H{"subject": subj, "html": html})
}
@@ -364,18 +375,29 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
// Build sample newsletter content using digest builder for the selected type
t := strings.ToLower(strings.TrimSpace(input.Type))
if t == "" { t = "newsletter" }
if t == "" {
t = "newsletter"
}
// Recognize digest types; default to generic newsletter template with minimal body
var subj, html string
switch t {
case "blogs", "events", "matches", "scores", "weekly":
types := []string{}
freq := "daily"
if t == "weekly" { types = []string{"blogs","events","matches","scores"}; freq = "weekly" } else { types = []string{t} }
if t == "weekly" {
types = []string{"blogs", "events", "matches", "scores"}
freq = "weekly"
} else {
types = []string{t}
}
prefs := services.NewsletterPrefs{Email: "test@local", ContentTypes: types, Competitions: []string{}, Frequency: freq}
subj, html = services.BuildNewsletterDigest("cache/prefetch", prefs)
if subj == "" { subj = "Test newsletter" }
if html == "" { html = "<p>Testovací obsah není k dispozici.</p>" }
if subj == "" {
subj = "Test newsletter"
}
if html == "" {
html = "<p>Testovací obsah není k dispozici.</p>"
}
default:
subj = "Test newsletter"
html = "<p>Toto je testovací email newsletteru.</p>"
@@ -383,13 +405,24 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
// Prepare recipients
recipients := []string{}
for _, e := range input.Emails { if v := strings.TrimSpace(e); v != "" { recipients = append(recipients, v) } }
if strings.TrimSpace(input.Email) != "" { recipients = append(recipients, strings.TrimSpace(input.Email)) }
for _, e := range input.Emails {
if v := strings.TrimSpace(e); v != "" {
recipients = append(recipients, v)
}
}
if strings.TrimSpace(input.Email) != "" {
recipients = append(recipients, strings.TrimSpace(input.Email))
}
if len(recipients) == 0 {
// fallback to admin email
to := strings.TrimSpace(config.AppConfig.AdminEmail)
if to == "" { to = strings.TrimSpace(config.AppConfig.SMTPFrom) }
if to == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient specified"}); return }
if to == "" {
to = strings.TrimSpace(config.AppConfig.SMTPFrom)
}
if to == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipient specified"})
return
}
recipients = []string{to}
}
@@ -432,11 +465,53 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"})
return
}
// Send welcome email (best-effort) using newsletter template with short body
// Build links (preferences/unsubscribe)
token, _ := utils.GenerateSubscriberToken(emailStr, 60*24*30)
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
manageURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
// Use newsletter email template with a short welcome content
manageURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/unsubscribe/" + url.QueryEscape(emailStr)
// Send styled newsletter welcome (best-effort)
_ = cc.emailService.SendNewsletterWelcome(&email.NewsletterWelcomeData{Email: emailStr, UnsubscribeLink: unsubscribeURL})
// Auto-create user account for the subscriber (fan role) if not exists
var existing models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", emailStr).First(&existing).Error; err == gorm.ErrRecordNotFound {
// Generate a random initial password
pwdBytes := make([]byte, 8)
if _, err := rand.Read(pwdBytes); err != nil {
// fallback to timestamp-derived hex if RNG fails
pwdBytes = []byte(fmt.Sprintf("%d", time.Now().UnixNano()))
}
genPass := hex.EncodeToString(pwdBytes)
if len(genPass) < 8 {
genPass = genPass + "12345678"
}
hashed, herr := utils.HashPassword(genPass)
if herr == nil {
u := models.User{Email: strings.ToLower(emailStr), Password: hashed, Role: "fan", IsActive: true}
if err := cc.DB.Create(&u).Error; err == nil {
// Send account created email with login + manage links (best-effort)
loginURL := baseFE + "/login"
// Reset URL can point to forgot-password page (token flow is initiated by user)
resetURL := baseFE + "/forgot-password"
_ = cc.emailService.SendEmail(&email.EmailData{
Subject: "Váš fan účet byl vytvořen",
To: []string{emailStr},
Template: "fan_account_created",
Data: map[string]interface{}{
"Email": emailStr,
"Password": genPass,
"LoginURL": loginURL,
"ResetURL": resetURL,
"ManageURL": manageURL,
"UnsubscribeURL": unsubscribeURL,
},
})
}
}
}
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
Subject: "Vítejte v odběru",
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
@@ -474,17 +549,26 @@ func (cc *ContactController) SetupNewsletterPreferences(c *gin.Context) {
sub = models.NewsletterSubscription{Email: emailStr, IsActive: true}
}
m := datatypes.JSONMap{}
for k, v := range input.Preferences { m[k] = v }
for k, v := range input.Preferences {
m[k] = v
}
sub.Preferences = m
sub.IsActive = true
if sub.ID == 0 { _ = cc.DB.Create(&sub).Error } else { _ = cc.DB.Save(&sub).Error }
if sub.ID == 0 {
_ = cc.DB.Create(&sub).Error
} else {
_ = cc.DB.Save(&sub).Error
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved"})
}
// GetNewsletterPreferencesByToken returns preferences for token holder
func (cc *ContactController) GetNewsletterPreferencesByToken(c *gin.Context) {
tok := strings.TrimSpace(c.Query("token"))
if tok == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}); return }
if tok == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
return
}
emailStr, err := utils.ParseSubscriberToken(tok)
if err != nil || strings.TrimSpace(emailStr) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
@@ -520,19 +604,33 @@ func (cc *ContactController) SaveNewsletterPreferencesByToken(c *gin.Context) {
sub = models.NewsletterSubscription{Email: emailStr, IsActive: true}
}
m := datatypes.JSONMap{}
for k, v := range input.Preferences { m[k] = v }
for k, v := range input.Preferences {
m[k] = v
}
sub.Preferences = m
sub.IsActive = true
if sub.ID == 0 { _ = cc.DB.Create(&sub).Error } else { _ = cc.DB.Save(&sub).Error }
if sub.ID == 0 {
_ = cc.DB.Create(&sub).Error
} else {
_ = cc.DB.Save(&sub).Error
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences saved", "email": sub.Email, "preferences": sub.Preferences})
}
// UnsubscribeByToken disables subscription using a token
func (cc *ContactController) UnsubscribeByToken(c *gin.Context) {
var input struct { Token string `json:"token"` }
if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}); return }
var input struct {
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
emailStr, err := utils.ParseSubscriberToken(strings.TrimSpace(input.Token))
if err != nil || strings.TrimSpace(emailStr) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"}); return }
if err != nil || strings.TrimSpace(emailStr) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
return
}
if err := models.UnsubscribeFromNewsletter(cc.DB, emailStr); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
return
@@ -849,21 +947,32 @@ 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" }
if weeklyDay == "" {
weeklyDay = "sun"
}
weeklyHour := s.NewsletterWeeklyHour
if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 }
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
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++ {
+120 -1
View File
@@ -14,6 +14,7 @@ import (
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
@@ -225,6 +226,10 @@ func getLogoBySearch(name string) string {
best = payload.Results[0].LogoURL
}
if best != "" {
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
return best
}
@@ -280,6 +285,9 @@ func getLogoBySearch(name string) string {
best = partial
}
if best != "" {
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
}
return best
@@ -292,11 +300,18 @@ func getLogo(teamName, teamID string) string {
return placeholder
}
if logo := getLogoBySearch(teamName); logo != "" {
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
return p
}
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
return p
}
return u
}
return placeholder
}
@@ -410,6 +425,10 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
}
img := a.Find("img").First()
logoURL, _ := img.Attr("src")
// Best-effort: Process FACR logos to transparent PNG. Non-facr URLs are returned unchanged.
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
logoURL = p
}
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
clubType := "football"
@@ -535,6 +554,63 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
// Best-effort: rewrite FACR logos in matches to processed local PNGs via rembg
// so that all consumers receive transparent logos consistently.
{
var orig map[string]any
if json.Unmarshal(b, &orig) == nil {
if comps, ok := orig["competitions"].([]any); ok {
seen := map[string]string{}
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
if matches, ok2 := comp["matches"].([]any); ok2 {
for j := range matches {
m, _ := matches[j].(map[string]any)
if m == nil {
continue
}
// home_logo_url
if s, ok3 := m["home_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
m["home_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
m["home_logo_url"] = p
} else {
seen[s] = s
}
}
}
// away_logo_url
if s, ok3 := m["away_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
m["away_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
m["away_logo_url"] = p
} else {
seen[s] = s
}
}
}
}
}
}
if nb, err := json.Marshal(orig); err == nil {
b = nb
}
}
}
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
@@ -573,6 +649,49 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
// Best-effort: rewrite team_logo_url in tables to processed local PNGs via rembg
{
var orig map[string]any
if json.Unmarshal(b, &orig) == nil {
if comps, ok := orig["competitions"].([]any); ok {
seen := map[string]string{}
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
tbl, _ := comp["table"].(map[string]any)
if tbl == nil {
continue
}
overall, _ := tbl["overall"].([]any)
for j := range overall {
row, _ := overall[j].(map[string]any)
if row == nil {
continue
}
if s, ok3 := row["team_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
row["team_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
row["team_logo_url"] = p
} else {
seen[s] = s
}
}
}
}
}
if nb, err := json.Marshal(orig); err == nil {
b = nb
}
}
}
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
+198 -49
View File
@@ -173,19 +173,29 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
updates := map[string]interface{}{}
if v, ok := raw["label"]; ok {
if s, ok2 := v.(string); ok2 { updates["label"] = s }
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 s, ok2 := v.(string); ok2 {
updates["url"] = s
}
}
if v, ok := raw["icon"]; ok {
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
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 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 s, ok2 := v.(string); ok2 {
updates["page_type"] = s
}
}
if v, ok := raw["page_id"]; ok {
switch t := v.(type) {
@@ -202,7 +212,9 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
}
}
if v, ok := raw["visible"]; ok {
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
if b, ok2 := v.(bool); ok2 {
updates["visible"] = b
}
}
if v, ok := raw["display_order"]; ok {
switch t := v.(type) {
@@ -231,16 +243,24 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
}
}
if v, ok := raw["target"]; ok {
if s, ok2 := v.(string); ok2 { updates["target"] = s }
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 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 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 b, ok2 := v.(bool); ok2 {
updates["requires_admin"] = b
}
}
if len(updates) == 0 {
@@ -549,6 +569,7 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
// Create items in a transaction with admin categories and children (seed missing parts only)
seededFrontend := false
seededAdmin := false
addedMissing := false
err := nc.DB.Transaction(func(tx *gorm.DB) error {
if frontendCount == 0 {
for _, item := range frontendItems {
@@ -578,61 +599,141 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
}
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
if err != nil {
return err
}
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil {
return err
}
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil {
return err
}
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 2); err != nil { return err }
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil { return err }
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil { return err }
if err != nil {
return err
}
if err := createChild(sport, "Týmy", "teams", 0); err != nil {
return err
}
if err := createChild(sport, "Zápasy", "matches", 1); err != nil {
return err
}
if err := createChild(sport, "Hráči", "players", 2); err != nil {
return err
}
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil {
return err
}
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil {
return err
}
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil {
return err
}
obsah, err := createCategory("Obsah")
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 != 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
}
// "O klubu" admin page
if err := createChild(obsah, "O klubu", "about", 2); 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 }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil {
return err
}
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Soubory", "files", 2); err != nil { return err }
if err != nil {
return err
}
if err := createChild(media, "Videa", "videos", 0); err != nil {
return err
}
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil {
return err
}
if err := createChild(media, "Soubory", "files", 2); err != nil {
return err
}
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil { return err }
if err != nil {
return err
}
if err := createChild(kom, "Zprávy", "messages", 0); err != nil {
return err
}
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil {
return err
}
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil {
return err
}
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
if err != nil {
return err
}
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil {
return err
}
if err := createChild(marketing, "Bannery", "banners", 1); err != nil {
return err
}
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil {
return err
}
if err := createChild(marketing, "Ankety", "polls", 3); err != nil {
return err
}
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil {
return err
}
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil {
return err
}
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil {
return err
}
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
if err != nil {
return err
}
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil {
return err
}
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil { return err }
if err != nil {
return err
}
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil {
return err
}
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil {
return err
}
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil {
return err
}
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
if err != nil {
return err
}
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil {
return err
}
seededAdmin = true
}
@@ -645,6 +746,51 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
return
}
// Also add missing admin "O klubu" item under "Obsah" when admin navigation exists but the item is missing
if adminCount > 0 {
var aboutCount int64
// Check if an admin nav item with page_type 'about' exists
if err := nc.DB.Model(&models.NavigationItem{}).
Where("requires_admin = ? AND page_type = ?", true, "about").
Count(&aboutCount).Error; err == nil {
if aboutCount == 0 {
// Ensure the 'Obsah' category exists (admin dropdown)
var obsah models.NavigationItem
findCatErr := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND type = ? AND label = ?", true, models.NavTypeDropdown, "Obsah").First(&obsah).Error
if findCatErr != nil {
if findCatErr == gorm.ErrRecordNotFound {
// Create category at the end of admin categories
var maxCat int
nc.DB.Model(&models.NavigationItem{}).
Where("parent_id IS NULL AND requires_admin = ?", true).
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxCat)
obsah = models.NavigationItem{Label: "Obsah", Type: models.NavTypeDropdown, DisplayOrder: maxCat, Visible: true, RequiresAdmin: true}
if err := nc.DB.Create(&obsah).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin category"})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
}
// Create the missing child under 'Obsah'
var maxChild int
nc.DB.Model(&models.NavigationItem{}).
Where("parent_id = ?", obsah.ID).
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxChild)
pid := obsah.ID
aboutNav := models.NavigationItem{Label: "O klubu", Type: models.NavTypeInternal, PageType: "about", DisplayOrder: maxChild, Visible: true, RequiresAdmin: true}
aboutNav.ParentID = &pid
if err := nc.DB.Create(&aboutNav).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create about nav item"})
return
}
addedMissing = true
}
}
}
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
@@ -659,13 +805,16 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
} else if seededAdmin {
message = "Default admin navigation created successfully"
}
if addedMissing && !(seededFrontend || seededAdmin) {
message = "Added missing navigation items"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": seededFrontend || seededAdmin,
"seeded": (seededFrontend || seededAdmin || addedMissing),
"seeded_frontend": seededFrontend,
"seeded_admin": seededAdmin,
})
+30
View File
@@ -0,0 +1,30 @@
package controllers
import (
"net/http"
"path/filepath"
"strings"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
)
type RembgController struct{}
func NewRembgController() *RembgController { return &RembgController{} }
func (rc *RembgController) Status(c *gin.Context) {
s := services.GetRembgStatus()
c.JSON(http.StatusOK, s)
}
func (rc *RembgController) Start(c *gin.Context) {
cacheDir := strings.TrimSpace(c.Query("cache_dir"))
if cacheDir == "" {
cacheDir = filepath.Join("cache", "prefetch")
}
started := services.StartFACRLogosBatch(cacheDir)
s := services.GetRembgStatus()
c.JSON(http.StatusOK, gin.H{"started": started, "status": s})
}
@@ -4,11 +4,11 @@ import (
"bytes"
"fmt"
"image"
"image/png"
_ "image/gif"
_ "image/jpeg"
"mime/multipart"
"image/png"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -48,10 +48,18 @@ func sanitizeAndWriteLogo(data []byte, outPath string) error {
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
continue
}
if x < minX { minX = x }
if y < minY { minY = y }
if x > maxX { maxX = x }
if y > maxY { maxY = y }
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
if minX >= maxX || minY >= maxY {
@@ -70,7 +78,9 @@ func sanitizeAndWriteLogo(data []byte, outPath string) error {
targetH := 64
if ch != targetH {
targetW := int(float64(cw) * float64(targetH) / float64(ch))
if targetW < 1 { targetW = 1 }
if targetW < 1 {
targetW = 1
}
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
for y2 := 0; y2 < targetH; y2++ {
srcY := y2 * ch / targetH
@@ -83,7 +93,9 @@ func sanitizeAndWriteLogo(data []byte, outPath string) error {
nrgba = resized
}
// write PNG
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err }
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
f, err := os.Create(outPath)
if err != nil {
return err
@@ -121,7 +133,9 @@ func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
@@ -151,14 +165,22 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
}
}
for _, hdr := range files {
if hdr == nil { continue }
if hdr == nil {
continue
}
src, err := hdr.Open()
if err != nil { continue }
if err != nil {
continue
}
// do not defer: loop
name := sanitizeFilename(hdr.Filename)
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
if name == "" {
name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
}
base := name
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
if i := strings.LastIndex(name, "."); i >= 0 {
base = name[:i]
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
@@ -201,6 +223,20 @@ func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// DeleteQR deletes the QR image (uploads/qr.png) if present
func (c *ScoreboardController) DeleteQR(ctx *gin.Context) {
path := filepath.Join(uploadsBaseDir(), "qr.png")
if _, err := os.Stat(path); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := os.Remove(path); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetQR returns the current QR image URL if present
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
path := filepath.Join(uploadsBaseDir(), "qr.png")
@@ -237,7 +273,9 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
var body struct{ IDs []uint `json:"ids"` }
var body struct {
IDs []uint `json:"ids"`
}
_ = ctx.ShouldBindJSON(&body)
var list []models.Sponsor
q := c.DB.Model(&models.Sponsor{})
@@ -255,31 +293,51 @@ func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
created := make([]string, 0, len(list))
for _, s := range list {
logo := strings.TrimSpace(s.LogoURL)
if logo == "" { continue }
if logo == "" {
continue
}
var data []byte
if strings.HasPrefix(logo, "/uploads/") {
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
if b, err := os.ReadFile(p); err == nil { data = b } else { continue }
if b, err := os.ReadFile(p); err == nil {
data = b
} else {
continue
}
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
resp, err := http.Get(logo)
if err != nil { continue }
if err != nil {
continue
}
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return
}
b, _ := io.ReadAll(resp.Body)
if len(b) > 0 { data = b }
if len(b) > 0 {
data = b
}
}()
if len(data) == 0 { continue }
if len(data) == 0 {
continue
}
} else {
continue
}
base := sanitizeFilename(s.Name)
if base == "" {
seg := logo
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
if i := strings.LastIndex(seg, "/"); i >= 0 {
seg = seg[i+1:]
}
if j := strings.LastIndex(seg, "."); j >= 0 {
seg = seg[:j]
}
base = sanitizeFilename(seg)
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
if base == "" {
base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
}
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
+350 -103
View File
@@ -1,21 +1,22 @@
package controllers
import (
"net/http"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"time"
"fmt"
"strings"
"io"
"image"
_ "image/png"
_ "image/jpeg"
_ "image/gif"
"net/http/httputil"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -31,7 +32,9 @@ type ScoreboardController struct {
// makeShort derives a 3-letter uppercase abbreviation from a club name.
func makeShort(name string) string {
name = strings.TrimSpace(name)
if name == "" { return "---" }
if name == "" {
return "---"
}
name = strings.ToUpper(name)
repl := strings.NewReplacer(
"Á", "A", "Ä", "A", "Å", "A", "Â", "A", "À", "A",
@@ -53,10 +56,14 @@ func makeShort(name string) string {
for _, r := range name {
if r >= 'A' && r <= 'Z' {
out = append(out, r)
if len(out) == 3 { break }
if len(out) == 3 {
break
}
}
for len(out) < 3 { out = append(out, '-') }
}
for len(out) < 3 {
out = append(out, '-')
}
return string(out)
}
@@ -67,8 +74,13 @@ func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
HomeLogo string `json:"homeLogo"`
AwayLogo string `json:"awayLogo"`
}
type singleResp struct{ Color string `json:"color"` }
type duoResp struct{ PrimaryColor string `json:"primaryColor"`; SecondaryColor string `json:"secondaryColor"` }
type singleResp struct {
Color string `json:"color"`
}
type duoResp struct {
PrimaryColor string `json:"primaryColor"`
SecondaryColor string `json:"secondaryColor"`
}
var q req
q.URL = ctx.Query("url")
@@ -91,10 +103,14 @@ func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
if q.HomeLogo != "" || q.AwayLogo != "" {
var primary, secondary string
if q.HomeLogo != "" {
if col, err := averageColorFromURL(q.HomeLogo); err == nil { primary = col }
if col, err := averageColorFromURL(q.HomeLogo); err == nil {
primary = col
}
}
if q.AwayLogo != "" {
if col, err := averageColorFromURL(q.AwayLogo); err == nil { secondary = col }
if col, err := averageColorFromURL(q.AwayLogo); err == nil {
secondary = col
}
}
ctx.JSON(http.StatusOK, duoResp{PrimaryColor: primary, SecondaryColor: secondary})
return
@@ -105,7 +121,9 @@ func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
// averageColorFromURL downloads an image and computes its average RGB color in hex.
func averageColorFromURL(u string) (string, error) {
resp, err := http.Get(u)
if err != nil { return "", err }
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// best-effort body capture for debugging
@@ -114,30 +132,43 @@ func averageColorFromURL(u string) (string, error) {
return "", fmt.Errorf("http status %d", resp.StatusCode)
}
img, _, err := image.Decode(resp.Body)
if err != nil { return "", err }
if err != nil {
return "", err
}
return averageHex(img), nil
}
func averageHex(img image.Image) string {
rect := img.Bounds()
if rect.Empty() { return "#000000" }
w := rect.Dx(); h := rect.Dy()
if rect.Empty() {
return "#000000"
}
w := rect.Dx()
h := rect.Dy()
stepX, stepY := 1, 1
for (w/stepX)*(h/stepY) > 160000 {
if stepX <= stepY { stepX *= 2 } else { stepY *= 2 }
if stepX <= stepY {
stepX *= 2
} else {
stepY *= 2
}
}
var rsum, gsum, bsum, count uint64
for y := rect.Min.Y; y < rect.Max.Y; y += stepY {
for x := rect.Min.X; x < rect.Max.X; x += stepX {
cr, cg, cb, ca := img.At(x,y).RGBA()
if ca < 0x2000 { continue }
cr, cg, cb, ca := img.At(x, y).RGBA()
if ca < 0x2000 {
continue
}
rsum += uint64(cr >> 8)
gsum += uint64(cg >> 8)
bsum += uint64(cb >> 8)
count++
}
}
if count == 0 { return "#000000" }
if count == 0 {
return "#000000"
}
r8 := uint8(rsum / count)
g8 := uint8(gsum / count)
b8 := uint8(bsum / count)
@@ -147,10 +178,14 @@ func averageHex(img image.Image) string {
// SwapSides toggles visual sides flipping only. It does NOT swap team data.
func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
s.SidesFlipped = !s.SidesFlipped
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -158,44 +193,66 @@ func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
// StartSecondHalf starts the second half without flipping visual sides and continues timer from end of 1st half.
func (c *ScoreboardController) StartSecondHalf(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// Move to second half and continue from end of first half
s.Half = 2
// Ensure base elapsed reflects end of first half
capFirst := s.HalfLength * 60
if capFirst <= 0 { capFirst = 45 * 60 }
if capFirst <= 0 {
capFirst = 45 * 60
}
base := s.ElapsedSeconds
if s.Running && s.TimerStartUnix > 0 {
now := time.Now().Unix()
diff := int(now - s.TimerStartUnix)
if diff > base { base = diff }
if diff > base {
base = diff
}
}
if base < capFirst {
base = capFirst
}
if base < capFirst { base = capFirst }
s.ElapsedSeconds = base
s.Timer = formatSeconds(base)
s.Running = true
s.TimerStartUnix = time.Now().Unix() - int64(base)
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// SaveState saves current scoreboard state as a JSON file in /saved directory.
func (c *ScoreboardController) SaveState(ctx *gin.Context) {
type req struct{ Filename string `json:"filename"` }
type req struct {
Filename string `json:"filename"`
}
var q req
_ = ctx.ShouldBindJSON(&q)
if q.Filename == "" { q.Filename = ctx.Query("filename") }
if q.Filename == "" {
q.Filename = ctx.Query("filename")
}
name := sanitizeFilename(q.Filename)
if name == "" { name = time.Now().Format("20060102-150405") }
if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" }
if name == "" {
name = time.Now().Format("20060102-150405")
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
name += ".json"
}
_ = os.MkdirAll("saved", 0o755)
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
b, _ := json.MarshalIndent(s, "", " ")
if err := os.WriteFile(filepath.Join("saved", name), b, 0o644); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
ctx.JSON(http.StatusOK, gin.H{"saved": name})
}
@@ -203,12 +260,19 @@ func (c *ScoreboardController) SaveState(ctx *gin.Context) {
// ListSaves returns the list of saved preset filenames from /saved
func (c *ScoreboardController) ListSaves(ctx *gin.Context) {
entries, err := os.ReadDir("saved")
if err != nil { ctx.JSON(http.StatusOK, []string{}) ; return }
if err != nil {
ctx.JSON(http.StatusOK, []string{})
return
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(strings.ToLower(name), ".json") { out = append(out, name) }
if strings.HasSuffix(strings.ToLower(name), ".json") {
out = append(out, name)
}
}
ctx.JSON(http.StatusOK, out)
}
@@ -217,7 +281,9 @@ func (c *ScoreboardController) ListSaves(ctx *gin.Context) {
func (c *ScoreboardController) LoadSaved(ctx *gin.Context) {
// Support filename via query, JSON, or multipart form file upload as raw JSON
filename := sanitizeFilename(ctx.Query("filename"))
var body struct{ Filename string `json:"filename"` }
var body struct {
Filename string `json:"filename"`
}
if filename == "" {
_ = ctx.ShouldBindJSON(&body)
filename = sanitizeFilename(body.Filename)
@@ -228,45 +294,86 @@ func (c *ScoreboardController) LoadSaved(ctx *gin.Context) {
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot read file"}); return }
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot read file"})
return
}
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
if err := json.Unmarshal(data, &imported); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
applyImportedState(imported, c, ctx)
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing filename"})
return
}
if !strings.HasSuffix(strings.ToLower(filename), ".json") { filename += ".json" }
if !strings.HasSuffix(strings.ToLower(filename), ".json") {
filename += ".json"
}
path := filepath.Join("saved", filename)
data, err := os.ReadFile(path)
if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"}); return }
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
if err := json.Unmarshal(data, &imported); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
applyImportedState(imported, c, ctx)
}
func applyImportedState(imported models.ScoreboardState, c *ScoreboardController, ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// overwrite relevant fields
s.HomeName = imported.HomeName
s.AwayName = imported.AwayName
s.HomeLogoURL = imported.HomeLogoURL
s.AwayLogoURL = imported.AwayLogoURL
// derive shorts if empty
if strings.TrimSpace(imported.HomeShort) != "" { s.HomeShort = imported.HomeShort } else { s.HomeShort = makeShort(s.HomeName) }
if strings.TrimSpace(imported.AwayShort) != "" { s.AwayShort = imported.AwayShort } else { s.AwayShort = makeShort(s.AwayName) }
if imported.PrimaryColor != "" { s.PrimaryColor = imported.PrimaryColor }
if imported.SecondaryColor != "" { s.SecondaryColor = imported.SecondaryColor }
if strings.TrimSpace(imported.HomeShort) != "" {
s.HomeShort = imported.HomeShort
} else {
s.HomeShort = makeShort(s.HomeName)
}
if strings.TrimSpace(imported.AwayShort) != "" {
s.AwayShort = imported.AwayShort
} else {
s.AwayShort = makeShort(s.AwayName)
}
if imported.PrimaryColor != "" {
s.PrimaryColor = imported.PrimaryColor
}
if imported.SecondaryColor != "" {
s.SecondaryColor = imported.SecondaryColor
}
s.HomeScore = imported.HomeScore
s.AwayScore = imported.AwayScore
// fouls with clamping
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
clamp := func(v int) int {
if v < 0 {
return 0
}
if v > 5 {
return 5
}
return v
}
s.HomeFouls = clamp(imported.HomeFouls)
s.AwayFouls = clamp(imported.AwayFouls)
if imported.HalfLength > 0 { s.HalfLength = imported.HalfLength }
if imported.Theme != "" { s.Theme = imported.Theme }
if imported.HalfLength > 0 {
s.HalfLength = imported.HalfLength
}
if imported.Theme != "" {
s.Theme = imported.Theme
}
// timer handling
base := parseTimerToSeconds(imported.Timer)
s.Timer = fmt.Sprintf("%02d:%02d", base/60, base%60)
@@ -278,7 +385,10 @@ func applyImportedState(imported models.ScoreboardState, c *ScoreboardController
s.Running = false
s.TimerStartUnix = 0
}
if err := c.DB.Save(s).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return }
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -312,7 +422,9 @@ func parseTimerToSeconds(timer string) int {
}
func formatSeconds(sec int) string {
if sec < 0 { sec = 0 }
if sec < 0 {
sec = 0
}
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
}
@@ -323,13 +435,21 @@ func computeTimer(s models.ScoreboardState) (timer string, running bool) {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { base = diff } else { base = 0 }
if diff > 0 {
base = diff
} else {
base = 0
}
}
}
// Cap by half length; allow up to 2*half when second half is active
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if cap <= 0 {
cap = 45 * 60
}
if s.Half >= 2 {
cap = s.HalfLength * 120
}
if base >= cap {
base = cap
running = false
@@ -341,14 +461,21 @@ func computeTimer(s models.ScoreboardState) (timer string, running bool) {
// StartTimer sets running=true and backdates TimerStartUnix
func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
if s.ElapsedSeconds == 0 && s.Timer != "" {
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
}
// Respect caps similarly to computeTimer
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if cap <= 0 {
cap = 45 * 60
}
if s.Half >= 2 {
cap = s.HalfLength * 120
}
if s.ElapsedSeconds >= cap {
// Already at or beyond cap; keep paused at cap
s.ElapsedSeconds = cap
@@ -361,7 +488,8 @@ func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
s.Running = true
}
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -369,25 +497,39 @@ func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
// PauseTimer sets running=false and fixes elapsedSeconds
func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
if s.Running {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { s.ElapsedSeconds = diff } else { s.ElapsedSeconds = 0 }
if diff > 0 {
s.ElapsedSeconds = diff
} else {
s.ElapsedSeconds = 0
}
}
}
s.Running = false
// Cap and set display string
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
if cap <= 0 {
cap = 45 * 60
}
if s.Half >= 2 {
cap = s.HalfLength * 120
}
if s.ElapsedSeconds > cap {
s.ElapsedSeconds = cap
}
s.Timer = formatSeconds(s.ElapsedSeconds)
// Clear start marker when paused
s.TimerStartUnix = 0
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -395,13 +537,17 @@ func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
// ResetTimer clears timer to 00:00 and stops it
func (c *ScoreboardController) ResetTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
s.Running = false
s.ElapsedSeconds = 0
s.TimerStartUnix = 0
s.Timer = "00:00"
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -445,16 +591,41 @@ func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState,
}
// Ensure defaults for newly added fields when loading existing row
changed := false
if s.Half == 0 { s.Half = 1; changed = true }
if s.QRShowEveryMinutes == 0 { s.QRShowEveryMinutes = 5; changed = true }
if s.QRShowDurationSeconds == 0 { s.QRShowDurationSeconds = 60; changed = true }
if s.Half == 0 {
s.Half = 1
changed = true
}
if s.QRShowEveryMinutes == 0 {
s.QRShowEveryMinutes = 5
changed = true
}
if s.QRShowDurationSeconds == 0 {
s.QRShowDurationSeconds = 60
changed = true
}
// Clamp fouls 0..5 and ensure non-negative
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
clamp := func(v int) int {
if v < 0 {
return 0
}
if v > 5 {
return 5
}
return v
}
nf := clamp(s.HomeFouls)
af := clamp(s.AwayFouls)
if s.HomeFouls != nf { s.HomeFouls = nf; changed = true }
if s.AwayFouls != af { s.AwayFouls = af; changed = true }
if changed { _ = c.DB.Save(&s).Error }
if s.HomeFouls != nf {
s.HomeFouls = nf
changed = true
}
if s.AwayFouls != af {
s.AwayFouls = af
changed = true
}
if changed {
_ = c.DB.Save(&s).Error
}
return &s, nil
}
@@ -475,6 +646,8 @@ func (c *ScoreboardController) GetPublic(ctx *gin.Context) {
"awayShort": s.AwayShort,
"primaryColor": s.PrimaryColor,
"secondaryColor": s.SecondaryColor,
"homeTextColor": s.HomeTextColor,
"awayTextColor": s.AwayTextColor,
"homeScore": s.HomeScore,
"awayScore": s.AwayScore,
"homeFouls": s.HomeFouls,
@@ -521,6 +694,8 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
AwayShort *string `json:"awayShort"`
PrimaryColor *string `json:"primaryColor"`
SecondaryColor *string `json:"secondaryColor"`
HomeTextColor *string `json:"homeTextColor"`
AwayTextColor *string `json:"awayTextColor"`
HomeScore *int `json:"homeScore"`
AwayScore *int `json:"awayScore"`
HomeFouls *int `json:"homeFouls"`
@@ -547,28 +722,92 @@ func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
return
}
// Apply patch
if payload.HomeName != nil { s.HomeName = *payload.HomeName }
if payload.AwayName != nil { s.AwayName = *payload.AwayName }
if payload.HomeLogo != nil { s.HomeLogoURL = *payload.HomeLogo }
if payload.AwayLogo != nil { s.AwayLogoURL = *payload.AwayLogo }
if payload.HomeShort != nil { s.HomeShort = *payload.HomeShort }
if payload.AwayShort != nil { s.AwayShort = *payload.AwayShort }
if payload.PrimaryColor != nil { s.PrimaryColor = *payload.PrimaryColor }
if payload.SecondaryColor != nil { s.SecondaryColor = *payload.SecondaryColor }
if payload.HomeScore != nil { s.HomeScore = *payload.HomeScore }
if payload.AwayScore != nil { s.AwayScore = *payload.AwayScore }
if payload.HomeName != nil {
s.HomeName = *payload.HomeName
}
if payload.AwayName != nil {
s.AwayName = *payload.AwayName
}
if payload.HomeLogo != nil {
v := strings.TrimSpace(*payload.HomeLogo)
if p, err := services.ProcessFACRLogo(v); err == nil && strings.TrimSpace(p) != "" {
s.HomeLogoURL = p
} else {
s.HomeLogoURL = *payload.HomeLogo
}
}
if payload.AwayLogo != nil {
v := strings.TrimSpace(*payload.AwayLogo)
if p, err := services.ProcessFACRLogo(v); err == nil && strings.TrimSpace(p) != "" {
s.AwayLogoURL = p
} else {
s.AwayLogoURL = *payload.AwayLogo
}
}
if payload.HomeShort != nil {
s.HomeShort = *payload.HomeShort
}
if payload.AwayShort != nil {
s.AwayShort = *payload.AwayShort
}
if payload.PrimaryColor != nil {
s.PrimaryColor = *payload.PrimaryColor
}
if payload.SecondaryColor != nil {
s.SecondaryColor = *payload.SecondaryColor
}
if payload.HomeTextColor != nil {
s.HomeTextColor = *payload.HomeTextColor
}
if payload.AwayTextColor != nil {
s.AwayTextColor = *payload.AwayTextColor
}
if payload.HomeScore != nil {
s.HomeScore = *payload.HomeScore
}
if payload.AwayScore != nil {
s.AwayScore = *payload.AwayScore
}
// Clamp fouls 0..5
clamp := func(v int) int { if v < 0 { return 0 }; if v > 5 { return 5 }; return v }
if payload.HomeFouls != nil { s.HomeFouls = clamp(*payload.HomeFouls) }
if payload.AwayFouls != nil { s.AwayFouls = clamp(*payload.AwayFouls) }
if payload.HalfLength != nil { s.HalfLength = *payload.HalfLength }
if payload.Theme != nil { s.Theme = *payload.Theme }
if payload.ExternalMatchID != nil { s.ExternalMatchID = *payload.ExternalMatchID }
if payload.Active != nil { s.Active = *payload.Active }
if payload.SidesFlipped != nil { s.SidesFlipped = *payload.SidesFlipped }
if payload.Half != nil { s.Half = *payload.Half }
if payload.QRShowEveryMinutes != nil && *payload.QRShowEveryMinutes > 0 { s.QRShowEveryMinutes = *payload.QRShowEveryMinutes }
if payload.QRShowDurationSeconds != nil && *payload.QRShowDurationSeconds > 0 { s.QRShowDurationSeconds = *payload.QRShowDurationSeconds }
clamp := func(v int) int {
if v < 0 {
return 0
}
if v > 5 {
return 5
}
return v
}
if payload.HomeFouls != nil {
s.HomeFouls = clamp(*payload.HomeFouls)
}
if payload.AwayFouls != nil {
s.AwayFouls = clamp(*payload.AwayFouls)
}
if payload.HalfLength != nil {
s.HalfLength = *payload.HalfLength
}
if payload.Theme != nil {
s.Theme = *payload.Theme
}
if payload.ExternalMatchID != nil {
s.ExternalMatchID = *payload.ExternalMatchID
}
if payload.Active != nil {
s.Active = *payload.Active
}
if payload.SidesFlipped != nil {
s.SidesFlipped = *payload.SidesFlipped
}
if payload.Half != nil {
s.Half = *payload.Half
}
if payload.QRShowEveryMinutes != nil && *payload.QRShowEveryMinutes > 0 {
s.QRShowEveryMinutes = *payload.QRShowEveryMinutes
}
if payload.QRShowDurationSeconds != nil && *payload.QRShowDurationSeconds > 0 {
s.QRShowDurationSeconds = *payload.QRShowDurationSeconds
}
if payload.Timer != nil && !s.Running {
// Set base timer string when paused
s.Timer = *payload.Timer
@@ -610,18 +849,26 @@ func writeLiveScoreboardCache(s *models.ScoreboardState) {
b, _ := json.MarshalIndent(payload, "", " ")
tmp := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json.tmp")
dst := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json")
if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) }
if err := os.WriteFile(tmp, b, 0o644); err == nil {
_ = os.Rename(tmp, dst)
}
// Patch prefetch events if available
prefetch := filepath.Join("cache", "prefetch", "events_upcoming.json")
f, err := os.Open(prefetch)
if err != nil { return }
if err != nil {
return
}
defer f.Close()
var arr []map[string]any
if err := json.NewDecoder(f).Decode(&arr); err != nil { return }
if err := json.NewDecoder(f).Decode(&arr); err != nil {
return
}
for i := range arr {
id := ""
if v, ok := arr[i]["match_id"].(string); ok { id = v }
if v, ok := arr[i]["match_id"].(string); ok {
id = v
}
if id == s.ExternalMatchID {
arr[i]["score"] = map[string]any{"home": s.HomeScore, "away": s.AwayScore}
arr[i]["home_logo_url"] = s.HomeLogoURL
+18 -1
View File
@@ -136,6 +136,23 @@ func codeFromHash(s string, n int) string {
return string(out)
}
func sanitizeCode(in string) string {
s := strings.TrimSpace(in)
if s == "" { return "" }
// filter allowed runes
rb := make([]rune, 0, len(s))
for _, ch := range s {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
rb = append(rb, ch)
}
}
if len(rb) == 0 { return "" }
if len(rb) > 16 {
rb = rb[:16]
}
return string(rb)
}
func getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
@@ -256,7 +273,7 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
code := strings.TrimSpace(body.Code)
code := sanitizeCode(strings.TrimSpace(body.Code))
if code == "" {
for i := 0; i < 5; i++ {
cnd, _ := randCode(7)
@@ -57,6 +57,11 @@ func ValidateContentType() gin.HandlerFunc {
return
}
if strings.Contains(path, "/rembg/start") {
c.Next()
return
}
// Require JSON for other API endpoints
if !strings.Contains(contentType, "application/json") {
c.JSON(http.StatusUnsupportedMediaType, gin.H{

Some files were not shown because too many files have changed in this diff Show More