mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
dev day #99
This commit is contained in:
@@ -78,6 +78,13 @@ LOG_LEVEL=info # debug, info, warn, error
|
|||||||
LOG_FORMAT=text # text or json
|
LOG_FORMAT=text # text or json
|
||||||
LOG_OUTPUT=stdout # stdout, stderr, or file path
|
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)
|
# OpenRouter (for AI blog generation)
|
||||||
# Get a key at https://openrouter.ai
|
# Get a key at https://openrouter.ai
|
||||||
# Do not commit real keys. Set in deployment environment.
|
# 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_SITE_URL=http://localhost:8080
|
||||||
OPENROUTER_APP_NAME=MyClub
|
OPENROUTER_APP_NAME=MyClub
|
||||||
|
|
||||||
|
# Frontend AI timeout (ms)
|
||||||
|
REACT_APP_AI_TIMEOUT_MS=90000
|
||||||
|
|
||||||
# Umami Analytics
|
# Umami Analytics
|
||||||
UMAMI_URL=https://umami.tdvorak.dev
|
UMAMI_URL=https://umami.tdvorak.dev
|
||||||
UMAMI_USERNAME=admin
|
UMAMI_USERNAME=admin
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ LOG_LEVEL=info # debug, info, warn, error
|
|||||||
LOG_FORMAT=text # text or json
|
LOG_FORMAT=text # text or json
|
||||||
LOG_OUTPUT=stdout # stdout, stderr, or file path
|
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)
|
# OpenRouter (for AI blog generation)
|
||||||
# Get a key at https://openrouter.ai
|
# Get a key at https://openrouter.ai
|
||||||
# Do not commit real keys. Set in deployment environment.
|
# Do not commit real keys. Set in deployment environment.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,32 +1,55 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.24.5-alpine AS builder
|
FROM golang:1.24.5-bullseye AS builder
|
||||||
|
ARG REMBG_ENABLED=true
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
# Install build dependencies
|
||||||
RUN apk add --no-cache git
|
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 ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY . .
|
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
|
# Build the application
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o fotbal-club
|
RUN CGO_ENABLED=0 GOOS=linux go build -o fotbal-club
|
||||||
|
|
||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM debian:bullseye-slim
|
||||||
|
ARG REMBG_ENABLED=true
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies (TLS certs, timezone data) and create non-root user
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache ca-certificates tzdata \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& addgroup -S app && adduser -S app -G app \
|
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 \
|
&& mkdir -p /app/uploads /app/cache \
|
||||||
&& chown -R app:app /app
|
&& 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 the binary from builder
|
||||||
COPY --from=builder /app/fotbal-club ./fotbal-club
|
COPY --from=builder /app/fotbal-club ./fotbal-club
|
||||||
|
|
||||||
|
|||||||
+51
-10
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
RUN apk add --no-cache gcc musl-dev git
|
RUN --mount=type=cache,target=/var/lib/apt/lists \
|
||||||
|
--mount=type=cache,target=/var/cache/apt \
|
||||||
# Copy go mod and sum files
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY go.mod go.sum ./
|
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
|
# Configure Go proxy with fallback and download dependencies with retry
|
||||||
ENV GOPROXY=https://proxy.golang.org,direct
|
ENV GOPROXY=https://proxy.golang.org,direct
|
||||||
ENV GOPRIVATE=
|
ENV GOPRIVATE=
|
||||||
ENV GOSUMDB=sum.golang.org
|
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
|
# Download all dependencies with retry logic and cache mount
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
@@ -22,6 +30,11 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
done && \
|
done && \
|
||||||
go mod verify
|
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 the source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -31,20 +44,48 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||||
go build -ldflags="-w -s" -trimpath -o main .
|
go build -ldflags="-w -s" -trimpath -o main .
|
||||||
|
|
||||||
# Use a smaller image for the final container
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM debian:bullseye-slim
|
||||||
|
ARG REMBG_ENABLED=true
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the binary from builder
|
# Install runtime dependencies
|
||||||
COPY --from=builder /app/main .
|
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/static ./static
|
||||||
COPY --from=builder /app/templates ./templates
|
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 port
|
||||||
EXPOSE 8080
|
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
|
# Command to run the executable
|
||||||
CMD ["./main"]
|
CMD ["./main"]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"items":[],"page":1,"page_size":10,"total":0}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:37Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:41Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
null
|
|
||||||
Vendored
-101
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"lastUpdated":"2025-11-14T14:03:41Z"}
|
|
||||||
Vendored
-52
@@ -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"
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"additional_meta":"","canonical_base_url":"","default_og_image_url":"","enable_indexing":false,"meta_keywords":"","site_description":"","site_title":"","twitter_handle":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -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"}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"by_id":{},"by_name":{}}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"etag":"","fetched_at":"2025-11-14T14:03:34Z","last_modified":""}
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
-1
@@ -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"}
|
|
||||||
Vendored
-102
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
null
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"fetched_at": "2025-11-14T14:03:49Z",
|
|
||||||
"link": ""
|
|
||||||
}
|
|
||||||
Vendored
-1081
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
%%{init: {
|
%%{init: {
|
||||||
'theme': 'base',
|
'theme': 'base',
|
||||||
'securityLevel': 'loose',
|
'securityLevel': 'loose',
|
||||||
'flowchart': { 'curve': 'basis' },
|
'flowchart': { 'curve': 'linear', 'useMaxWidth': true, 'nodeSpacing': 36, 'rankSpacing': 48 },
|
||||||
'themeVariables': {
|
'themeVariables': {
|
||||||
'primaryColor': '#0b5cff',
|
'primaryColor': '#0b5cff',
|
||||||
'primaryTextColor': '#ffffff',
|
'primaryTextColor': '#ffffff',
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
'tertiaryColor': '#f8fafc',
|
'tertiaryColor': '#f8fafc',
|
||||||
'fontSize': '12px'
|
'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
|
flowchart TB
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
flowchart TB
|
||||||
|
|
||||||
classDef group fill:#eef7ff,stroke:#2b6cb0,color:#0b3a60;
|
classDef group fill:#eef7ff,stroke:#2b6cb0,color:#0b3a60;
|
||||||
@@ -16,7 +16,7 @@ client ==>|HTTP| api
|
|||||||
client ==>|HTTP| rootgrp
|
client ==>|HTTP| rootgrp
|
||||||
|
|
||||||
subgraph PUBLIC["Public endpoints"]
|
subgraph PUBLIC["Public endpoints"]
|
||||||
direction TB
|
direction LR
|
||||||
p_health["GET /health"]:::pub
|
p_health["GET /health"]:::pub
|
||||||
p_csrf["GET /csrf-token"]:::pub
|
p_csrf["GET /csrf-token"]:::pub
|
||||||
p_image_proxy["GET /proxy/image"]:::pub
|
p_image_proxy["GET /proxy/image"]:::pub
|
||||||
@@ -54,7 +54,7 @@ subgraph PUBLIC["Public endpoints"]
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph PROTECTED["Protected (JWTAuth + CSRF for state)"]
|
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_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_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
|
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
|
end
|
||||||
|
|
||||||
subgraph ADMIN["Admin groups (JWT + Role: admin)"]
|
subgraph ADMIN["Admin groups (JWT + Role: admin)"]
|
||||||
direction TB
|
direction LR
|
||||||
ad_errors["/admin/errors: list, get, external proxies"]:::admin
|
ad_errors["/admin/errors: list, get, external proxies"]:::admin
|
||||||
ad_comments["/admin/comments: list, status, bans, unban requests"]:::admin
|
ad_comments["/admin/comments: list, status, bans, unban requests"]:::admin
|
||||||
ad_comp_aliases["/admin/competition-aliases: CRUD + reorder"]:::admin
|
ad_comp_aliases["/admin/competition-aliases: CRUD + reorder"]:::admin
|
||||||
@@ -98,7 +98,7 @@ subgraph ADMIN["Admin groups (JWT + Role: admin)"]
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph ROOT["Root endpoints"]
|
subgraph ROOT["Root endpoints"]
|
||||||
direction TB
|
direction LR
|
||||||
r_robots["GET /robots.txt"]:::root
|
r_robots["GET /robots.txt"]:::root
|
||||||
r_sitemap["GET /sitemap.xml"]:::root
|
r_sitemap["GET /sitemap.xml"]:::root
|
||||||
r_short["GET /s/:code"]:::root
|
r_short["GET /s/:code"]:::root
|
||||||
|
|||||||
@@ -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
|
flowchart TD
|
||||||
%% Routes to Pages Mapping (from App.lazy.tsx)
|
%% Routes to Pages Mapping (from App.lazy.tsx)
|
||||||
classDef page fill:#fff7ed,stroke:#f59e0b,color:#7c2d12;
|
classDef page fill:#fff7ed,stroke:#f59e0b,color:#7c2d12;
|
||||||
@@ -7,6 +7,7 @@ flowchart TD
|
|||||||
Router[BrowserRouter]:::route --> Routes:::route
|
Router[BrowserRouter]:::route --> Routes:::route
|
||||||
|
|
||||||
subgraph PublicRoutes[Public Routes]
|
subgraph PublicRoutes[Public Routes]
|
||||||
|
direction LR
|
||||||
R0["/"]:::route --> HomeRoute:::route --> HomePage:::page
|
R0["/"]:::route --> HomeRoute:::route --> HomePage:::page
|
||||||
R1["/blog"]:::route --> BlogRoute:::route --> BlogPage:::page
|
R1["/blog"]:::route --> BlogRoute:::route --> BlogPage:::page
|
||||||
R2["/hledat"]:::route --> SearchPage:::page
|
R2["/hledat"]:::route --> SearchPage:::page
|
||||||
@@ -60,6 +61,7 @@ flowchart TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph AdminRoutes[Admin Routes - guarded by ProtectedRoute]
|
subgraph AdminRoutes[Admin Routes - guarded by ProtectedRoute]
|
||||||
|
direction LR
|
||||||
A0["/admin"]:::route --> AdminDashboardPage:::page
|
A0["/admin"]:::route --> AdminDashboardPage:::page
|
||||||
A1["/admin/docs"]:::route --> AdminDocsPage:::page
|
A1["/admin/docs"]:::route --> AdminDocsPage:::page
|
||||||
A2["/admin/o-klubu"]:::route --> AboutAdminPage:::page
|
A2["/admin/o-klubu"]:::route --> AboutAdminPage:::page
|
||||||
|
|||||||
+31
-19
@@ -20,7 +20,7 @@
|
|||||||
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
.btn.primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||||
.btn.ghost{background:transparent}
|
.btn.ghost{background:transparent}
|
||||||
main{padding:16px}
|
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{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}
|
.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}
|
.title{display:flex;flex-direction:column;gap:4px}
|
||||||
@@ -39,14 +39,15 @@
|
|||||||
</style>
|
</style>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
|
||||||
<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){
|
async function renderMermaidFile(mmdPath, container){
|
||||||
try{
|
try{
|
||||||
container.innerHTML = '<div style="padding:16px;color:#9aa3b2">Loading '+mmdPath+'…</div>';
|
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);
|
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 id = 'm-'+Math.random().toString(36).slice(2);
|
||||||
const { svg } = await mermaid.render(id, code);
|
const { svg } = await mermaid.render(id, code);
|
||||||
container.innerHTML = svg;
|
container.innerHTML = svg;
|
||||||
@@ -82,10 +83,13 @@
|
|||||||
if(!source.match(/^<svg[^>]+xmlns=/)) source = source.replace(/^<svg/, '<svg xmlns="http://www.w3.org/2000/svg"');
|
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;
|
source = '<?xml version="1.0" standalone="no"?>\n'+source;
|
||||||
// Inject white background and readable styles for new tab view
|
// Inject white background and readable styles for new tab view
|
||||||
const firstGt = source.indexOf('>');
|
const svgStart = source.indexOf('<svg');
|
||||||
if(firstGt > 0){
|
if(svgStart !== -1){
|
||||||
const inject = '<rect width="100%" height="100%" fill="#ffffff"/><style>text{fill:#111827}.edgePath path,.flowchart-link{stroke:#334155}</style>';
|
const svgTagEnd = source.indexOf('>', svgStart);
|
||||||
source = source.slice(0, firstGt+1) + inject + source.slice(firstGt+1);
|
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, svgTagEnd+1) + inject + source.slice(svgTagEnd+1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const blob = new Blob([source], { type:'image/svg+xml;charset=utf-8' });
|
const blob = new Blob([source], { type:'image/svg+xml;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -95,8 +99,7 @@
|
|||||||
|
|
||||||
const ALL_DIAGRAMS = [
|
const ALL_DIAGRAMS = [
|
||||||
// System & DB
|
// System & DB
|
||||||
{ id:'system-clean', label:'System Overview (Clean)', file:'system-overall-clean.mmd', cat:'System', tags:['overview','recommended','big'] },
|
{ id:'system-clean', label:'System Overview', 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:'db-er', label:'Database ER', file:'db-er.mmd', cat:'System', tags:['db'] },
|
{ 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'] },
|
{ id:'db-models', label:'Database Models', file:'db-models.mmd', cat:'System', tags:['db'] },
|
||||||
// Backend
|
// Backend
|
||||||
@@ -107,15 +110,13 @@
|
|||||||
{ id:'auth', label:'Auth Flow', file:'auth-flow.mmd', cat:'Backend', tags:['auth','flow'] },
|
{ 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'] },
|
{ id:'err-flow', label:'Error Tracking Flow', file:'error-tracking-flow.mmd', cat:'Backend', tags:['errors','flow'] },
|
||||||
// Frontend
|
// Frontend
|
||||||
{ id:'fe-everything', label:'Frontend — Everything (Big)', file:'frontend-everything.mmd', cat:'Frontend', tags:['overview','big'], defaultWires:'faint' },
|
{ 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'] },
|
{ 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-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-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'] },
|
{ id:'fe-api', label:'Frontend — API Map', file:'frontend-api-map.mmd', cat:'Frontend', tags:['api'] },
|
||||||
// Admin
|
// 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:'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:'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'] },
|
{ 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';
|
const tb = document.createElement('div'); tb.className='toolbar';
|
||||||
tb.innerHTML = `
|
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>
|
<a class="btn ghost src" href="${d.file}" target="_blank">Source</a>
|
||||||
<span class="sp"></span>
|
<span class="sp"></span>
|
||||||
<button class="btn open">Open SVG in new tab</button>
|
<button class="btn open">Open SVG in new tab</button>
|
||||||
@@ -161,9 +163,17 @@
|
|||||||
const svg = container?.querySelector('svg');
|
const svg = container?.querySelector('svg');
|
||||||
if(!svg) return;
|
if(!svg) return;
|
||||||
const fit = card.querySelector('.fit');
|
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');
|
||||||
svg.style.transformOrigin = '';
|
if(fit && fit.checked){
|
||||||
svg.style.transform = '';
|
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){
|
function wireCardControls(card, file){
|
||||||
@@ -173,7 +183,9 @@
|
|||||||
const openBtn = card.querySelector('.open');
|
const openBtn = card.querySelector('.open');
|
||||||
const refresh = card.querySelector('.refresh');
|
const refresh = card.querySelector('.refresh');
|
||||||
const download = card.querySelector('.download');
|
const download = card.querySelector('.download');
|
||||||
|
const zoom = card.querySelector('.zoom');
|
||||||
fit.addEventListener('change', () => applyFitZoomFor(card));
|
fit.addEventListener('change', () => applyFitZoomFor(card));
|
||||||
|
zoom.addEventListener('input', () => applyFitZoomFor(card));
|
||||||
openBtn.addEventListener('click', () => openSVGInNewTab(diag));
|
openBtn.addEventListener('click', () => openSVGInNewTab(diag));
|
||||||
refresh.addEventListener('click', async () => { diag.dataset.rendered=''; await renderMermaidFile(file, diag); diag.dataset.rendered='1'; applyFitZoomFor(card); });
|
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'));
|
download.addEventListener('click', () => downloadSVGOf(diag, (file.replace('.mmd','')||'diagram')+'.svg'));
|
||||||
|
|||||||
@@ -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
|
flowchart LR
|
||||||
|
|
||||||
classDef client fill:#f1f5f9,stroke:#334155,color:#0f172a;
|
classDef client fill:#f1f5f9,stroke:#334155,color:#0f172a
|
||||||
classDef fe fill:#fff7ed,stroke:#f59e0b,color:#7c2d12;
|
classDef fe fill:#fff7ed,stroke:#f59e0b,color:#7c2d12
|
||||||
classDef be fill:#ecfdf5,stroke:#16a34a,color:#065f46;
|
classDef be fill:#ecfdf5,stroke:#16a34a,color:#065f46
|
||||||
classDef db fill:#e3f2fd,stroke:#1e88e5,color:#0c4a6e;
|
classDef db fill:#e3f2fd,stroke:#1e88e5,color:#0c4a6e
|
||||||
classDef ext fill:#f5f3ff,stroke:#8b5cf6,color:#4c1d95;
|
classDef ext fill:#f5f3ff,stroke:#8b5cf6,color:#4c1d95
|
||||||
classDef stat fill:#e2e8f0,stroke:#475569,color:#111827;
|
classDef stat fill:#e2e8f0,stroke:#475569,color:#111827
|
||||||
|
|
||||||
U((User Browser)):::client
|
U(("User Browser"))
|
||||||
FE[Frontend (React app)]:::fe
|
FE["Frontend (React app)"]
|
||||||
API[Backend API (Go + Gin)\n/api/v1]:::be
|
API["Backend API (Go + Gin)<br/>/api/v1"]
|
||||||
DB[(PostgreSQL DB)]:::db
|
DB[(PostgreSQL DB)]
|
||||||
STATIC[Static & Uploads\n/assets, /uploads]:::stat
|
STATIC["Static & Uploads<br/>/assets, /uploads"]
|
||||||
EXT[External Services\n(SMTP, Error Receiver, Umami, FACR, Zonerama, YouTube)]:::ext
|
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
|
U --> FE
|
||||||
FE ==>|HTTP| API
|
FE ==>|HTTP| API
|
||||||
|
|||||||
+40
-40
@@ -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
|
flowchart TB
|
||||||
|
|
||||||
%% ========================= Docker & Runtime =========================
|
%% ========================= Docker & Runtime =========================
|
||||||
@@ -37,13 +37,13 @@ subgraph DOCKER["Docker Compose (Local Dev/Prod)"]
|
|||||||
end
|
end
|
||||||
|
|
||||||
user_browser((User Browser)):::ext
|
user_browser((User Browser)):::ext
|
||||||
user_browser ==>|HTTP 80| docker_frontend:::animated-edge
|
user_browser ==>|HTTP 80| fe_3000
|
||||||
user_browser -.->|dev direct (HTTP 8080)| docker_backend
|
user_browser -.->|dev direct :8080| be_8080
|
||||||
|
|
||||||
%% ========================= Backend (Go/Gin) =========================
|
%% ========================= Backend (Go/Gin) =========================
|
||||||
subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
|
subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
|
||||||
direction TB
|
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)]
|
logger[Logger (pkg/logger)]
|
||||||
db_init[[InitDB() + AutoMigrate()]]:::db
|
db_init[[InitDB() + AutoMigrate()]]:::db
|
||||||
email_svc[EmailService (pkg/email)]:::svc
|
email_svc[EmailService (pkg/email)]:::svc
|
||||||
@@ -77,42 +77,42 @@ subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
|
|||||||
|
|
||||||
subgraph controllers[Controllers]
|
subgraph controllers[Controllers]
|
||||||
direction TB
|
direction TB
|
||||||
c_auth[AuthController\n/login,/logout,/register,/me\n/password-reset]
|
c_auth["AuthController<br/>/login,/logout,/register,/me<br/>/password-reset"]
|
||||||
c_contact[ContactController\n/contact + newsletter + admin forwarding]
|
c_contact["ContactController<br/>/contact + newsletter + admin forwarding"]
|
||||||
c_pass[PasswordController]
|
c_pass[PasswordController]
|
||||||
c_ai[AIController\n/ai/blog,/ai/about,/ai/css,/ai/instagram]
|
c_ai["AIController<br/>/ai/blog,/ai/about,/ai/css,/ai/instagram"]
|
||||||
c_score[ScoreboardController\n/public + admin timer/sponsors/qr]
|
c_score["ScoreboardController<br/>/public + admin timer/sponsors/qr"]
|
||||||
c_about[AboutController]
|
c_about[AboutController]
|
||||||
c_gallery[GalleryController\n/Zonerama profile/albums/picks]
|
c_gallery["GalleryController<br/>/Zonerama profile/albums/picks"]
|
||||||
c_files[FilesController\n/list/unused/duplicates/usage\n/scan/refresh-tracking/delete]
|
c_files["FilesController<br/>/list/unused/duplicates/usage<br/>/scan/refresh-tracking/delete"]
|
||||||
c_notify[NotificationsController]
|
c_notify[NotificationsController]
|
||||||
c_email[EmailController\n/open.gif/click/unsubscribe/stats]
|
c_email["EmailController<br/>/open.gif/click/unsubscribe/stats"]
|
||||||
c_prefetch[PrefetchController\n/status/trigger]
|
c_prefetch["PrefetchController<br/>/status/trigger"]
|
||||||
c_seo[SEOController\n/seo (public) + robots.txt + sitemap]
|
c_seo["SEOController<br/>/seo (public) + robots.txt + sitemap"]
|
||||||
c_nav[NavigationController\n/navigation + social-links + admin CRUD]
|
c_nav["NavigationController<br/>/navigation + social-links + admin CRUD"]
|
||||||
c_poll[PollController\n/public vote/results + admin]
|
c_poll["PollController<br/>/public vote/results + admin"]
|
||||||
c_sw[SweepstakesController\n/public current/visual + admin CRUD/finalize]
|
c_sw["SweepstakesController<br/>/public current/visual + admin CRUD/finalize"]
|
||||||
c_cloth[ClothingController\n/public + admin CRUD]
|
c_cloth["ClothingController<br/>/public + admin CRUD"]
|
||||||
c_pec[PageElementConfigController\n/public + admin CRUD/batch]
|
c_pec["PageElementConfigController<br/>/public + admin CRUD/batch"]
|
||||||
c_article[ArticleController\n/create + match-link]
|
c_article["ArticleController<br/>/create + match-link"]
|
||||||
c_base[BaseController\n/health, uploads, categories, teams, players, matches, standings, zonerama, settings, shortlinks(public)]
|
c_base["BaseController<br/>/health, uploads, categories, teams, players, matches, standings, zonerama, settings, shortlinks(public)"]
|
||||||
c_myu[MyUIbrixController\n/validate,/preview,/optimize]
|
c_myu["MyUIbrixController<br/>/validate,/preview,/optimize"]
|
||||||
c_editor[EditorPreviewController\n/preview state + variants]
|
c_editor["EditorPreviewController<br/>/preview state + variants"]
|
||||||
c_short[ShortLinkController\n/public create + admin + redirect /s/:code]
|
c_short["ShortLinkController<br/>/public create + admin + redirect /s/:code"]
|
||||||
c_comment[CommentController\n/public list + CRUD + reactions\nban/unban/report (admin)]
|
c_comment["CommentController<br/>/public list + CRUD + reactions<br/>ban/unban/report (admin)"]
|
||||||
c_eng[EngagementController\n/rewards/leaderboard/profile/actions]
|
c_eng["EngagementController<br/>/rewards/leaderboard/profile/actions"]
|
||||||
c_facr[FACRController\n/facr club search/info/table]
|
c_facr["FACRController<br/>/facr club search/info/table"]
|
||||||
c_yt[YouTubeController\n/youtube/videos]
|
c_yt["YouTubeController<br/>/youtube/videos"]
|
||||||
c_umami[UmamiController\n/config + admin initialize/stats]
|
c_umami["UmamiController<br/>/config + admin initialize/stats"]
|
||||||
c_error[ErrorController\n/errors ingest + admin + external]
|
c_error["ErrorController<br/>/errors ingest + admin + external"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph services[Services & Jobs]
|
subgraph services[Services & Jobs]
|
||||||
direction TB
|
direction TB
|
||||||
s_errrep[ErrorReporter]
|
s_errrep[ErrorReporter]
|
||||||
s_prefetch[Prefetcher\nStartPrefetcher(target)]
|
s_prefetch["Prefetcher<br/>StartPrefetcher(target)"]
|
||||||
s_nlsched[NewsletterScheduler]
|
s_nlsched[NewsletterScheduler]
|
||||||
s_nlauto[NewsletterAutomation\nweekly, reminders, results]
|
s_nlauto["NewsletterAutomation<br/>weekly, reminders, results"]
|
||||||
s_sweep[SweepstakesScheduler]
|
s_sweep[SweepstakesScheduler]
|
||||||
s_umami[UmamiService]
|
s_umami[UmamiService]
|
||||||
s_facr[FACRService]
|
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
|
errors_admin["Error Review Admin UI/API: errors.tdvorak.dev"]:::ext
|
||||||
umami_ext["Umami Analytics server"]:::ext
|
umami_ext["Umami Analytics server"]:::ext
|
||||||
|
|
||||||
s_facr <---> facr_ext:::animated-edge
|
s_facr <---> facr_ext
|
||||||
s_errrep --> errors_ingest:::animated-edge
|
s_errrep --> errors_ingest
|
||||||
c_error <---> errors_admin
|
c_error <---> errors_admin
|
||||||
s_umami <---> umami_ext
|
s_umami <---> umami_ext
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ subgraph BACKEND["Backend Service (Golang + Gin) :8080"]
|
|||||||
prometheus --- user_browser
|
prometheus --- user_browser
|
||||||
end
|
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
|
user_browser ==>|HTTP /robots.txt, /sitemap.xml, /s/:code| root_grp
|
||||||
|
|
||||||
%% ========================= Frontend (React) =========================
|
%% ========================= Frontend (React) =========================
|
||||||
@@ -241,7 +241,7 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
|
|||||||
p_home[HomePage /]
|
p_home[HomePage /]
|
||||||
p_blog[BlogPage /blog]
|
p_blog[BlogPage /blog]
|
||||||
p_newslist[ArticlesListPage]
|
p_newslist[ArticlesListPage]
|
||||||
p_article[ArticleDetailPage /news/:slug | /articles/:id]
|
p_article["ArticleDetailPage /news/:slug | /articles/:id"]
|
||||||
p_about[AboutPage /o-klubu]
|
p_about[AboutPage /o-klubu]
|
||||||
p_club[ClubPage /klub]
|
p_club[ClubPage /klub]
|
||||||
p_calendar[CalendarPage /kalendar]
|
p_calendar[CalendarPage /kalendar]
|
||||||
@@ -315,9 +315,9 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
|
|||||||
end
|
end
|
||||||
|
|
||||||
%% FE -> BE API mappings (high level)
|
%% 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_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_home -->|GET /articles/featured, /matches, /standings, /settings, /navigation| api_grp
|
||||||
p_matches -->|GET /matches,/standings| api_grp
|
p_matches -->|GET /matches,/standings| api_grp
|
||||||
p_match -->|GET /matches/:id| 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
|
p_short -->|GET /s/:code (root)| root_grp
|
||||||
|
|
||||||
%% Admin flows
|
%% 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_matches -->|GET /admin/matches| api_grp
|
||||||
a_comments -->|GET/PATCH /admin/comments| api_grp
|
a_comments -->|GET/PATCH /admin/comments| api_grp
|
||||||
a_navigation -->|CRUD /admin/navigation| api_grp
|
a_navigation -->|CRUD /admin/navigation| api_grp
|
||||||
@@ -347,7 +347,7 @@ subgraph FRONTEND[Frontend (React + ChakraUI)]
|
|||||||
a_analytics -->|/admin/umami| api_grp
|
a_analytics -->|/admin/umami| api_grp
|
||||||
|
|
||||||
%% FE error reporting & analytics
|
%% 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
|
fe_router -->|GET /umami/config| api_grp
|
||||||
|
|
||||||
end
|
end
|
||||||
@@ -358,7 +358,7 @@ subgraph PORTS[Ports & CORS]
|
|||||||
port_be[Backend :8080]
|
port_be[Backend :8080]
|
||||||
port_fe[Frontend :3000 -> :80]
|
port_fe[Frontend :3000 -> :80]
|
||||||
port_db[Postgres :5432]
|
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
|
end
|
||||||
port_be --- docker_backend
|
port_be --- docker_backend
|
||||||
port_fe --- docker_frontend
|
port_fe --- docker_frontend
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ services:
|
|||||||
target: builder # Build only the builder stage
|
target: builder # Build only the builder stage
|
||||||
cache_from:
|
cache_from:
|
||||||
- type=local,src=/tmp/.buildx-cache
|
- type=local,src=/tmp/.buildx-cache
|
||||||
|
cache_to:
|
||||||
|
- type=local,dest=/tmp/.buildx-cache,mode=max
|
||||||
args:
|
args:
|
||||||
BUILDKIT_INLINE_CACHE: 1
|
BUILDKIT_INLINE_CACHE: 1
|
||||||
|
REMBG_ENABLED: ${REMBG_ENABLED:-true}
|
||||||
container_name: myclub-backend
|
container_name: myclub-backend
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -53,6 +56,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
cache_from:
|
cache_from:
|
||||||
- type=local,src=/tmp/.buildx-cache-frontend
|
- type=local,src=/tmp/.buildx-cache-frontend
|
||||||
|
cache_to:
|
||||||
|
- type=local,dest=/tmp/.buildx-cache-frontend,mode=max
|
||||||
args:
|
args:
|
||||||
BUILDKIT_INLINE_CACHE: 1
|
BUILDKIT_INLINE_CACHE: 1
|
||||||
shm_size: '512m' # Increase shared memory for build
|
shm_size: '512m' # Increase shared memory for build
|
||||||
|
|||||||
+200
@@ -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. We’ll work through these incrementally, closing items as they’re 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 (won’t 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.
|
||||||
Generated
+25
@@ -18,6 +18,7 @@
|
|||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"tinymce": "^8.2.2",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"yup": "^1.3.3"
|
"yup": "^1.3.3"
|
||||||
@@ -4193,6 +4195,24 @@
|
|||||||
"@testing-library/dom": ">=7.21.4"
|
"@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": {
|
"node_modules/@tootallnate/once": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
"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": {
|
"node_modules/tinyqueue": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@tinymce/tinymce-react": "^6.3.0",
|
||||||
"@types/jest": "^27.5.2",
|
"@types/jest": "^27.5.2",
|
||||||
"@types/node": "^16.18.126",
|
"@types/node": "^16.18.126",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-simple-maps": "^3.0.0",
|
"react-simple-maps": "^3.0.0",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"tinymce": "^8.2.2",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
"yup": "^1.3.3"
|
"yup": "^1.3.3"
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ const AdminSidebar = ({
|
|||||||
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
|
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
|
||||||
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
|
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const seedFixRef = useRef<{ about: boolean }>({ about: false });
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const STORAGE_KEY = 'admin-sidebar-scroll';
|
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 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 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 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)
|
// Collapsed state for admin categories (dropdown items)
|
||||||
@@ -294,7 +312,31 @@ const AdminSidebar = ({
|
|||||||
setNavItems(adminItems);
|
setNavItems(adminItems);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setNavItems(adminItems);
|
// 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) {
|
} catch (error) {
|
||||||
@@ -538,6 +580,161 @@ const AdminSidebar = ({
|
|||||||
Oblečení
|
Oblečení
|
||||||
</NavItem>
|
</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
|
// Fallback to hardcoded navigation
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [album, setAlbum] = useState<Album | null>(null);
|
const [album, setAlbum] = useState<Album | null>(null);
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
|
||||||
|
const [visibleCount, setVisibleCount] = useState<number>(60);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const handleFetchAlbum = async () => {
|
const handleFetchAlbum = async () => {
|
||||||
@@ -117,6 +118,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
photos: mappedPhotos,
|
photos: mappedPhotos,
|
||||||
});
|
});
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
|
setVisibleCount(60);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Album načteno',
|
title: 'Album načteno',
|
||||||
@@ -176,6 +178,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
setAlbumLink('');
|
setAlbumLink('');
|
||||||
setAlbum(null);
|
setAlbum(null);
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
|
setVisibleCount(60);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,7 +272,7 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
|
|
||||||
{/* Photos Grid */}
|
{/* Photos Grid */}
|
||||||
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
|
<SimpleGrid columns={{ base: 3, md: 4, lg: 5 }} spacing={3}>
|
||||||
{album.photos.map((photo) => (
|
{album.photos.slice(0, visibleCount).map((photo) => (
|
||||||
<Box
|
<Box
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
position="relative"
|
position="relative"
|
||||||
@@ -288,6 +291,8 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
w="100%"
|
w="100%"
|
||||||
h="150px"
|
h="150px"
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -301,6 +306,11 @@ const AlbumPhotoPicker: React.FC<AlbumPhotoPickerProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
{album.photos.length > visibleCount && (
|
||||||
|
<HStack justify="center" pt={2}>
|
||||||
|
<Button size="sm" onClick={() => setVisibleCount((c) => c + 60)}>Načíst další</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
|
import { createShortLink, createPublicShortLink } from '../../services/shortlinks';
|
||||||
import { Article, getArticleMatchLink } from '../../services/articles';
|
import { Article, getArticleMatchLink } from '../../services/articles';
|
||||||
import { API_URL } from '../../services/api';
|
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 { generateInstagramAI } from '../../services/ai';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
|
|
||||||
@@ -159,12 +159,13 @@ const InstagramGeneratorButton: React.FC<Props> = ({
|
|||||||
content: stripHtml(article.content),
|
content: stripHtml(article.content),
|
||||||
club_name: clubName,
|
club_name: clubName,
|
||||||
link: sUrl || fullUrl,
|
link: sUrl || fullUrl,
|
||||||
|
category: (article as any)?.category?.name || (article as any)?.category_name,
|
||||||
match: resolvedMatch ? {
|
match: resolvedMatch ? {
|
||||||
home: resolvedMatch.home,
|
home: resolvedMatch.home,
|
||||||
away: resolvedMatch.away,
|
away: resolvedMatch.away,
|
||||||
competition: resolvedMatch.competition,
|
competition: resolvedMatch.competition,
|
||||||
date_time: resolvedMatch.date_time,
|
date_time: resolvedMatch.date_time ? formatDateTime(resolvedMatch.date_time) : undefined,
|
||||||
venue: resolvedMatch.venue,
|
venue: resolvedMatch.venue ? cleanVenue(resolvedMatch.venue) : undefined,
|
||||||
score: resolvedMatch.score,
|
score: resolvedMatch.score,
|
||||||
} : undefined,
|
} : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
|
import { listComments, createComment, updateComment, deleteComment, CommentItem, reactComment, unreactComment, requestUnban, reportComment } from '../../services/comments';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@@ -87,7 +87,37 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
|||||||
|
|
||||||
const reactMut = useMutation({
|
const reactMut = useMutation({
|
||||||
mutationFn: (args: { id: number; type: string }) => reactComment(args.id, args.type),
|
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] });
|
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
|
||||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||||
},
|
},
|
||||||
@@ -95,7 +125,36 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
|||||||
|
|
||||||
const unreactMut = useMutation({
|
const unreactMut = useMutation({
|
||||||
mutationFn: (id: number) => unreactComment(id),
|
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] });
|
await queryClient.invalidateQueries({ queryKey: ['comments', targetType, targetId] });
|
||||||
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
try { window.dispatchEvent(new CustomEvent('engagement:refresh')); } catch {}
|
||||||
},
|
},
|
||||||
@@ -136,24 +195,41 @@ const CommentsSection: React.FC<Props> = ({ targetType, targetId }) => {
|
|||||||
}, [allItems]);
|
}, [allItems]);
|
||||||
|
|
||||||
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
|
const ReactionBar: React.FC<{ c: CommentItem }> = ({ c }) => {
|
||||||
const options: { key: string; label: string }[] = [
|
const options: { key: string; label: string; color: string; name: string }[] = [
|
||||||
{ key: 'thumbs_up', label: '👍' },
|
{ key: 'thumbs_up', label: '👍', color: 'green', name: 'Palec nahoru' },
|
||||||
{ key: 'heart', label: '❤️' },
|
{ key: 'heart', label: '❤️', color: 'pink', name: 'Srdíčko' },
|
||||||
{ key: 'smile', label: '😀' },
|
{ key: 'smile', label: '😀', color: 'yellow', name: 'Úsměv' },
|
||||||
{ key: 'surprised', label: '😮' },
|
{ key: 'surprised', label: '😮', color: 'purple', name: 'Překvapení' },
|
||||||
{ key: 'thumbs_down', label: '👎' },
|
{ key: 'thumbs_down', label: '👎', color: 'red', name: 'Palec dolů' },
|
||||||
];
|
];
|
||||||
const counts = c.reactions || {};
|
const counts = c.reactions || {};
|
||||||
const active = c.my_reaction;
|
const active = c.my_reaction;
|
||||||
|
const isBusy = reactMut.isPending || unreactMut.isPending;
|
||||||
return (
|
return (
|
||||||
<HStack spacing={2} mt={1}>
|
<HStack spacing={2} mt={1}>
|
||||||
{options.map((o) => (
|
{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>
|
||||||
if (!isAuthenticated) return;
|
<Button
|
||||||
if (active === o.key) unreactMut.mutate(c.id); else reactMut.mutate({ id: c.id, type: o.key });
|
size="xs"
|
||||||
}}>
|
colorScheme={o.color}
|
||||||
<HStack spacing={1}><Text as="span">{o.label}</Text><Text as="span" fontSize="xs">{counts[o.key] || 0}</Text></HStack>
|
variant={active === o.key ? 'solid' : 'outline'}
|
||||||
</Button>
|
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>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import ReactQuill from 'react-quill';
|
import ReactQuill from 'react-quill';
|
||||||
import ReactCrop, { Crop } from 'react-image-crop';
|
import ReactCrop, { Crop } from 'react-image-crop';
|
||||||
import DOMPurify from 'dompurify';
|
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 'react-image-crop/dist/ReactCrop.css';
|
||||||
import '../../styles/custom-editor.css';
|
import '../../styles/custom-editor.css';
|
||||||
import {
|
import {
|
||||||
@@ -74,7 +74,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const quillRef = useRef<ReactQuill | null>(null);
|
const quillRef = useRef<ReactQuill | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
const selectedImageIdRef = useRef<string | null>(null);
|
const selectedImageIdRef = useRef<string | null>(null);
|
||||||
@@ -99,7 +98,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||||
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
const [crop, setCrop] = useState<Crop>({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
||||||
const [cropQuality, setCropQuality] = useState<number>(85);
|
const [cropQuality, setCropQuality] = useState<number>(85);
|
||||||
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1920);
|
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1600);
|
||||||
const [cropProcessing, setCropProcessing] = useState(false);
|
const [cropProcessing, setCropProcessing] = useState(false);
|
||||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
@@ -137,24 +136,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const [imageWidth, setImageWidth] = useState<number>(0);
|
const [imageWidth, setImageWidth] = useState<number>(0);
|
||||||
const [manualWidth, setManualWidth] = useState<string>('');
|
const [manualWidth, setManualWidth] = useState<string>('');
|
||||||
const [widthPercent, setWidthPercent] = useState<number>(0);
|
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
|
// Define toolbar configurations
|
||||||
const toolbarConfigs = {
|
const toolbarConfigs = {
|
||||||
@@ -162,7 +143,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline', 'strike'],
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
[{ color: [] }, { background: [] }],
|
[{ color: [] }, { background: [] }],
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
[{ align: [] }],
|
[{ align: [] }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['blockquote'],
|
['blockquote'],
|
||||||
@@ -171,8 +152,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
basic: [
|
basic: [
|
||||||
[{ header: [1, 2, 3, false] }],
|
[{ header: [1, 2, 3, false] }],
|
||||||
['bold', 'italic', 'underline'],
|
['bold', 'italic', 'underline'],
|
||||||
[{ color: [] }, { background: [] }],
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }, 'liststyle'],
|
|
||||||
[{ align: [] }],
|
[{ align: [] }],
|
||||||
['link', 'image'],
|
['link', 'image'],
|
||||||
['clean'],
|
['clean'],
|
||||||
@@ -254,92 +234,18 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
setIsLinkOpen(true);
|
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(() => ({
|
const quillModules = useMemo(() => ({
|
||||||
toolbar: {
|
toolbar: {
|
||||||
container: toolbarConfig,
|
container: toolbarConfig,
|
||||||
handlers: {
|
handlers: {
|
||||||
image: onImageUpload ? handleImageUpload : undefined,
|
image: onImageUpload ? handleImageUpload : undefined,
|
||||||
link: handleLinkToolbar,
|
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: {
|
clipboard: {
|
||||||
matchVisual: false,
|
matchVisual: false,
|
||||||
},
|
},
|
||||||
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar, toggleListStyle]);
|
}), [toolbarConfig, onImageUpload, handleImageUpload, handleLinkToolbar]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const quillFormats = useMemo(
|
const quillFormats = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -363,100 +269,50 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Localize Quill toolbar tooltips/labels to Czech
|
// Localize Quill toolbar tooltips/labels to Czech
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
let active = true;
|
const editor = quillRef.current?.getEditor();
|
||||||
withEditor((editor) => {
|
if (!editor) return;
|
||||||
if (!active) return;
|
const container = editor.root?.parentElement; // .ql-container
|
||||||
const container = editor.root?.parentElement; // .ql-container
|
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
|
||||||
const toolbarEl = container?.previousElementSibling as HTMLElement | null; // .ql-toolbar
|
if (!toolbarEl) return;
|
||||||
if (!toolbarEl) return;
|
|
||||||
|
|
||||||
const setTitle = (selector: string, title: string) => {
|
const setTitle = (selector: string, title: string) => {
|
||||||
toolbarEl.querySelectorAll(selector).forEach((el) => {
|
toolbarEl.querySelectorAll(selector).forEach((el) => {
|
||||||
(el as HTMLElement).setAttribute('title', title);
|
(el as HTMLElement).setAttribute('title', title);
|
||||||
(el as HTMLElement).setAttribute('aria-label', title);
|
(el as HTMLElement).setAttribute('aria-label', title);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Basic formatting
|
// Basic formatting
|
||||||
setTitle('button.ql-bold', 'Tučné');
|
setTitle('button.ql-bold', 'Tučné');
|
||||||
setTitle('button.ql-italic', 'Kurzíva');
|
setTitle('button.ql-italic', 'Kurzíva');
|
||||||
setTitle('button.ql-underline', 'Podtržení');
|
setTitle('button.ql-underline', 'Podtržení');
|
||||||
setTitle('button.ql-strike', 'Přeškrtnutí');
|
setTitle('button.ql-strike', 'Přeškrtnutí');
|
||||||
setTitle('button.ql-link', 'Vložit odkaz');
|
setTitle('button.ql-link', 'Vložit odkaz');
|
||||||
setTitle('button.ql-image', 'Vložit obrázek');
|
setTitle('button.ql-image', 'Vložit obrázek');
|
||||||
setTitle('button.ql-blockquote', 'Citace');
|
setTitle('button.ql-blockquote', 'Citace');
|
||||||
setTitle('button.ql-clean', 'Vyčistit formátování');
|
setTitle('button.ql-clean', 'Vyčistit formátování');
|
||||||
|
|
||||||
// Lists
|
// Lists
|
||||||
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
|
setTitle('button.ql-list[value="ordered"]', 'Číslovaný seznam');
|
||||||
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
|
setTitle('button.ql-list[value="bullet"]', 'Odrážkový seznam');
|
||||||
|
|
||||||
// Alignment
|
// Alignment
|
||||||
setTitle('button.ql-align', 'Zarovnání');
|
setTitle('button.ql-align', 'Zarovnání');
|
||||||
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
|
setTitle('button.ql-align[value=""]', 'Zarovnat vlevo');
|
||||||
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
|
setTitle('button.ql-align[value="center"]', 'Zarovnat na střed');
|
||||||
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
|
setTitle('button.ql-align[value="right"]', 'Zarovnat vpravo');
|
||||||
setTitle('button.ql-align[value="justify"]', 'Do bloku');
|
setTitle('button.ql-align[value="justify"]', 'Do bloku');
|
||||||
|
|
||||||
// Colors and background
|
// Colors and background
|
||||||
setTitle('.ql-color .ql-picker-label', 'Barva textu');
|
setTitle('.ql-color .ql-picker-label', 'Barva textu');
|
||||||
setTitle('.ql-background .ql-picker-label', 'Barva pozadí');
|
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
|
// Headers
|
||||||
setTitle('.ql-header .ql-picker-label', 'Nadpis');
|
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="1"]', 'Nadpis 1');
|
||||||
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
|
setTitle('.ql-header .ql-picker-item[data-value="2"]', 'Nadpis 2');
|
||||||
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
|
setTitle('.ql-header .ql-picker-item[data-value="3"]', 'Nadpis 3');
|
||||||
setTitle('button.ql-liststyle', 'Styl odrážek');
|
}, [isMounted, toolbar]);
|
||||||
});
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [isMounted, toolbar, withEditor]);
|
|
||||||
|
|
||||||
// (Removed) Previously injected custom bullet-style group; now using a single toolbar button 'liststyle'.
|
|
||||||
|
|
||||||
// Get cropped blob
|
// Get cropped blob
|
||||||
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
const getCroppedBlob = (image: HTMLImageElement, cropPixels: { x: number; y: number; width: number; height: number }): Promise<Blob> => {
|
||||||
@@ -592,13 +448,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
try { targetImg.setAttribute('width', String(px)); } catch {}
|
try { targetImg.setAttribute('width', String(px)); } catch {}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
try {
|
// Move cursor after the image
|
||||||
if (document.contains(quill.root)) {
|
quill.setSelection(index + 1, 0, 'api');
|
||||||
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
|
// Persist content so default width is saved
|
||||||
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||||
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
|
||||||
@@ -627,7 +478,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
setCropFile(null);
|
setCropFile(null);
|
||||||
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
setCrop({ unit: '%', width: 80, height: 80, x: 10, y: 10 });
|
||||||
setCropQuality(85);
|
setCropQuality(85);
|
||||||
setCropMaxWidth(1920);
|
setCropMaxWidth(1600);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -635,6 +486,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = quillRef.current?.getEditor();
|
const editor = quillRef.current?.getEditor();
|
||||||
if (!editor || readOnly) return;
|
if (!editor || readOnly) return;
|
||||||
|
const enableDragReposition = true;
|
||||||
|
|
||||||
let selectedImage: HTMLImageElement | null = null;
|
let selectedImage: HTMLImageElement | null = null;
|
||||||
let resizeHandle: HTMLDivElement | 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)
|
// 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;
|
if (!editorContainer) return null;
|
||||||
const sizeLabel = document.createElement('div');
|
const sizeLabel = document.createElement('div');
|
||||||
sizeLabel.style.cssText = `
|
sizeLabel.style.cssText = `
|
||||||
@@ -678,18 +530,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
try {
|
try {
|
||||||
const edW = editor.root.clientWidth || w || 1;
|
const edW = editor.root.clientWidth || w || 1;
|
||||||
const pct = Math.max(1, Math.min(100, Math.round((w / edW) * 100)));
|
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 {
|
} 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 = [
|
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-right', cursor: 'nwse-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
{ position: 'bottom-left', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
{ position: 'top-right', cursor: 'nesw-resize', width: '20px', height: '20px', isCorner: true },
|
||||||
@@ -791,27 +641,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
startWidth = img.offsetWidth;
|
startWidth = img.offsetWidth;
|
||||||
const startHeight = img.offsetHeight;
|
const startHeight = img.offsetHeight;
|
||||||
const aspectRatio = startWidth / startHeight;
|
const aspectRatio = startWidth / startHeight;
|
||||||
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) => {
|
const onPointerMove = (ev: PointerEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
const deltaX = ev.clientX - startX;
|
const deltaX = ev.clientX - startX;
|
||||||
@@ -825,23 +654,23 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
newWidth = startWidth + (deltaY * aspectRatio);
|
newWidth = startWidth + (deltaY * aspectRatio);
|
||||||
}
|
}
|
||||||
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
|
newWidth = Math.max(50, Math.min(newWidth, editor.root.clientWidth - 40));
|
||||||
lastWidth = newWidth;
|
img.style.width = `${newWidth}px`;
|
||||||
pendingWidth = newWidth;
|
img.style.maxWidth = '100%';
|
||||||
schedule();
|
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 = () => {
|
const onPointerUp = () => {
|
||||||
isResizing = false;
|
isResizing = false;
|
||||||
document.removeEventListener('pointermove', onPointerMove);
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
document.removeEventListener('pointerup', onPointerUp);
|
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));
|
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
||||||
const id = selectedImageIdRef.current;
|
const id = selectedImageIdRef.current;
|
||||||
setTimeout(() => { if (id) { try { selectImageByIdRef.current?.(id); } catch {} } }, 30);
|
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)';
|
img.style.boxShadow = '0 4px 12px rgba(49, 130, 206, 0.3)';
|
||||||
|
|
||||||
// Prevent default drag behavior to avoid duplication
|
// Prevent default drag behavior to avoid duplication
|
||||||
img.setAttribute('draggable', 'false');
|
img.setAttribute('draggable', enableDragReposition ? 'true' : 'false');
|
||||||
|
|
||||||
createResizeHandle(img);
|
createResizeHandle(img);
|
||||||
|
|
||||||
@@ -976,20 +805,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
|
|
||||||
const handleImageClick = (e: Event) => {
|
const handleImageClick = (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
// In read-only mode, show preview instead of selecting
|
// In read-only mode, show preview instead of selecting
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
const imgSrc = (target as HTMLImageElement).src;
|
const imgSrc = imgEl.src;
|
||||||
setPreviewImage(imgSrc);
|
setPreviewImage(imgSrc);
|
||||||
setIsPreviewOpen(true);
|
setIsPreviewOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectImage(target as HTMLImageElement);
|
selectImage(imgEl);
|
||||||
return; // Important: return early to prevent further processing
|
return; // Important: return early to prevent further processing
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,13 +844,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const handleMouseDown = (e: MouseEvent) => {
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'IMG' && selectedImage === target) {
|
if (target.tagName === 'IMG' && selectedImage === target) {
|
||||||
|
if (enableDragReposition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Allow edge-drag fallback resize if overlay handle doesn't catch it
|
// Allow edge-drag fallback resize if overlay handle doesn't catch it
|
||||||
const rect = target.getBoundingClientRect();
|
const rect = target.getBoundingClientRect();
|
||||||
const nearLeft = e.clientX < rect.left + 16;
|
const nearLeft = e.clientX < rect.left + 16;
|
||||||
const nearRight = e.clientX > rect.right - 16;
|
const nearRight = e.clientX > rect.right - 16;
|
||||||
const nearTop = e.clientY < rect.top + 16;
|
const nearTop = e.clientY < rect.top + 16;
|
||||||
const nearBottom = e.clientY > rect.bottom - 16;
|
const nearBottom = e.clientY > rect.bottom - 16;
|
||||||
if (nearLeft || nearRight || nearTop || nearBottom) {
|
if (false) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
isResizing = true;
|
isResizing = true;
|
||||||
@@ -1029,22 +863,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const startHeight = (target as HTMLImageElement).offsetHeight;
|
const startHeight = (target as HTMLImageElement).offsetHeight;
|
||||||
const aspectRatio = startWidth / Math.max(1, startHeight);
|
const aspectRatio = startWidth / Math.max(1, startHeight);
|
||||||
const edge = nearRight ? 'right' : nearLeft ? 'left' : nearBottom ? 'bottom' : 'top';
|
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) => {
|
const onMouseMove: (ev: MouseEvent) => void = (ev: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
const deltaX = ev.clientX - startX;
|
const deltaX = ev.clientX - startX;
|
||||||
@@ -1054,27 +873,29 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
else if (edge === 'left') newWidth = startWidth - deltaX;
|
else if (edge === 'left') newWidth = startWidth - deltaX;
|
||||||
else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio);
|
else if (edge === 'bottom') newWidth = startWidth + (deltaY * aspectRatio);
|
||||||
else if (edge === 'top') 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));
|
newWidth = Math.max(50, Math.min(newWidth, maxW));
|
||||||
lastWidth = newWidth;
|
const imgEl = target as HTMLImageElement;
|
||||||
queued = newWidth;
|
imgEl.style.width = `${newWidth}px`;
|
||||||
schedule();
|
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 = () => {
|
const onMouseUp = () => {
|
||||||
isResizing = false;
|
isResizing = false;
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
if (raf) cancelAnimationFrame(raf);
|
if (editor) { onChangeRef.current(cleanEditorHTML(editor.root.innerHTML)); }
|
||||||
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));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
return;
|
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 handleDragStart = (e: DragEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'IMG') {
|
if (target && target.tagName === 'IMG') {
|
||||||
e.preventDefault();
|
draggedImage = target as HTMLImageElement;
|
||||||
e.stopPropagation();
|
try {
|
||||||
return false;
|
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);
|
const root = editor.root as HTMLElement;
|
||||||
editor.root.addEventListener('mousedown', handleMouseDown);
|
root.addEventListener('click', handleImageClick);
|
||||||
editor.root.addEventListener('scroll', handleScroll);
|
root.addEventListener('scroll', handleScroll);
|
||||||
editor.root.addEventListener('dragstart', handleDragStart);
|
if (enableDragReposition) {
|
||||||
|
root.addEventListener('dragstart', handleDragStart);
|
||||||
|
root.addEventListener('dragover', handleDragOver);
|
||||||
|
root.addEventListener('drop', handleDrop);
|
||||||
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
// Also reposition on window resize and any document scroll (capture phase)
|
// Also reposition on window resize and any document scroll (capture phase)
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
@@ -1197,9 +1067,13 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
editor.root.removeEventListener('click', handleImageClick);
|
editor.root.removeEventListener('click', handleImageClick);
|
||||||
editor.root.removeEventListener('mousedown', handleMouseDown);
|
const root = editor.root as HTMLElement;
|
||||||
editor.root.removeEventListener('scroll', handleScroll);
|
root.removeEventListener('scroll', handleScroll);
|
||||||
editor.root.removeEventListener('dragstart', handleDragStart);
|
if (enableDragReposition) {
|
||||||
|
root.removeEventListener('dragstart', handleDragStart);
|
||||||
|
root.removeEventListener('dragover', handleDragOver);
|
||||||
|
root.removeEventListener('drop', handleDrop);
|
||||||
|
}
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('resize', handleScroll);
|
window.removeEventListener('resize', handleScroll);
|
||||||
document.removeEventListener('scroll', handleScroll, true);
|
document.removeEventListener('scroll', handleScroll, true);
|
||||||
@@ -1209,6 +1083,65 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
};
|
};
|
||||||
}, [readOnly, toast, isMounted]);
|
}, [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
|
// Apply filters to selected image
|
||||||
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
|
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
|
||||||
const filterString = `
|
const filterString = `
|
||||||
@@ -1229,6 +1162,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
|
|
||||||
img.style.filter = filterString;
|
img.style.filter = filterString;
|
||||||
img.style.transform = transform;
|
img.style.transform = transform;
|
||||||
|
img.style.transformOrigin = "center center";
|
||||||
img.setAttribute('data-filters', JSON.stringify(filters));
|
img.setAttribute('data-filters', JSON.stringify(filters));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1265,6 +1199,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
const editor = quillRef.current?.getEditor();
|
const editor = quillRef.current?.getEditor();
|
||||||
if (editor) {
|
if (editor) {
|
||||||
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
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;
|
return newFilters;
|
||||||
@@ -1387,6 +1327,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
setManualWidth(finalWidth.toString());
|
setManualWidth(finalWidth.toString());
|
||||||
if (editor) {
|
if (editor) {
|
||||||
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
onChangeRef.current(cleanEditorHTML(editor.root.innerHTML));
|
||||||
|
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
|
||||||
}
|
}
|
||||||
// Keep selection active for subsequent operations (e.g., 50% → 75%)
|
// Keep selection active for subsequent operations (e.g., 50% → 75%)
|
||||||
reselectAfterContentUpdate();
|
reselectAfterContentUpdate();
|
||||||
@@ -1458,11 +1399,140 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedImageElement, toast]);
|
}, [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) => {
|
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 insertOrUpdateLink = useCallback(() => {
|
||||||
const quill = quillRef.current?.getEditor();
|
const quill = quillRef.current?.getEditor();
|
||||||
@@ -1479,22 +1549,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
// Replace selected text with provided text and link
|
// Replace selected text with provided text and link
|
||||||
quill.deleteText(range.index, range.length, 'user');
|
quill.deleteText(range.index, range.length, 'user');
|
||||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||||
try {
|
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||||
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 {
|
} else {
|
||||||
quill.insertText(range.index, text || url, 'link', url, 'user');
|
quill.insertText(range.index, text || url, 'link', url, 'user');
|
||||||
try {
|
quill.setSelection(range.index + (text || url).length, 0, 'user');
|
||||||
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));
|
onChangeRef.current(cleanEditorHTML(quill.root.innerHTML));
|
||||||
setIsLinkOpen(false);
|
setIsLinkOpen(false);
|
||||||
@@ -1522,7 +1580,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
<Box display="none" />
|
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1533,7 +1590,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
bg={bgColor}
|
bg={bgColor}
|
||||||
ref={containerRef}
|
|
||||||
sx={{
|
sx={{
|
||||||
'.ql-toolbar': {
|
'.ql-toolbar': {
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
@@ -1587,11 +1643,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
padding: '8px',
|
padding: '8px',
|
||||||
},
|
},
|
||||||
'& .ql-liststyle::before': {
|
|
||||||
content: '"•◦▪"',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'.ql-container': {
|
'.ql-container': {
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
@@ -1675,12 +1726,10 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
height: 'auto',
|
height: 'auto',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
margin: '12px 0',
|
margin: '12px 0',
|
||||||
transition: 'box-shadow 0.15s ease, opacity 0.15s ease, transform 0.15s ease',
|
transition: 'all 0.2s ease',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
WebkitUserDrag: 'none',
|
|
||||||
userDrag: 'none',
|
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
transform: 'scale(1.01)',
|
transform: 'scale(1.01)',
|
||||||
@@ -1704,18 +1753,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
ref={quillRef}
|
ref={quillRef}
|
||||||
modules={quillModules}
|
modules={quillModules}
|
||||||
formats={quillFormats}
|
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>
|
</Box>
|
||||||
@@ -2111,47 +2148,11 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button variant="ghost" mr={3} onClick={() => setIsLinkOpen(false)}>Zrušit</Button>
|
<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>
|
<Button colorScheme="blue" onClick={insertOrUpdateLink}>Vložit</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</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 */}
|
{/* Crop Modal */}
|
||||||
{/* Image Preview Modal */}
|
{/* Image Preview Modal */}
|
||||||
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
|
<Modal isOpen={isPreviewOpen} onClose={() => setIsPreviewOpen(false)} size="6xl" isCentered>
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
Link as ChakraLink,
|
|
||||||
Text,
|
Text,
|
||||||
VStack,
|
VStack,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FiExternalLink,
|
FiDownload,
|
||||||
FiFile,
|
FiFile,
|
||||||
FiFileText,
|
FiFileText,
|
||||||
FiImage,
|
FiImage,
|
||||||
@@ -24,6 +23,7 @@ export interface FilePreviewProps {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
showInline?: boolean;
|
showInline?: boolean;
|
||||||
|
buttonOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FilePreview: React.FC<FilePreviewProps> = ({
|
const FilePreview: React.FC<FilePreviewProps> = ({
|
||||||
@@ -31,10 +31,20 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
name,
|
name,
|
||||||
mimeType = '',
|
mimeType = '',
|
||||||
size,
|
size,
|
||||||
|
buttonOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
const fullUrl = assetUrl(url) || url;
|
const fullUrl = assetUrl(url) || url;
|
||||||
const fileName = name || url.split('/').pop() || 'file';
|
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 mime = mimeType.toLowerCase();
|
||||||
|
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
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 sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
|
||||||
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
|
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 (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
@@ -81,30 +135,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
|
|||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
|
flexWrap="wrap"
|
||||||
|
w="100%"
|
||||||
>
|
>
|
||||||
<HStack flex={1} minW={0}>
|
<HStack flex={1} minW={0}>
|
||||||
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
|
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
|
||||||
<VStack align="start" spacing={0} flex={1} minW={0}>
|
<VStack align="start" spacing={0} flex={1} minW={0}>
|
||||||
<Text
|
<Text fontWeight="medium" isTruncated maxW="100%">
|
||||||
fontWeight="medium"
|
{displayName}
|
||||||
isTruncated
|
|
||||||
maxW="100%"
|
|
||||||
>
|
|
||||||
{fileName}
|
|
||||||
</Text>
|
</Text>
|
||||||
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
|
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack spacing={2} flexShrink={0}>
|
<HStack spacing={2} flexShrink={0}>
|
||||||
<Button
|
<Button size="sm" leftIcon={<FiDownload />} colorScheme="blue" onClick={handleDownload}>
|
||||||
as={ChakraLink}
|
Stáhnout
|
||||||
href={fullUrl}
|
|
||||||
isExternal
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<FiExternalLink />}
|
|
||||||
colorScheme="blue"
|
|
||||||
>
|
|
||||||
Otevřít v novém okně
|
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import {
|
|||||||
HStack,
|
HStack,
|
||||||
Button,
|
Button,
|
||||||
Text,
|
Text,
|
||||||
useToast,
|
|
||||||
IconButton,
|
|
||||||
VStack,
|
VStack,
|
||||||
|
Link,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { Download, ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { API_URL } from '../../services/api';
|
|
||||||
|
|
||||||
interface PhotoModalProps {
|
interface PhotoModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -32,47 +30,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
|
|||||||
pageUrl,
|
pageUrl,
|
||||||
albumTitle,
|
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 (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||||
@@ -107,6 +65,7 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
|
|||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
@@ -125,53 +84,21 @@ const PhotoModal: React.FC<PhotoModalProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<HStack spacing={2} justify="space-between" flexWrap="wrap">
|
<HStack spacing={2} justify="flex-start" flexWrap="wrap">
|
||||||
<HStack spacing={2}>
|
<Button
|
||||||
<Button
|
as="a"
|
||||||
leftIcon={<Download size={18} />}
|
href={pageUrl}
|
||||||
onClick={handleDownload}
|
target="_blank"
|
||||||
colorScheme="green"
|
rel="noopener noreferrer"
|
||||||
size="sm"
|
leftIcon={<ExternalLink size={18} />}
|
||||||
>
|
colorScheme="purple"
|
||||||
Stáhnout
|
size="sm"
|
||||||
</Button>
|
>
|
||||||
<Button
|
Zobrazit originál
|
||||||
as="a"
|
</Button>
|
||||||
href={pageUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
leftIcon={<ExternalLink size={18} />}
|
|
||||||
colorScheme="purple"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Zobrazit originál
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* Zonerama Copyright */}
|
{/* Attribution moved into image overlay */}
|
||||||
<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>
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
|||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
{/* Zonerama Attribution */}
|
{/* Zonerama Attribution (single source of truth) */}
|
||||||
<Box
|
<Box
|
||||||
bg={infoBg}
|
bg={infoBg}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
@@ -177,7 +177,7 @@ const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
|||||||
py={2}
|
py={2}
|
||||||
>
|
>
|
||||||
<Text fontSize="xs" color={infoText}>
|
<Text fontSize="xs" color={infoText}>
|
||||||
📸 Všechny fotografie jsou z platformy{' '}
|
© Fotografie z{' '}
|
||||||
<Text
|
<Text
|
||||||
as="a"
|
as="a"
|
||||||
href={zoneramaUrl || profileUrl || 'https://zonerama.com'}
|
href={zoneramaUrl || profileUrl || 'https://zonerama.com'}
|
||||||
|
|||||||
@@ -74,26 +74,6 @@ const PhotosSection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl
|
|||||||
</Box>
|
</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}>
|
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap={4}>
|
||||||
{albums.map((album) => {
|
{albums.map((album) => {
|
||||||
const coverPhoto = album.photos && album.photos.length > 0 ? album.photos[0] : null;
|
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"
|
decoding="async"
|
||||||
referrerPolicy="origin-when-cross-origin"
|
referrerPolicy="origin-when-cross-origin"
|
||||||
style={{ objectFit: 'cover' }}
|
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">
|
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface EmbeddedPollProps {
|
|||||||
title?: string;
|
title?: string;
|
||||||
showTitle?: boolean;
|
showTitle?: boolean;
|
||||||
maxPolls?: number;
|
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í',
|
title = 'Hlasování',
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
maxPolls,
|
maxPolls,
|
||||||
|
unstyled = false,
|
||||||
}) => {
|
}) => {
|
||||||
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
const bgSection = useColorModeValue('gray.50', 'gray.900');
|
||||||
|
|
||||||
@@ -100,8 +103,13 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<Box bg={bgSection} py={8} px={4} borderRadius="xl" my={8}>
|
<Box {...wrapperProps}>
|
||||||
<VStack spacing={6} maxW="6xl" mx="auto">
|
<VStack spacing={6} maxW="6xl" mx="auto">
|
||||||
{showTitle && (
|
{showTitle && (
|
||||||
<Heading size="md" textAlign="center">
|
<Heading size="md" textAlign="center">
|
||||||
@@ -139,6 +147,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
hasVoted={pollResponse.has_voted}
|
hasVoted={pollResponse.has_voted}
|
||||||
isActive={pollResponse.is_active}
|
isActive={pollResponse.is_active}
|
||||||
canShowResults={pollResponse.can_show_results}
|
canShowResults={pollResponse.can_show_results}
|
||||||
|
flat={unstyled}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -153,6 +162,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
hasVoted={pollResponse.has_voted}
|
hasVoted={pollResponse.has_voted}
|
||||||
isActive={pollResponse.is_active}
|
isActive={pollResponse.is_active}
|
||||||
canShowResults={pollResponse.can_show_results}
|
canShowResults={pollResponse.can_show_results}
|
||||||
|
flat={unstyled}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
@@ -168,6 +178,7 @@ const EmbeddedPoll: React.FC<EmbeddedPollProps> = ({
|
|||||||
hasVoted={pollResponse.has_voted}
|
hasVoted={pollResponse.has_voted}
|
||||||
isActive={pollResponse.is_active}
|
isActive={pollResponse.is_active}
|
||||||
canShowResults={pollResponse.can_show_results}
|
canShowResults={pollResponse.can_show_results}
|
||||||
|
flat={unstyled}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ interface PollCardProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
canShowResults: boolean;
|
canShowResults: boolean;
|
||||||
onVoteSuccess?: () => void;
|
onVoteSuccess?: () => void;
|
||||||
|
// When true, render transparent card without own bg/border/shadow
|
||||||
|
flat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PollCard: React.FC<PollCardProps> = ({
|
const PollCard: React.FC<PollCardProps> = ({
|
||||||
@@ -48,6 +50,7 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
isActive,
|
isActive,
|
||||||
canShowResults: initialCanShowResults,
|
canShowResults: initialCanShowResults,
|
||||||
onVoteSuccess,
|
onVoteSuccess,
|
||||||
|
flat = false,
|
||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -254,12 +257,12 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg={bgCard}
|
bg={flat ? 'transparent' : bgCard}
|
||||||
borderWidth="1px"
|
borderWidth={flat ? '0' : '1px'}
|
||||||
borderColor={borderColor}
|
borderColor={flat ? 'transparent' : borderColor}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
p={6}
|
p={flat ? 0 : 6}
|
||||||
boxShadow="md"
|
boxShadow={flat ? 'none' : 'md'}
|
||||||
>
|
>
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{poll.image_url && (
|
{poll.image_url && (
|
||||||
@@ -319,12 +322,12 @@ const PollCard: React.FC<PollCardProps> = ({
|
|||||||
// Show voting form
|
// Show voting form
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
bg={bgCard}
|
bg={flat ? 'transparent' : bgCard}
|
||||||
borderWidth="1px"
|
borderWidth={flat ? '0' : '1px'}
|
||||||
borderColor={borderColor}
|
borderColor={flat ? 'transparent' : borderColor}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
p={6}
|
p={flat ? 0 : 6}
|
||||||
boxShadow="md"
|
boxShadow={flat ? 'none' : 'md'}
|
||||||
>
|
>
|
||||||
<VStack spacing={4} align="stretch">
|
<VStack spacing={4} align="stretch">
|
||||||
{poll.image_url && (
|
{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 { 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 { 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 { 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.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 { 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 { 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.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)); }
|
.scoreboard.pill .seg.team.away { background: linear-gradient(90deg, var(--away-dark), var(--away-light)); color: var(--away-text, #ffffff); }
|
||||||
.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.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 .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 .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)); }
|
.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,
|
'--away-dark': right.color,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
'--away-light': shade(right.color, 20),
|
'--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;
|
} as any;
|
||||||
|
|
||||||
if (theme !== 'pill') {
|
if (theme !== 'pill') {
|
||||||
@@ -113,7 +114,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
|
|||||||
<div className="pill-wrapper" style={cssVars as any}>
|
<div className="pill-wrapper" style={cssVars as any}>
|
||||||
<div className="scoreboard pill">
|
<div className="scoreboard pill">
|
||||||
<div className="seg timer"><span>{timer}</span></div>
|
<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 || ''} />
|
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
|
||||||
<span>{left.short}</span>
|
<span>{left.short}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +147,6 @@ const MyClubOverlay: React.FC<{ state: ScoreboardState }> = ({ state }) => {
|
|||||||
<div className="pill-wrapper" style={cssVars as any}>
|
<div className="pill-wrapper" style={cssVars as any}>
|
||||||
<div className="scoreboard pill">
|
<div className="scoreboard pill">
|
||||||
<div className="seg timer"><span>{timer}</span></div>
|
<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 || ''} />
|
<div className="seg team home"><img className="logo" alt="" src={left.logo || ''} />
|
||||||
<span>{left.short}</span>
|
<span>{left.short}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,6 +89,66 @@ export const MatchesWidget: React.FC<{
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
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 getLogo = (teamName?: string, original?: string) => {
|
||||||
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
const byName = (overrides as any)?.by_name || {} as Record<string, string>;
|
||||||
const norm = (s: string) => String(s || '')
|
const norm = (s: string) => String(s || '')
|
||||||
@@ -153,8 +213,8 @@ export const MatchesWidget: React.FC<{
|
|||||||
id: m.match_id,
|
id: m.match_id,
|
||||||
date_time: m.date_time || m.date,
|
date_time: m.date_time || m.date,
|
||||||
competitionName: m.competitionName,
|
competitionName: m.competitionName,
|
||||||
home: (m.home_id && byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : (m.home || m.home_team),
|
home: getOverrideName(m.home || m.home_team, m.home_id),
|
||||||
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),
|
away: getOverrideName(m.away || m.away_team, m.away_id),
|
||||||
score: m.score,
|
score: m.score,
|
||||||
venue: m.venue,
|
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),
|
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),
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ export function useAutoSave<T extends Record<string, any>>({
|
|||||||
const [draftAge, setDraftAge] = useState<number | null>(null);
|
const [draftAge, setDraftAge] = useState<number | null>(null);
|
||||||
|
|
||||||
const saveTimerRef = useRef<NodeJS.Timeout>();
|
const saveTimerRef = useRef<NodeJS.Timeout>();
|
||||||
const lastDataRef = useRef<string>('');
|
const lastLocalDataRef = useRef<string>('');
|
||||||
|
const lastBackendDataRef = useRef<string>('');
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
const lastDataObjRef = useRef<T | null>(null);
|
||||||
|
const localSaveTimerRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
// Check for existing draft on mount
|
// Check for existing draft on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,18 +156,26 @@ export function useAutoSave<T extends Record<string, any>>({
|
|||||||
// Main auto-save effect
|
// Main auto-save effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
if (lastDataObjRef.current === data) {
|
||||||
const dataString = JSON.stringify(data);
|
|
||||||
|
|
||||||
// Skip if data hasn't changed
|
|
||||||
if (dataString === lastDataRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastDataRef.current = dataString;
|
lastDataObjRef.current = data;
|
||||||
|
|
||||||
// Save to localStorage immediately
|
if (localSaveTimerRef.current) {
|
||||||
saveToLocalStorage(data);
|
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
|
// Debounce backend save
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
@@ -172,13 +183,24 @@ export function useAutoSave<T extends Record<string, any>>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
saveToBackend(data);
|
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);
|
}, debounceMs);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (localSaveTimerRef.current) {
|
||||||
|
clearTimeout(localSaveTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
|
}, [data, enabled, debounceMs, saveToLocalStorage, saveToBackend]);
|
||||||
|
|
||||||
@@ -211,6 +233,9 @@ export function useAutoSave<T extends Record<string, any>>({
|
|||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
clearTimeout(saveTimerRef.current);
|
clearTimeout(saveTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (localSaveTimerRef.current) {
|
||||||
|
clearTimeout(localSaveTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './styles/admin-enhancements.css';
|
|||||||
import './styles/home-style-pack.css';
|
import './styles/home-style-pack.css';
|
||||||
import './styles/sparta-styles.css';
|
import './styles/sparta-styles.css';
|
||||||
// Quill editor styles (MUST be imported globally) - CRITICAL for rich text editor
|
// 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';
|
import 'react-image-crop/dist/ReactCrop.css';
|
||||||
// Custom editor styles AFTER quill base styles to ensure proper override
|
// Custom editor styles AFTER quill base styles to ensure proper override
|
||||||
import './styles/custom-editor.css';
|
import './styles/custom-editor.css';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,12 @@ const BlogPage: React.FC = () => {
|
|||||||
const matchId = searchParams.get('match_id') || '';
|
const matchId = searchParams.get('match_id') || '';
|
||||||
const qParam = searchParams.get('q') || '';
|
const qParam = searchParams.get('q') || '';
|
||||||
const [qInput, setQInput] = React.useState<string>(qParam);
|
const [qInput, setQInput] = React.useState<string>(qParam);
|
||||||
|
const [matchInput, setMatchInput] = React.useState<string>('');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
const textColor = useColorModeValue('gray.500', 'gray.400');
|
const textColor = useColorModeValue('gray.500', 'gray.400');
|
||||||
|
const suggestBg = useColorModeValue('white','gray.800');
|
||||||
|
const suggestBorder = useColorModeValue('gray.200','gray.700');
|
||||||
|
const suggestHoverBg = useColorModeValue('gray.50','gray.700');
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -151,6 +155,24 @@ const BlogPage: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setQInput(qParam);
|
setQInput(qParam);
|
||||||
}, [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>>(
|
const featuredQ = useQuery<Paginated<Article>>(
|
||||||
['articles-featured', { page_size: 3 }],
|
['articles-featured', { page_size: 3 }],
|
||||||
() => getFeaturedArticles({ page_size: 3 }),
|
() => getFeaturedArticles({ page_size: 3 }),
|
||||||
@@ -266,9 +288,9 @@ const BlogPage: React.FC = () => {
|
|||||||
{/* Header like blog.html */}
|
{/* Header like blog.html */}
|
||||||
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
|
<Box bg="transparent" color="inherit" py={{ base: 8, md: 10 }} mb={4} borderBottom="1px" borderColor={borderColor}>
|
||||||
<Container maxW="7xl">
|
<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>
|
<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">
|
<Box flex="1">
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputLeftElement pointerEvents="none">
|
<InputLeftElement pointerEvents="none">
|
||||||
@@ -301,7 +323,7 @@ const BlogPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
{!!categories.length && (
|
{!!categories.length && (
|
||||||
<Select
|
<Select
|
||||||
maxW={{ base: '44%', md: '240px' }}
|
maxW={{ base: '48%', md: '220px' }}
|
||||||
placeholder="Všechny kategorie"
|
placeholder="Všechny kategorie"
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -320,6 +342,45 @@ const BlogPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</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>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -232,6 +232,63 @@ const CalendarPage: React.FC = () => {
|
|||||||
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
|
const byId: Record<string, { name?: string; logo_url?: string }> = (overrides?.by_id || {}) as any;
|
||||||
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
|
const byNameNormalized: Record<string, string> = Object.keys(byName || {}).reduce((acc: Record<string, string>, k: string) => { acc[normalize(k)] = byName[k]; return acc; }, {});
|
||||||
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
||||||
|
const 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) => {
|
const getOverrideLogo = (teamName?: string, original?: string, teamId?: string) => {
|
||||||
// Prefer admin override by ID
|
// Prefer admin override by ID
|
||||||
if (teamId && byId?.[teamId]?.logo_url) {
|
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 isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||||
const time = (t || '00:00').slice(0,5);
|
const time = (t || '00:00').slice(0,5);
|
||||||
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
|
const score = (m.score || m.result || (typeof m.goals_home === 'number' && typeof m.goals_away === 'number' ? `${m.goals_home}:${m.goals_away}` : '') || '').toString();
|
||||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
const homeName = getOverrideName(m.home, m.home_id);
|
||||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
const awayName = getOverrideName(m.away, m.away_id);
|
||||||
return {
|
return {
|
||||||
id: m.match_id || `${cIdx}-${idx}`,
|
id: m.match_id || `${cIdx}-${idx}`,
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
@@ -321,8 +378,8 @@ const CalendarPage: React.FC = () => {
|
|||||||
id: m.match_id || `${cIdx}-${idx}`,
|
id: m.match_id || `${cIdx}-${idx}`,
|
||||||
date: isoDate,
|
date: isoDate,
|
||||||
time,
|
time,
|
||||||
home: (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home,
|
home: getOverrideName(m.home, m.home_id),
|
||||||
away: (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away,
|
away: getOverrideName(m.away, m.away_id),
|
||||||
home_id: m.home_id,
|
home_id: m.home_id,
|
||||||
away_id: m.away_id,
|
away_id: m.away_id,
|
||||||
venue: m.venue,
|
venue: m.venue,
|
||||||
|
|||||||
@@ -145,30 +145,6 @@ const GalleryPage: React.FC = () => {
|
|||||||
<Heading size="2xl" color={textPrimary}>
|
<Heading size="2xl" color={textPrimary}>
|
||||||
Fotogalerie
|
Fotogalerie
|
||||||
</Heading>
|
</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>
|
</VStack>
|
||||||
|
|
||||||
{/* Loading State */}
|
{/* Loading State */}
|
||||||
|
|||||||
@@ -1548,65 +1548,78 @@ const HomePage: React.FC = () => {
|
|||||||
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
|
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
|
||||||
{isVisible('matches', true) ? (
|
{isVisible('matches', true) ? (
|
||||||
facrCompetitions.length > 0 ? (
|
facrCompetitions.length > 0 ? (
|
||||||
upcomingCompIndices.length > 0 ? (
|
(() => {
|
||||||
(() => {
|
// Only render when the currently selected competition has an upcoming match
|
||||||
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
|
if (upcomingCompIndices.length === 0) return null;
|
||||||
const comp = facrCompetitions[effectiveIndex];
|
const effectiveIndex = Math.max(0, Math.min(matchesTab, facrCompetitions.length - 1));
|
||||||
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
const comp = facrCompetitions[effectiveIndex];
|
||||||
const upcoming = items
|
const items = Array.isArray(comp?.matches) ? comp.matches : [];
|
||||||
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
const upcoming = items
|
||||||
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
||||||
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
||||||
const show = upcoming || null;
|
.sort((a: any, b: any) => a.t - b.t)[0]?.m;
|
||||||
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
|
if (!upcoming) return null;
|
||||||
// Compute prev/next among competitions that actually have upcoming matches
|
const show = upcoming;
|
||||||
const pos = upcomingCompIndices.indexOf(effectiveIndex);
|
const link = (show && (show.facr_link || show.report_url)) || comp?.matches_link || nextMatchLink;
|
||||||
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
|
// Compute prev/next among competitions that actually have upcoming matches
|
||||||
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
|
const pos = upcomingCompIndices.indexOf(effectiveIndex);
|
||||||
const handleNextMatchClick = () => {
|
const prevIdx = upcomingCompIndices[(Math.max(0, pos) - 1 + upcomingCompIndices.length) % upcomingCompIndices.length];
|
||||||
if (show) {
|
const nextIdx = upcomingCompIndices[(Math.max(0, pos) + 1) % upcomingCompIndices.length];
|
||||||
setSelectedMatch({
|
const handleNextMatchClick = () => {
|
||||||
...show,
|
if (show) {
|
||||||
competition: comp?.name,
|
setSelectedMatch({
|
||||||
});
|
...show,
|
||||||
setIsMatchModalOpen(true);
|
competition: comp?.name,
|
||||||
} else if (link) {
|
});
|
||||||
window.open(link, '_blank', 'noopener,noreferrer');
|
setIsMatchModalOpen(true);
|
||||||
}
|
} else if (link) {
|
||||||
};
|
window.open(link, '_blank', 'noopener,noreferrer');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextMatch
|
<NextMatch
|
||||||
data={show}
|
data={show}
|
||||||
competitionName={comp?.name}
|
competitionName={comp?.name}
|
||||||
countdown={countdown}
|
countdown={countdown}
|
||||||
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
|
onPrev={() => { setNextCompIdx(prevIdx); setMatchesTab(prevIdx); }}
|
||||||
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
|
onNext={() => { setNextCompIdx(nextIdx); setMatchesTab(nextIdx); }}
|
||||||
onOpen={handleNextMatchClick}
|
onOpen={handleNextMatchClick}
|
||||||
elementProps={{
|
elementProps={{
|
||||||
'data-element': 'matches' as any,
|
'data-element': 'matches' as any,
|
||||||
'data-variant': getVariant('matches', 'compact') as any,
|
'data-variant': getVariant('matches', 'compact') as any,
|
||||||
'aria-live': 'polite' as any,
|
'aria-live': 'polite' as any,
|
||||||
style: { ...getStyles('matches') },
|
style: { ...getStyles('matches') },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : null
|
|
||||||
) : (
|
) : (
|
||||||
<div className="card">
|
(() => {
|
||||||
<NextMatch
|
// Fallback without FACR: show only if there is an upcoming match in the fallback list
|
||||||
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
if (!matches || matches.length === 0) return null;
|
||||||
data={{
|
const future = matches
|
||||||
home: matches[0]?.homeTeam || clubName,
|
.map((m: any) => ({ m, t: new Date(`${m.date}T${(m.time || '00:00')}:00`).getTime() }))
|
||||||
home_logo_url: matches[0]?.homeLogoURL || clubLogo,
|
.filter((x: any) => !isNaN(x.t) && x.t > Date.now())
|
||||||
away: matches[0]?.awayTeam || 'Soupeř',
|
.sort((a: any, b: any) => a.t - b.t);
|
||||||
away_logo_url: matches[0]?.awayLogoURL,
|
const next = future[0]?.m;
|
||||||
}}
|
if (!next) return null;
|
||||||
countdown={countdown}
|
return (
|
||||||
elementProps={{ 'data-element': 'matches', 'data-variant': getVariant('matches', 'compact'), 'aria-live': 'polite', style: { position: 'relative', ...getStyles('matches') } }}
|
<div className="card">
|
||||||
/>
|
<NextMatch
|
||||||
</div>
|
key={`matches-${refreshKey}-${getVariant('matches', 'compact')}`}
|
||||||
|
data={{
|
||||||
|
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}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,63 @@ const MatchesPage: React.FC = () => {
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
const byNameStrippedPairs: Array<{ keyNorm: string; url: string }> = Object.keys(byName || {}).map((k: string) => ({ keyNorm: stripPrefixes(k), url: byName[k] }));
|
||||||
|
const 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) => {
|
const getFallbackLogo = (teamName?: string, original?: string) => {
|
||||||
if (teamName) {
|
if (teamName) {
|
||||||
@@ -370,8 +427,8 @@ const MatchesPage: React.FC = () => {
|
|||||||
const [day, month, year] = (d || '').split('.');
|
const [day, month, year] = (d || '').split('.');
|
||||||
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
const isoDate = (day && month && year) ? `${year}-${month.padStart(2,'0')}-${day.padStart(2,'0')}` : new Date().toISOString().slice(0,10);
|
||||||
const time = (t || '18:00').slice(0,5);
|
const time = (t || '18:00').slice(0,5);
|
||||||
const homeName = (byId?.[m.home_id]?.name && String(byId[m.home_id].name).trim()) ? String(byId[m.home_id].name) : m.home;
|
const homeName = getOverrideName(m.home, m.home_id);
|
||||||
const awayName = (byId?.[m.away_id]?.name && String(byId[m.away_id].name).trim()) ? String(byId[m.away_id].name) : m.away;
|
const awayName = getOverrideName(m.away, m.away_id);
|
||||||
|
|
||||||
// Check if match is in the future - if so, ignore score
|
// Check if match is in the future - if so, ignore score
|
||||||
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
const matchTime = new Date(`${isoDate}T${time}:00`).getTime();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import './styles/MagazineHome.css';
|
|||||||
import './styles/ProHome.css';
|
import './styles/ProHome.css';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
|
import { getSetupStatus, initializeSetup, SetupInitializePayload, validateSMTP } from '../services/setup';
|
||||||
|
import { getRembgStatus, startRembgBatch } from '../services/rembg';
|
||||||
import { updateSeoSettings } from '../services/seo';
|
import { updateSeoSettings } from '../services/seo';
|
||||||
import { API_URL } from '../services/api';
|
import { API_URL } from '../services/api';
|
||||||
import { assetUrl } from '../utils/url';
|
import { assetUrl } from '../utils/url';
|
||||||
@@ -123,6 +124,9 @@ const SetupPage: React.FC = () => {
|
|||||||
const [isDomainHost, setIsDomainHost] = useState(false);
|
const [isDomainHost, setIsDomainHost] = useState(false);
|
||||||
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
|
const [showAdvancedApi, setShowAdvancedApi] = useState(false);
|
||||||
const [apiUrlTouched, setApiUrlTouched] = 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 toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -378,6 +382,38 @@ const SetupPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
toast({ title: 'Nastavení dokončeno', status: 'success', duration: 3000, isClosable: true });
|
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 {
|
try {
|
||||||
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
|
const fb = (frontendBaseUrl || '').trim().replace(/\/$/, '');
|
||||||
let ab = (apiBaseUrl || '').trim();
|
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>
|
<Button type="submit" colorScheme="blue" mt={8} isLoading={submitting} loadingText="Ukládám…">Dokončit nastavení</Button>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -185,6 +185,26 @@ const VideosPage: React.FC = () => {
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
referrerPolicy="origin-when-cross-origin"
|
referrerPolicy="origin-when-cross-origin"
|
||||||
style={{ objectFit: 'cover' }}
|
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">
|
<Box bg={placeholderBg} display="flex" alignItems="center" justifyContent="center">
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
if (localDraft) {
|
if (localDraft) {
|
||||||
setEditing(localDraft);
|
setEditing(localDraft);
|
||||||
} else {
|
} else {
|
||||||
setEditing({ title: '', description: '', type: 'other', is_public: false } as any);
|
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||||
}
|
}
|
||||||
setLocationLat(undefined);
|
setLocationLat(undefined);
|
||||||
setLocationLng(undefined);
|
setLocationLng(undefined);
|
||||||
@@ -504,7 +504,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
end_time: (endISO as any) || null,
|
end_time: (endISO as any) || null,
|
||||||
location: (editing.location || '').trim(),
|
location: (editing.location || '').trim(),
|
||||||
type: (editing.type || 'other') as any,
|
type: (editing.type || 'other') as any,
|
||||||
is_public: !!editing.is_public,
|
is_public: true,
|
||||||
image_url: imageUrl || undefined,
|
image_url: imageUrl || undefined,
|
||||||
file_url: (editing as any).file_url || undefined,
|
file_url: (editing as any).file_url || undefined,
|
||||||
category_name: (editing as any)?.category_name || undefined,
|
category_name: (editing as any)?.category_name || undefined,
|
||||||
@@ -538,7 +538,6 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
<Th>Začátek</Th>
|
<Th>Začátek</Th>
|
||||||
<Th>Konec</Th>
|
<Th>Konec</Th>
|
||||||
<Th>Místo</Th>
|
<Th>Místo</Th>
|
||||||
<Th>Veřejná</Th>
|
|
||||||
<Th w="140px">Akce</Th>
|
<Th w="140px">Akce</Th>
|
||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
@@ -594,7 +593,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
)}
|
)}
|
||||||
{!isLoading && events.map(ev => (
|
{!isLoading && events.map(ev => (
|
||||||
<Tr key={ev.id} opacity={ev.is_public ? 1 : 0.6}>
|
<Tr key={ev.id}>
|
||||||
<Td>
|
<Td>
|
||||||
{(ev as any).image_url ? (
|
{(ev as any).image_url ? (
|
||||||
<ThumbnailPreview
|
<ThumbnailPreview
|
||||||
@@ -623,7 +622,6 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
|
||||||
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
<Td>{ev.end_time ? new Date(ev.end_time).toLocaleString() : '-'}</Td>
|
||||||
<Td>{ev.location || '-'}</Td>
|
<Td>{ev.location || '-'}</Td>
|
||||||
<Td>{ev.is_public ? 'Ano' : 'Ne'}</Td>
|
|
||||||
<Td>
|
<Td>
|
||||||
<HStack>
|
<HStack>
|
||||||
<IconButton aria-label="Upravit" size="sm" icon={<FiEdit2 />} onClick={() => openEdit(ev)} />
|
<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)} />
|
<Switch isChecked={aiOverwrite} onChange={(e)=> setAiOverwrite(e.target.checked)} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Tooltip label="AI doplní titul a popis podle zadaných informací." hasArrow>
|
<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
|
AI text
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -1063,10 +1070,7 @@ const AdminActivitiesPage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</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) */}
|
{/* ... (rest of the code remains the same) */}
|
||||||
<HStack mt={4} align="flex-start">
|
<HStack mt={4} align="flex-start">
|
||||||
|
|||||||
@@ -443,7 +443,27 @@ const AdminVideosPage: React.FC = () => {
|
|||||||
.map((v) => (
|
.map((v) => (
|
||||||
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
|
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
|
||||||
<VStack align="stretch" spacing={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>
|
<Box>
|
||||||
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
|
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
|
||||||
<HStack spacing={2} color="gray.600" fontSize="sm">
|
<HStack spacing={2} color="gray.600" fontSize="sm">
|
||||||
@@ -484,7 +504,37 @@ const AdminVideosPage: React.FC = () => {
|
|||||||
{items.map((it, idx) => (
|
{items.map((it, idx) => (
|
||||||
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
|
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
|
||||||
<VStack align="stretch" spacing={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>
|
<Box>
|
||||||
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
|
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
|
||||||
<HStack spacing={2} color="gray.600" fontSize="sm">
|
<HStack spacing={2} color="gray.600" fontSize="sm">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Textarea, Icon, useBreakpointValue, InputGroup, InputLeftElement,
|
Textarea, Icon, useBreakpointValue, InputGroup, InputLeftElement,
|
||||||
ButtonGroup, Spinner, Heading, Td, Th, Thead, Tr, Tbody, Table, Switch,
|
ButtonGroup, Spinner, Heading, Td, Th, Thead, Tr, Tbody, Table, Switch,
|
||||||
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
Select, Badge, Tabs, TabList, TabPanels, Tab, TabPanel, Accordion, AccordionItem,
|
||||||
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon
|
AccordionButton, AccordionPanel, AccordionIcon, AspectRatio, Link, Alert, AlertIcon, Checkbox
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
import { FiEdit2, FiTrash2, FiPlus, FiSearch, FiUpload, FiExternalLink, FiVideo, FiX, FiRefreshCcw, FiLink } from 'react-icons/fi';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -22,6 +22,8 @@ import { getPublicSettings } from '../../services/settings';
|
|||||||
import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, saveAlbumToCache } from '../../services/zonerama';
|
import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, saveAlbumToCache } from '../../services/zonerama';
|
||||||
import { facrApi } from '../../services/facr/facrApi';
|
import { facrApi } from '../../services/facr/facrApi';
|
||||||
import { API_URL } from '../../services/api';
|
import { API_URL } from '../../services/api';
|
||||||
|
import { triggerPrefetch } from '../../services/admin/prefetch';
|
||||||
|
import { saveArticleReliable } from '../../services/articleSave';
|
||||||
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||||
import PollLinker from '../../components/admin/PollLinker';
|
import PollLinker from '../../components/admin/PollLinker';
|
||||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||||
@@ -278,109 +280,27 @@ const ArticlesAdminPage = () => {
|
|||||||
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
|
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
|
||||||
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
||||||
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
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
|
// Auto-save hook - saves draft automatically
|
||||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||||
data: editing || {},
|
data: editing || {},
|
||||||
storageKey: draftKey,
|
storageKey: draftKey,
|
||||||
onSave: async (data) => {
|
onSave: async (data) => {
|
||||||
// If article has ID, update it as draft
|
// Use centralized reliable saver (normalizes payload, retries, triggers cache refresh when published)
|
||||||
if (data.id) {
|
if ((data as any)?.id || (data as any)?.title?.trim()) {
|
||||||
try {
|
const saved: any = await saveArticleReliable(data as any);
|
||||||
// Build safe minimal payload the backend expects
|
if (saved?.id && !(data as any)?.id) {
|
||||||
const attachmentsNorm = (() => {
|
setEditing(prev => ({ ...(prev as any), id: saved.id } as any));
|
||||||
const a: any = (data as any)?.attachments;
|
setDraftKey(`draft-article-${saved.id}`);
|
||||||
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}`);
|
|
||||||
try { localStorage.removeItem('draft-article-new'); } catch {}
|
|
||||||
}
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
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 {}
|
try { localStorage.removeItem('draft-article-new'); } catch {}
|
||||||
}
|
}
|
||||||
return created;
|
return saved;
|
||||||
}
|
}
|
||||||
// Don't save if no title
|
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
debounceMs: 2000,
|
debounceMs: 2000,
|
||||||
@@ -467,6 +387,12 @@ const ArticlesAdminPage = () => {
|
|||||||
}
|
}
|
||||||
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
|
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isExistingAlbumsOpen && cachedAlbums.length === 0 && !galleryLoading) {
|
||||||
|
fetchCachedGallery();
|
||||||
|
}
|
||||||
|
}, [isExistingAlbumsOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
|
||||||
|
|
||||||
const filteredYoutubeVideos = useMemo(() => {
|
const filteredYoutubeVideos = useMemo(() => {
|
||||||
const q = youtubeSearch.trim().toLowerCase();
|
const q = youtubeSearch.trim().toLowerCase();
|
||||||
if (!q) return youtubeVideos;
|
if (!q) return youtubeVideos;
|
||||||
@@ -545,16 +471,19 @@ const ArticlesAdminPage = () => {
|
|||||||
// Handle album photo selection for blog content
|
// Handle album photo selection for blog content
|
||||||
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
|
const handleAlbumPhotosSelected = useCallback(async (photos: Array<{ id: string; page_url: string; image_1500: string }>, albumInfo: any) => {
|
||||||
try {
|
try {
|
||||||
// Save album to cache (admins only)
|
// Save album to cache (admins only) with a sufficiently high photo limit to fetch the full album
|
||||||
if (isAdmin) {
|
if (isAdmin && albumInfo?.url) {
|
||||||
toast({ title: 'Ukládám album...', status: 'info', duration: 2000 });
|
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
|
// Store album info with article and append images to content
|
||||||
setEditing((prev) => {
|
setEditing((prev) => {
|
||||||
const currentContent = (prev as any)?.content || '';
|
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 {
|
return {
|
||||||
...(prev as any),
|
...(prev as any),
|
||||||
gallery_album_id: albumInfo.id,
|
gallery_album_id: albumInfo.id,
|
||||||
@@ -733,13 +662,7 @@ const ArticlesAdminPage = () => {
|
|||||||
const settings = await getPublicSettings();
|
const settings = await getPublicSettings();
|
||||||
const clubId = (settings as any)?.club_id || '';
|
const clubId = (settings as any)?.club_id || '';
|
||||||
const clubType = ((settings as any)?.club_type || 'football') as 'football' | 'futsal';
|
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
|
// Aliases
|
||||||
let amap: Record<string, string> = {};
|
let amap: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
@@ -747,6 +670,27 @@ const ArticlesAdminPage = () => {
|
|||||||
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
|
list.forEach((a) => { if (a.code && a.alias) amap[a.code] = a.alias; });
|
||||||
setAliasesList(list as any);
|
setAliasesList(list as any);
|
||||||
} catch {}
|
} 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
|
// 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 }));
|
const withAliases = comps.map((c) => ({ code: c.code, name: (c.code && amap[c.code]) ? amap[c.code] : c.name }));
|
||||||
setAliasesMap(amap);
|
setAliasesMap(amap);
|
||||||
@@ -759,7 +703,16 @@ const ArticlesAdminPage = () => {
|
|||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
|
const parsed = parseInt(String(aiMinWordsInput || '').trim(), 10);
|
||||||
const effective = Number.isFinite(parsed) && !isNaN(parsed) && parsed > 0 ? parsed : aiMinWords;
|
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 2–4 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) => {
|
onSuccess: (res) => {
|
||||||
console.log('AI blog response:', res);
|
console.log('AI blog response:', res);
|
||||||
@@ -891,6 +844,7 @@ const ArticlesAdminPage = () => {
|
|||||||
// Clear temporary storage
|
// Clear temporary storage
|
||||||
setTempMatchLink('');
|
setTempMatchLink('');
|
||||||
setMatchIdInput('');
|
setMatchIdInput('');
|
||||||
|
try { if (created?.published) { await triggerPrefetch(); } } catch {}
|
||||||
|
|
||||||
// Invalidate queries to refresh the list
|
// Invalidate queries to refresh the list
|
||||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||||
@@ -916,9 +870,10 @@ const ArticlesAdminPage = () => {
|
|||||||
mutationFn: ({ id, payload }: { id: number | string; payload: UpdateArticlePayload }) =>
|
mutationFn: ({ id, payload }: { id: number | string; payload: UpdateArticlePayload }) =>
|
||||||
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
||||||
updateArticle(id, payload),
|
updateArticle(id, payload),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: async (saved: any, variables) => {
|
||||||
const articleId = variables.id;
|
const articleId = variables.id;
|
||||||
console.log('Article updated successfully in mutation callback:', articleId);
|
console.log('Article updated successfully in mutation callback:', articleId);
|
||||||
|
try { if (saved?.published) { await triggerPrefetch(); } } catch {}
|
||||||
|
|
||||||
// Invalidate queries to refresh the list
|
// Invalidate queries to refresh the list
|
||||||
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
qc.invalidateQueries({ queryKey: ['admin-articles'] });
|
||||||
@@ -1110,11 +1065,6 @@ const ArticlesAdminPage = () => {
|
|||||||
|
|
||||||
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
|
const onSubmit = async (options: { keepOpen?: boolean } = {}) => {
|
||||||
if (!editing) return;
|
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)
|
// Check if content contains raw AI JSON (invalid state)
|
||||||
const contentText = String(editing.content || '').trim();
|
const contentText = String(editing.content || '').trim();
|
||||||
@@ -1618,10 +1568,10 @@ const ArticlesAdminPage = () => {
|
|||||||
/>
|
/>
|
||||||
<FormHelperText>Automaticky generováno z názvu článku</FormHelperText>
|
<FormHelperText>Automaticky generováno z názvu článku</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl isRequired>
|
<FormControl>
|
||||||
<FormLabel fontWeight="bold">Kategorie (soutěž)</FormLabel>
|
<FormLabel fontWeight="bold">Kategorie (soutěž)</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Vyberte kategorii článku"
|
placeholder="Vyberte kategorii (volitelné)"
|
||||||
value={(editing as any)?.category_name || ''}
|
value={(editing as any)?.category_name || ''}
|
||||||
onChange={(e) => setEditing((prev) => ({ ...(prev as any), category_name: e.target.value }))}
|
onChange={(e) => setEditing((prev) => ({ ...(prev as any), category_name: e.target.value }))}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -1632,10 +1582,7 @@ const ArticlesAdminPage = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí</FormHelperText>
|
<FormHelperText>Kategorie určuje, ve které sekci se článek zobrazí (volitelné)</FormHelperText>
|
||||||
{!(editing as any)?.category_name && (
|
|
||||||
<Text color="orange.500" fontSize="sm" mt={1}>⚠️ Kategorie je povinná</Text>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* Featured toggle - prominent display */}
|
{/* Featured toggle - prominent display */}
|
||||||
@@ -1831,6 +1778,14 @@ const ArticlesAdminPage = () => {
|
|||||||
>
|
>
|
||||||
Vložit fotografie z alba
|
Vložit fotografie z alba
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<FiSearch />}
|
||||||
|
onClick={onExistingAlbumsOpen}
|
||||||
|
>
|
||||||
|
Vybrat z alba
|
||||||
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
{activeTabIndex === 2 && (
|
{activeTabIndex === 2 && (
|
||||||
<RichTextEditor
|
<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>
|
<Text fontSize="sm" color="gray.500" mt={2}>Zadejte odkaz na Zonerama album a klikněte na "Načíst album"</Text>
|
||||||
)}
|
)}
|
||||||
{zAlbumPhotos.length > 0 && (
|
{zAlbumPhotos.length > 0 && (
|
||||||
<SimpleGrid columns={{ base: 3, md: 6 }} spacing={2} mt={2}>
|
<>
|
||||||
{zAlbumPhotos.map((p) => (
|
<SimpleGrid columns={{ base: 3, md: 6 }} spacing={2} mt={2}>
|
||||||
<Box key={p.id} borderWidth="1px" borderRadius="md" overflow="hidden" _hover={{ boxShadow: 'md' }} cursor="pointer"
|
{zAlbumPhotos.slice(0, zVisibleCount).map((p) => (
|
||||||
onClick={() => pickZoneramaImage({ id: p.id, album_id: '', album_url: zAlbumLink, page_url: p.page_url, image_url: p.image_1500 || '', title: p.title })}
|
<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" />
|
>
|
||||||
</Box>
|
<Image src={p.image_1500 || ''} alt={p.id} w="100%" h="100px" objectFit="cover" loading="lazy" decoding="async" />
|
||||||
))}
|
</Box>
|
||||||
</SimpleGrid>
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
{zAlbumPhotos.length > zVisibleCount && (
|
||||||
|
<HStack justify="center" mt={2}>
|
||||||
|
<Button size="sm" onClick={() => setZVisibleCount((c) => c + 60)}>Načíst další</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -2246,6 +2208,139 @@ const ArticlesAdminPage = () => {
|
|||||||
onPhotosSelected={handleAlbumPhotosSelected}
|
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 */}
|
{/* YouTube Video Picker Modal */}
|
||||||
<Modal isOpen={isYouTubeModalOpen} onClose={onYouTubeModalClose} size="6xl">
|
<Modal isOpen={isYouTubeModalOpen} onClose={onYouTubeModalClose} size="6xl">
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
@@ -2402,6 +2497,8 @@ const ArticlesAdminPage = () => {
|
|||||||
src={photo.image_1500}
|
src={photo.image_1500}
|
||||||
alt={photo.id}
|
alt={photo.id}
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const BANNER_PRESETS: BannerPreset[] = [
|
|||||||
aspectRatio: 8.09,
|
aspectRatio: 8.09,
|
||||||
position: 'article'
|
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',
|
value: 'homepage_under_table',
|
||||||
label: 'Pod tabulkou (Homepage)',
|
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 { Box, Button, Center, HStack, Heading, Image, SimpleGrid, Text, useColorModeValue, useToast, VStack, Badge } from '@chakra-ui/react';
|
||||||
import AdminLayout from '@/layouts/AdminLayout';
|
import AdminLayout from '@/layouts/AdminLayout';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 MobileScoreboardControlPage: React.FC = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -59,54 +59,57 @@ const MobileScoreboardControlPage: React.FC = () => {
|
|||||||
<Box p={3}>
|
<Box p={3}>
|
||||||
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
|
<Heading size="md" mb={3}>Mobilní ovládání tabule</Heading>
|
||||||
<VStack align="stretch" spacing={3}>
|
<VStack align="stretch" spacing={3}>
|
||||||
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={3}>
|
<Box borderWidth="1px" borderColor={borderCol} bg={cardBg} borderRadius="lg" p={{ base: 3, md: 4 }}>
|
||||||
<SimpleGrid columns={3} spacing={2} alignItems="center">
|
<VStack spacing={3} align="stretch">
|
||||||
<VStack spacing={2}>
|
<HStack justify="space-between" align="center" flexWrap="wrap">
|
||||||
{state.homeLogo ? <Image src={state.homeLogo} alt="DOM" boxSize="64px" objectFit="contain" /> : null}
|
<Text fontSize={{ base: '4xl', md: '5xl' }} fontWeight="black" lineHeight="1">{state.homeScore} : {state.awayScore}</Text>
|
||||||
<Text fontWeight="bold" textAlign="center">{state.homeShort || 'DOM'}</Text>
|
<Text fontSize={{ base: '3xl', md: '4xl' }} fontFamily="mono" fontWeight="semibold">{mmss}</Text>
|
||||||
<HStack>
|
</HStack>
|
||||||
<Button size="lg" onClick={() => setPartial({ homeScore: Math.max(0, (state.homeScore || 0) - 1) })}>−</Button>
|
<HStack spacing={2} wrap="wrap">
|
||||||
<Button size="lg" colorScheme="green" onClick={() => setPartial({ homeScore: (state.homeScore || 0) + 1 })}>+</Button>
|
<Button size="lg" colorScheme={state.running ? 'red' : 'green'} onClick={() => (state.running ? handlePauseTimer() : handleStartTimer())}>
|
||||||
</HStack>
|
{state.running ? 'Stop' : 'Start'}
|
||||||
<HStack>
|
</Button>
|
||||||
<Button size="sm" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) - 1)) })}>− Faul</Button>
|
<Button size="lg" variant="outline" onClick={handleResetTimer}>Reset</Button>
|
||||||
<Text fontWeight="semibold">{Math.max(0, Math.min(5, state.homeFouls || 0))}</Text>
|
<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' }); } }}>
|
||||||
<Button size="sm" colorScheme="orange" onClick={() => setPartial({ homeFouls: Math.max(0, Math.min(5, (state.homeFouls || 0) + 1)) })}>+ Faul</Button>
|
Začít 2. poločas
|
||||||
</HStack>
|
</Button>
|
||||||
</VStack>
|
<Badge ml="auto" colorScheme="purple" fontSize={{ base: 'sm', md: 'md' }}>Poločas: {state.half || 1}</Badge>
|
||||||
<VStack spacing={2}>
|
</HStack>
|
||||||
<Text fontSize="5xl" fontWeight="black">{state.homeScore} : {state.awayScore}</Text>
|
</VStack>
|
||||||
<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}
|
|
||||||
<Text fontWeight="bold" textAlign="center">{state.awayShort || 'HOS'}</Text>
|
|
||||||
<HStack>
|
|
||||||
<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>
|
|
||||||
<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>
|
</Box>
|
||||||
|
|
||||||
{/* Removed 'Vybraný zápas' section for remote – managed on main Tabule page */}
|
<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 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 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={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 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 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>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
|
{/* Removed 'Vybraný zápas' section for remote – managed on main Tabule page */}
|
||||||
</Box>
|
</Box>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Switch,
|
Switch,
|
||||||
Badge,
|
Badge,
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
useDisclosure,
|
||||||
Tabs,
|
Tabs,
|
||||||
TabList,
|
TabList,
|
||||||
TabPanels,
|
TabPanels,
|
||||||
@@ -49,12 +57,13 @@ import {
|
|||||||
prefillSponsorsFromPage,
|
prefillSponsorsFromPage,
|
||||||
getQr,
|
getQr,
|
||||||
uploadQr,
|
uploadQr,
|
||||||
|
deleteQr,
|
||||||
} from '@/services/scoreboard';
|
} from '@/services/scoreboard';
|
||||||
import { useFacrApi } from '@/hooks/useFacrApi';
|
import { useFacrApi } from '@/hooks/useFacrApi';
|
||||||
import { SearchResult } from '@/services/facr/types';
|
import { SearchResult } from '@/services/facr/types';
|
||||||
import { API_URL } from '@/services/api';
|
import { API_URL } from '@/services/api';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { getFacrClubInfoCache } from '@/services/facr/cache';
|
||||||
import { createSponsor } from '@/services/sponsors';
|
import { createSponsor } from '@/services/sponsors';
|
||||||
|
|
||||||
@@ -85,6 +94,8 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
const [sUploadBusy, setSUploadBusy] = useState(false);
|
const [sUploadBusy, setSUploadBusy] = useState(false);
|
||||||
const [qrUrl, setQrUrl] = useState<string>('');
|
const [qrUrl, setQrUrl] = useState<string>('');
|
||||||
const [qrBusy, setQrBusy] = useState(false);
|
const [qrBusy, setQrBusy] = useState(false);
|
||||||
|
const { isOpen: isSponsorModalOpen, onOpen: openSponsorModal, onClose: closeSponsorModal } = useDisclosure();
|
||||||
|
const [uploadedSponsorUrls, setUploadedSponsorUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
// Club search inline (home/away target)
|
// Club search inline (home/away target)
|
||||||
const [clubQuery, setClubQuery] = useState('');
|
const [clubQuery, setClubQuery] = useState('');
|
||||||
@@ -126,6 +137,101 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
staleTime: 60_000,
|
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
|
// Load competitions/matches from cached FACR blob
|
||||||
const { data: facrCache } = useQuery<any>({
|
const { data: facrCache } = useQuery<any>({
|
||||||
queryKey: ['facr-club-info-cache'],
|
queryKey: ['facr-club-info-cache'],
|
||||||
@@ -229,10 +335,17 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
const applyMatch = async (m: AdminMatch) => {
|
const applyMatch = async (m: AdminMatch) => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
// Populate names, logos and short codes
|
// Populate names, logos and short codes
|
||||||
const homeName = String(m.home || m.home_team || '').trim();
|
const rawHomeName = String(m.home || (m as any).home_team || '').trim();
|
||||||
const awayName = String(m.away || m.away_team || '').trim();
|
const rawAwayName = String(m.away || (m as any).away_team || '').trim();
|
||||||
const homeLogo = resolveLogoUrl(m.home_logo_url || '') || '';
|
const homeTeamId = String((m as any).home_id || (m as any).homeTeamId || (m as any).home_team_id || '');
|
||||||
const awayLogo = resolveLogoUrl(m.away_logo_url || '') || '';
|
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> = {
|
const updates: Partial<ScoreboardState> = {
|
||||||
homeName,
|
homeName,
|
||||||
awayName,
|
awayName,
|
||||||
@@ -444,30 +557,6 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</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>
|
<FormControl>
|
||||||
<FormLabel>Délka poločasu (min)</FormLabel>
|
<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 })}>
|
<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>
|
<FormLabel>Barva hostů</FormLabel>
|
||||||
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
|
<Input type="color" value={state.secondaryColor || '#2563eb'} onChange={async (e) => setPartial({ secondaryColor: e.target.value })} />
|
||||||
</FormControl>
|
</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>
|
<FormControl>
|
||||||
<FormLabel>QR interval (minuty)</FormLabel>
|
<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) })}>
|
<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 {
|
try {
|
||||||
const urls = (res?.files || []).filter(Boolean) as string[];
|
const urls = (res?.files || []).filter(Boolean) as string[];
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const want = window.confirm('Chcete přidat nahraná loga i jako nové sponzory na web?');
|
setUploadedSponsorUrls(urls);
|
||||||
if (want) {
|
openSponsorModal();
|
||||||
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' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -623,6 +711,42 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</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}>
|
<Box borderWidth="1px" borderRadius="lg" p={4} bg={cardBg} mb={6}>
|
||||||
<Heading size="md" mb={3}>QR kód</Heading>
|
<Heading size="md" mb={3}>QR kód</Heading>
|
||||||
<HStack spacing={4} align="flex-start" flexWrap="wrap">
|
<HStack spacing={4} align="flex-start" flexWrap="wrap">
|
||||||
@@ -652,6 +776,9 @@ const ScoreboardAdminPage: React.FC = () => {
|
|||||||
}} />
|
}} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={async ()=>{ try { setQrUrl(await getQr()); toast({ title: 'Obnoveno', status: 'info' }); } catch {} }}>Obnovit</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>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ const SettingsAdminPage: React.FC = () => {
|
|||||||
getAdminSettings()
|
getAdminSettings()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const s = 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(() => {
|
.catch(() => {
|
||||||
toast({ title: 'Chyba', description: 'Nepodařilo se načíst nastavení', status: 'error' });
|
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,
|
api_base_url: (settings as any).api_base_url,
|
||||||
// homepage matches display
|
// homepage matches display
|
||||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
||||||
storage_warn_threshold: (settings as any).storage_warn_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 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 integration (domain managed via .env; only tokens are saved)
|
||||||
error_review_admin_token: (settings as any).error_review_admin_token,
|
error_review_admin_token: (settings as any).error_review_admin_token,
|
||||||
error_review_ingest_token: (settings as any).error_review_ingest_token,
|
error_review_ingest_token: (settings as any).error_review_ingest_token,
|
||||||
@@ -302,7 +305,7 @@ const SettingsAdminPage: React.FC = () => {
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
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)}
|
onChange={handleNumChange('storage_warn_threshold' as any)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -312,7 +315,7 @@ const SettingsAdminPage: React.FC = () => {
|
|||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
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)}
|
onChange={handleNumChange('storage_critical_threshold' as any)}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -55,13 +55,17 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
|
if (!t) { toast({ title: 'Zadejte cílovou URL', status: 'warning' }); return; }
|
||||||
try {
|
try {
|
||||||
setCreating(true);
|
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);
|
await navigator.clipboard.writeText(res.short_url);
|
||||||
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
|
toast({ title: 'Odkaz vytvořen', description: `Zkopírováno: ${res.short_url}`, status: 'success' });
|
||||||
setTargetUrl(''); setTitle(''); setCode('');
|
setTargetUrl(''); setTitle(''); setCode('');
|
||||||
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
|
qc.invalidateQueries({ queryKey: ['admin-shortlinks'] });
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
}
|
}
|
||||||
@@ -93,7 +97,7 @@ const ShortlinksAdminPage: React.FC = () => {
|
|||||||
<HStack spacing={2} flexWrap="wrap">
|
<HStack spacing={2} flexWrap="wrap">
|
||||||
<Input placeholder="https://…" value={targetUrl} onChange={(e)=>setTargetUrl(e.target.value)} flex={3} />
|
<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="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>
|
<Button onClick={handleCreate} isLoading={creating} colorScheme="blue">Vytvořit</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -266,9 +266,9 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<Container maxW="7xl" py={8}>
|
<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>
|
<Heading size="lg">Soutěže</Heading>
|
||||||
<HStack>
|
<HStack flexWrap="wrap">
|
||||||
<Select value={status} onChange={(e)=>setStatus(e.target.value)} size="sm" maxW="220px">
|
<Select value={status} onChange={(e)=>setStatus(e.target.value)} size="sm" maxW="220px">
|
||||||
<option value="">Všechny</option>
|
<option value="">Všechny</option>
|
||||||
<option value="draft">Koncepty</option>
|
<option value="draft">Koncepty</option>
|
||||||
@@ -277,7 +277,7 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
<option value="finalized">Dokončené</option>
|
<option value="finalized">Dokončené</option>
|
||||||
<option value="archived">Archiv</option>
|
<option value="archived">Archiv</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Button colorScheme="blue" onClick={openCreate}>Nová soutěž</Button>
|
<Button colorScheme="blue" onClick={openCreate} minW="max-content">Nová soutěž</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Modal with tabs */}
|
{/* Create/Edit Modal with tabs */}
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} size="3xl" scrollBehavior="inside" isCentered>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
<ModalHeader>{editing ? 'Upravit soutěž' : 'Nová soutěž'}</ModalHeader>
|
||||||
@@ -373,7 +373,7 @@ const SweepstakesAdminPage: React.FC = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<FormLabel>Pravidla</FormLabel>
|
<FormLabel>Pravidla</FormLabel>
|
||||||
<VStack align="start" spacing={2}>
|
<VStack align="start" spacing={2}>
|
||||||
<HStack>
|
<HStack flexWrap="wrap" spacing={2}>
|
||||||
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
<Button as="label" leftIcon={<FiUpload />} variant="outline">
|
||||||
Nahrát PDF/obrázek
|
Nahrát PDF/obrázek
|
||||||
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
<Input ref={rulesInputRef} type="file" display="none" accept="image/*,application/pdf" onChange={(e)=>onUploadRules(e.target.files?.[0])} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import api from './api';
|
import api from './api';
|
||||||
|
const AI_TIMEOUT = Number(process.env.REACT_APP_AI_TIMEOUT_MS || '') || 90000;
|
||||||
|
|
||||||
export interface AIGenerateBlogReq {
|
export interface AIGenerateBlogReq {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -13,7 +14,7 @@ export interface AIGenerateBlogResp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateBlogAI(payload: AIGenerateBlogReq): Promise<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)
|
// Handle potential JSON string response from AI (defensive parsing)
|
||||||
let parsedData = data;
|
let parsedData = data;
|
||||||
@@ -47,13 +48,14 @@ export interface AIGenerateInstagramReq {
|
|||||||
hashtags?: string[];
|
hashtags?: string[];
|
||||||
audience?: string;
|
audience?: string;
|
||||||
tone?: string;
|
tone?: string;
|
||||||
|
category?: string;
|
||||||
match?: AIGenerateInstagramMatch | null;
|
match?: AIGenerateInstagramMatch | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIGenerateInstagramResp { text: string }
|
export interface AIGenerateInstagramResp { text: string }
|
||||||
|
|
||||||
export async function generateInstagramAI(payload: AIGenerateInstagramReq): Promise<AIGenerateInstagramResp> {
|
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;
|
let parsed: any = data;
|
||||||
if (typeof parsed === 'string') {
|
if (typeof parsed === 'string') {
|
||||||
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
|
try { parsed = JSON.parse(parsed); } catch { parsed = { text: '' }; }
|
||||||
@@ -77,7 +79,7 @@ export interface AIGenerateCSSResp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCSSAI(payload: AIGenerateCSSReq): Promise<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;
|
let parsed = data as any;
|
||||||
if (typeof parsed === 'string') {
|
if (typeof parsed === 'string') {
|
||||||
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
|
try { parsed = JSON.parse(parsed); } catch { parsed = { css: '' }; }
|
||||||
@@ -101,7 +103,7 @@ export interface AIGenerateAboutResp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateAboutAI(payload: AIGenerateAboutReq): Promise<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)
|
// Handle potential JSON string response from AI (defensive parsing)
|
||||||
let parsedData = data;
|
let parsedData = data;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -199,7 +199,7 @@ export async function deleteArticle(id: number | string) {
|
|||||||
export async function getArticleBySlug(slug: string) {
|
export async function getArticleBySlug(slug: string) {
|
||||||
try {
|
try {
|
||||||
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
|
const res = await api.get<Article>(`/articles/slug/${encodeURIComponent(slug)}`);
|
||||||
return res.data;
|
return normalizeArticle(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback: attempt list query through normalized helper and return first match
|
// Fallback: attempt list query through normalized helper and return first match
|
||||||
const list = await getArticles({ slug });
|
const list = await getArticles({ slug });
|
||||||
@@ -239,7 +239,8 @@ export async function uploadFile(file: File) {
|
|||||||
|
|
||||||
export async function trackArticleView(id: number | string) {
|
export async function trackArticleView(id: number | string) {
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
console.debug('Failed to track article view:', e);
|
console.debug('Failed to track article view:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export function composeInstagramPostFromArticle(params: {
|
|||||||
}): string {
|
}): string {
|
||||||
const { article, trackingUrl, clubName, hashtags = [], match } = params;
|
const { article, trackingUrl, clubName, hashtags = [], match } = params;
|
||||||
const title = article.title?.trim() || '';
|
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 : [
|
const defaultTags = hashtags.length ? hashtags : [
|
||||||
`#${normalizeTag(clubName || 'FKKrnov')}`,
|
`#${normalizeTag(clubName || 'FKKrnov')}`,
|
||||||
'#fotbal',
|
'#fotbal',
|
||||||
@@ -43,15 +44,15 @@ export function composeInstagramPostFromArticle(params: {
|
|||||||
const date = match.date_time ? formatDateTime(match.date_time) : '';
|
const date = match.date_time ? formatDateTime(match.date_time) : '';
|
||||||
const score = match.score && /\d/.test(match.score) ? match.score : '';
|
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 = [
|
const lines = [
|
||||||
header,
|
header,
|
||||||
'',
|
'',
|
||||||
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
|
score ? `Výsledek: ${home} ${score} ${away}` : `${home} vs ${away}`,
|
||||||
comp || date ? `${comp}${comp && date ? ' • ' : ''}${date}` : '',
|
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 👇',
|
'📸 Celý článek najdeš tady 👇',
|
||||||
`🔗 ${trackingUrl}`,
|
`🔗 ${trackingUrl}`,
|
||||||
@@ -63,11 +64,11 @@ export function composeInstagramPostFromArticle(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Informative/general article
|
// Informative/general article
|
||||||
const header = `💙💛 ${clubName || 'Náš klub'}: ${title} 💛💙`;
|
const header = `💙💛 ${(catName || clubName || 'Náš klub')}: ${title} 💛💙`;
|
||||||
const lines = [
|
const lines = [
|
||||||
header,
|
header,
|
||||||
'',
|
'',
|
||||||
plain,
|
snippet,
|
||||||
'',
|
'',
|
||||||
'📸 Celý článek najdeš tady 👇',
|
'📸 Celý článek najdeš tady 👇',
|
||||||
`🔗 ${trackingUrl}`,
|
`🔗 ${trackingUrl}`,
|
||||||
@@ -112,12 +113,38 @@ export function composeInstagramPostFromActivity(params: {
|
|||||||
return lines.join('\n');
|
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 {
|
try {
|
||||||
const d = new Date(dt);
|
const d = new Date(s);
|
||||||
return `${d.toLocaleDateString('cs-CZ')} ${d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}`;
|
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 {
|
} catch {
|
||||||
return dt;
|
return v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ export type ScoreboardState = {
|
|||||||
awayShort?: string;
|
awayShort?: string;
|
||||||
primaryColor?: string; // home color
|
primaryColor?: string; // home color
|
||||||
secondaryColor?: string; // away color
|
secondaryColor?: string; // away color
|
||||||
|
homeTextColor?: string; // text color for home label/short
|
||||||
|
awayTextColor?: string; // text color for away label/short
|
||||||
homeScore: number;
|
homeScore: number;
|
||||||
awayScore: number;
|
awayScore: number;
|
||||||
homeFouls?: number;
|
homeFouls?: number;
|
||||||
@@ -286,15 +288,32 @@ export async function derivePrimaryFromLogo(logoUrl?: string): Promise<string |
|
|||||||
// Helpers to map API payloads
|
// Helpers to map API payloads
|
||||||
function normalizeFromApi(d: any): Partial<ScoreboardState> {
|
function normalizeFromApi(d: any): Partial<ScoreboardState> {
|
||||||
if (!d) return {};
|
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 {
|
return {
|
||||||
homeName: d.homeName || d.home_name || d.HomeName || '',
|
homeName: d.homeName || d.home_name || d.HomeName || '',
|
||||||
awayName: d.awayName || d.away_name || d.AwayName || '',
|
awayName: d.awayName || d.away_name || d.AwayName || '',
|
||||||
homeLogo: d.homeLogo || d.home_logo || d.home_logo_url || d.HomeLogoURL || '',
|
homeLogo: absolutize(rawHome),
|
||||||
awayLogo: d.awayLogo || d.away_logo || d.away_logo_url || d.AwayLogoURL || '',
|
awayLogo: absolutize(rawAway),
|
||||||
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
|
homeShort: d.homeShort || d.home_short || d.HomeShort || '',
|
||||||
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
|
awayShort: d.awayShort || d.away_short || d.AwayShort || '',
|
||||||
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
|
primaryColor: d.primaryColor || d.primary_color || d.PrimaryColor || undefined,
|
||||||
secondaryColor: d.secondaryColor || d.secondary_color || d.SecondaryColor || 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),
|
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),
|
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),
|
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.awayShort !== undefined) out.awayShort = p.awayShort;
|
||||||
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
|
if (p.primaryColor !== undefined) out.primaryColor = p.primaryColor;
|
||||||
if (p.secondaryColor !== undefined) out.secondaryColor = p.secondaryColor;
|
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.homeScore !== undefined) out.homeScore = p.homeScore;
|
||||||
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
|
if (p.awayScore !== undefined) out.awayScore = p.awayScore;
|
||||||
if (p.homeFouls !== undefined) out.homeFouls = p.homeFouls;
|
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;
|
if (p.qrDuration !== undefined) out.qrDuration = p.qrDuration;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteQr(): Promise<void> {
|
||||||
|
await api.delete('/admin/scoreboard/qr');
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,32 +18,53 @@ export interface ShortLinkResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createShortLink(payload: CreateShortLinkPayload): Promise<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 {
|
try {
|
||||||
// Prefer editor-accessible endpoint
|
const resAdmin = await api.post<ShortLinkResponse>('/admin/shortlinks', normalized);
|
||||||
const res = await api.post<ShortLinkResponse>('/shortlinks', payload);
|
return resAdmin.data;
|
||||||
return res.data;
|
} catch (_) {
|
||||||
} catch (e: any) {
|
// Fallback to public/editor route if admin path is not available
|
||||||
// Fallback to admin endpoint (for admin-only contexts)
|
try {
|
||||||
const res2 = await api.post<ShortLinkResponse>('/admin/shortlinks', payload);
|
const resPublic = await api.post<ShortLinkResponse>('/shortlinks', normalized);
|
||||||
return res2.data;
|
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)
|
// Public shortlink creation for visitors (no auth; backend validates allowed host)
|
||||||
export async function createPublicShortLink(payload: { target_url: string; title?: string }): Promise<ShortLinkResponse> {
|
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;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listShortLinks(): Promise<{ items: any[] }> {
|
export async function listShortLinks(): Promise<{ items: any[] }> {
|
||||||
// Prefer editor-accessible endpoint
|
// Prefer admin endpoint first in admin context
|
||||||
try {
|
try {
|
||||||
|
const resAdmin = await api.get<{ items: any[] }>('/admin/shortlinks');
|
||||||
|
return resAdmin.data;
|
||||||
|
} catch (_) {
|
||||||
const res = await api.get<{ items: any[] }>('/shortlinks');
|
const res = await api.get<{ items: any[] }>('/shortlinks');
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (e) {
|
|
||||||
// Fallback to admin endpoint (admins only)
|
|
||||||
const res2 = await api.get<{ items: any[] }>('/admin/shortlinks');
|
|
||||||
return res2.data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,12 +171,31 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
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-colorreset::before,
|
||||||
.ql-toolbar.ql-snow button.ql-bgreset::before {
|
.ql-toolbar.ql-snow button.ql-bgreset::before {
|
||||||
content: "×";
|
content: "";
|
||||||
font-size: 16px;
|
position: absolute;
|
||||||
line-height: 1;
|
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 */
|
/* Center icons and enlarge align icon */
|
||||||
@@ -265,6 +284,20 @@
|
|||||||
margin: 0.25em 0;
|
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 {
|
.ql-editor blockquote {
|
||||||
border-left: 4px solid #3182ce;
|
border-left: 4px solid #3182ce;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
@@ -425,7 +458,7 @@
|
|||||||
|
|
||||||
.ql-editor {
|
.ql-editor {
|
||||||
background-color: white !important;
|
background-color: white !important;
|
||||||
color: #2d3748 !important;
|
/* do not force color here; allow inline styles from the editor to apply */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive Adjustments */
|
/* Responsive Adjustments */
|
||||||
|
|||||||
@@ -26,5 +26,5 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
]
|
, "public/tinymce" ]
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-17
@@ -14,10 +14,10 @@ import (
|
|||||||
// Config holds all configuration for the application
|
// Config holds all configuration for the application
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// App settings
|
// App settings
|
||||||
AppEnv string
|
AppEnv string
|
||||||
Port string
|
Port string
|
||||||
Debug bool
|
Debug bool
|
||||||
Premium bool
|
Premium bool
|
||||||
|
|
||||||
// Database settings
|
// Database settings
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
@@ -68,14 +68,14 @@ type Config struct {
|
|||||||
AllowedOrigins []string
|
AllowedOrigins []string
|
||||||
|
|
||||||
// External services
|
// External services
|
||||||
ScraperBaseURL string
|
ScraperBaseURL string
|
||||||
FrontendBaseURL string
|
FrontendBaseURL string
|
||||||
PublicAPIBaseURL string
|
PublicAPIBaseURL string
|
||||||
|
|
||||||
// Umami Analytics
|
// Umami Analytics
|
||||||
UmamiURL string
|
UmamiURL string
|
||||||
UmamiUsername string
|
UmamiUsername string
|
||||||
UmamiPassword string
|
UmamiPassword string
|
||||||
UmamiWebsiteID string
|
UmamiWebsiteID string
|
||||||
|
|
||||||
ErrorIngestURL string
|
ErrorIngestURL string
|
||||||
@@ -85,6 +85,9 @@ type Config struct {
|
|||||||
ClamAVEnabled bool
|
ClamAVEnabled bool
|
||||||
ClamAVHost string
|
ClamAVHost string
|
||||||
ClamAVPort int
|
ClamAVPort int
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
RembgEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var AppConfig *Config
|
var AppConfig *Config
|
||||||
@@ -96,10 +99,10 @@ func LoadConfig() {
|
|||||||
|
|
||||||
AppConfig = &Config{
|
AppConfig = &Config{
|
||||||
// App settings
|
// App settings
|
||||||
AppEnv: getEnv("APP_ENV", "development"),
|
AppEnv: getEnv("APP_ENV", "development"),
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
Debug: getEnvAsBool("DEBUG", true),
|
Debug: getEnvAsBool("DEBUG", true),
|
||||||
Premium: getEnvAsBool("PREMIUM", false),
|
Premium: getEnvAsBool("PREMIUM", false),
|
||||||
|
|
||||||
// Database settings
|
// Database settings
|
||||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/fotbal_club?sslmode=disable"),
|
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/fotbal_club?sslmode=disable"),
|
||||||
@@ -131,11 +134,11 @@ func LoadConfig() {
|
|||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
// Documents
|
// Documents
|
||||||
"application/pdf",
|
"application/pdf",
|
||||||
"application/msword", // .doc
|
"application/msword", // .doc
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||||
"application/vnd.ms-excel", // .xls
|
"application/vnd.ms-excel", // .xls
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
|
||||||
"application/vnd.ms-powerpoint", // .ppt
|
"application/vnd.ms-powerpoint", // .ppt
|
||||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
|
||||||
// Text
|
// Text
|
||||||
"text/plain",
|
"text/plain",
|
||||||
@@ -192,6 +195,9 @@ func LoadConfig() {
|
|||||||
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
|
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
|
||||||
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
|
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
|
||||||
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
|
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
|
||||||
|
|
||||||
|
// Feature flags
|
||||||
|
RembgEnabled: getEnvAsBool("REMBG_ENABLED", true),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override allowed origins if specified in environment (comma-separated)
|
// Override allowed origins if specified in environment (comma-separated)
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"os"
|
|
||||||
|
"fotbal-club/pkg/httpclient"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -22,92 +24,123 @@ type AIController struct {
|
|||||||
|
|
||||||
// GenerateCSS creates scoped CSS for a page element
|
// GenerateCSS creates scoped CSS for a page element
|
||||||
func (ac *AIController) GenerateCSS(c *gin.Context) {
|
func (ac *AIController) GenerateCSS(c *gin.Context) {
|
||||||
var req aiCSSRequest
|
var req aiCSSRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
baseURL := getOpenRouterBaseURL()
|
baseURL := getOpenRouterBaseURL()
|
||||||
apiKey := getOpenRouterAPIKey()
|
apiKey := getOpenRouterAPIKey()
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model := getOpenRouterModel()
|
model := getOpenRouterModel()
|
||||||
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
|
if model == "" {
|
||||||
fallbackModel := getOpenRouterFallbackModel()
|
model = "mistralai/mistral-small-3.2-24b-instruct:free"
|
||||||
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
|
}
|
||||||
|
fallbackModel := getOpenRouterFallbackModel()
|
||||||
|
if fallbackModel == "" {
|
||||||
|
fallbackModel = "mistralai/mistral-nemo:free"
|
||||||
|
}
|
||||||
|
|
||||||
rootSelector := strings.TrimSpace(req.RootSelector)
|
rootSelector := strings.TrimSpace(req.RootSelector)
|
||||||
if rootSelector == "" {
|
if rootSelector == "" {
|
||||||
en := strings.TrimSpace(req.ElementName)
|
en := strings.TrimSpace(req.ElementName)
|
||||||
if en == "" { en = "element" }
|
if en == "" {
|
||||||
rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en)
|
en = "element"
|
||||||
}
|
}
|
||||||
|
rootSelector = fmt.Sprintf("[data-element=\"%s\"]", en)
|
||||||
|
}
|
||||||
|
|
||||||
themeJSON, _ := json.Marshal(req.Theme)
|
themeJSON, _ := json.Marshal(req.Theme)
|
||||||
stylesJSON, _ := json.Marshal(req.CurrentStyles)
|
stylesJSON, _ := json.Marshal(req.CurrentStyles)
|
||||||
|
|
||||||
system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině."
|
system := "Jsi zkušený CSS návrhář pro klubové weby. Piš čistý, přístupný a responzivní CSS. VÝSTUP POUZE JSON: {\"css\":\"...\"}. Nepoužívej reset, neovlivňuj globální prvky. CSS MUSÍ být scope-nuté POUZE pod kořenový selektor, žádný selektor mimo. Používej CSS proměnné (např. --club-primary, --club-secondary). Čeština není nutná v kódu, ale požadavky jsou v češtině."
|
||||||
user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.",
|
user := fmt.Sprintf("Požadavek: %s\nKořenový selektor: %s\nAktuální CSS (může být prázdné):\n---\n%s\n---\nAktuální styly (JSON): %s\nTéma (JSON): %s\nBreakpoints: %v\nPožadavky: 1) Scope pouze pod kořenový selektor. 2) Žádné !important. 3) Media queries pro mobil/tablet/desktop dle potřeby. 4) Zaměř se na vzhled prvků uvnitř bloku. 5) Nepřidávej inline styly ani globální sel. 6) Používej proměnné, zachovej kontrast a čitelnost.",
|
||||||
strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints)
|
strings.TrimSpace(req.Prompt), rootSelector, strings.TrimSpace(req.CurrentCSS), string(stylesJSON), string(themeJSON), req.Breakpoints)
|
||||||
|
|
||||||
callModel := func(modelName string) (string, int, error) {
|
callModel := func(modelName string) (string, int, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"model": modelName,
|
"model": modelName,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
},
|
},
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
"max_tokens": 1200,
|
"max_tokens": 1200,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
||||||
if err != nil { return "", http.StatusInternalServerError, err }
|
if err != nil {
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
return "", http.StatusInternalServerError, err
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
}
|
||||||
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
|
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
client := &http.Client{Timeout: 45 * time.Second}
|
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
|
||||||
resp, err := client.Do(reqHTTP)
|
reqHTTP.Header.Set("HTTP-Referer", ref)
|
||||||
if err != nil { return "", http.StatusBadGateway, err }
|
}
|
||||||
defer resp.Body.Close()
|
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
reqHTTP.Header.Set("X-Title", ttl)
|
||||||
var e map[string]interface{}
|
}
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
client := httpclient.SlowClient()
|
||||||
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
|
resp, err := client.Do(reqHTTP)
|
||||||
}
|
if err != nil {
|
||||||
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
|
return "", http.StatusBadGateway, err
|
||||||
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") }
|
defer resp.Body.Close()
|
||||||
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
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")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
content, _, err := callModel(model)
|
content, _, err := callModel(model)
|
||||||
if err != nil || strings.TrimSpace(content) == "" {
|
if err != nil || strings.TrimSpace(content) == "" {
|
||||||
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
||||||
content = fbContent
|
content = fbContent
|
||||||
} else {
|
} else {
|
||||||
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
|
if err != nil {
|
||||||
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 selhal (včetně fallbacku)", "details": err.Error()})
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sanitized := sanitizeAIResponse(content)
|
sanitized := sanitizeAIResponse(content)
|
||||||
var out aiCSSResponse
|
var out aiCSSResponse
|
||||||
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
||||||
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
||||||
if m := re.FindString(sanitized); m != "" {
|
if m := re.FindString(sanitized); m != "" {
|
||||||
_ = json.Unmarshal([]byte(m), &out)
|
_ = json.Unmarshal([]byte(m), &out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(out.CSS) == "" {
|
if strings.TrimSpace(out.CSS) == "" {
|
||||||
out.CSS = fmt.Sprintf("%s { }", rootSelector)
|
out.CSS = fmt.Sprintf("%s { }", rootSelector)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateAboutPage creates about page content using the OpenRouter API
|
// GenerateAboutPage creates about page content using the OpenRouter API
|
||||||
@@ -158,7 +191,7 @@ func (ac *AIController) GenerateAboutPage(c *gin.Context) {
|
|||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
},
|
},
|
||||||
"temperature": 0.5,
|
"temperature": 0.5,
|
||||||
"max_tokens": 2200,
|
"max_tokens": 2200,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
@@ -270,10 +303,10 @@ type aiBlogResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type aiAboutRequest struct {
|
type aiAboutRequest struct {
|
||||||
Prompt string `json:"prompt" binding:"required"`
|
Prompt string `json:"prompt" binding:"required"`
|
||||||
ClubName string `json:"club_name"`
|
ClubName string `json:"club_name"`
|
||||||
Style string `json:"style"`
|
Style string `json:"style"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiAboutResponse struct {
|
type aiAboutResponse struct {
|
||||||
@@ -285,165 +318,231 @@ type aiAboutResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type aiCSSRequest struct {
|
type aiCSSRequest struct {
|
||||||
Prompt string `json:"prompt" binding:"required"`
|
Prompt string `json:"prompt" binding:"required"`
|
||||||
ElementName string `json:"element_name"`
|
ElementName string `json:"element_name"`
|
||||||
RootSelector string `json:"root_selector"`
|
RootSelector string `json:"root_selector"`
|
||||||
CurrentCSS string `json:"current_css"`
|
CurrentCSS string `json:"current_css"`
|
||||||
CurrentStyles map[string]interface{} `json:"current_styles"`
|
CurrentStyles map[string]interface{} `json:"current_styles"`
|
||||||
Theme map[string]string `json:"theme"`
|
Theme map[string]string `json:"theme"`
|
||||||
Breakpoints []int `json:"breakpoints"`
|
Breakpoints []int `json:"breakpoints"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiCSSResponse struct {
|
type aiCSSResponse struct {
|
||||||
CSS string `json:"css"`
|
CSS string `json:"css"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instagram caption generation
|
// Instagram caption generation
|
||||||
type aiInstaMatch struct {
|
type aiInstaMatch struct {
|
||||||
Home string `json:"home"`
|
Home string `json:"home"`
|
||||||
Away string `json:"away"`
|
Away string `json:"away"`
|
||||||
Competition string `json:"competition"`
|
Competition string `json:"competition"`
|
||||||
DateTime string `json:"date_time"`
|
DateTime string `json:"date_time"`
|
||||||
Venue string `json:"venue"`
|
Venue string `json:"venue"`
|
||||||
Score string `json:"score"`
|
Score string `json:"score"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiInstagramRequest struct {
|
type aiInstagramRequest struct {
|
||||||
Type string `json:"type"` // "article" | "event" | "generic"
|
Type string `json:"type"` // "article" | "event" | "generic"
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Content string `json:"content"` // plain text, HTML will be ignored
|
Content string `json:"content"` // plain text, HTML will be ignored
|
||||||
ClubName string `json:"club_name"`
|
ClubName string `json:"club_name"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Hashtags []string `json:"hashtags"`
|
Hashtags []string `json:"hashtags"`
|
||||||
Audience string `json:"audience"`
|
Audience string `json:"audience"`
|
||||||
Tone string `json:"tone"`
|
Tone string `json:"tone"`
|
||||||
Match *aiInstaMatch `json:"match"`
|
Category string `json:"category"`
|
||||||
|
Match *aiInstaMatch `json:"match"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type aiInstagramResponse struct {
|
type aiInstagramResponse struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateInstagram creates an Instagram caption in Czech using OpenRouter
|
// GenerateInstagram creates an Instagram caption in Czech using OpenRouter
|
||||||
func (ac *AIController) GenerateInstagram(c *gin.Context) {
|
func (ac *AIController) GenerateInstagram(c *gin.Context) {
|
||||||
var req aiInstagramRequest
|
var req aiInstagramRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Normalize
|
// Normalize
|
||||||
t := strings.ToLower(strings.TrimSpace(req.Type))
|
t := strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
if t == "" { t = "article" }
|
if t == "" {
|
||||||
club := strings.TrimSpace(req.ClubName)
|
t = "article"
|
||||||
if club == "" { club = "Náš klub" }
|
}
|
||||||
audience := strings.TrimSpace(req.Audience)
|
club := strings.TrimSpace(req.ClubName)
|
||||||
if audience == "" { audience = "fanoušci klubu" }
|
if club == "" {
|
||||||
tone := strings.TrimSpace(req.Tone)
|
club = "Náš klub"
|
||||||
if tone == "" { tone = "informativní, přátelský" }
|
}
|
||||||
|
audience := strings.TrimSpace(req.Audience)
|
||||||
|
if audience == "" {
|
||||||
|
audience = "fanoušci klubu"
|
||||||
|
}
|
||||||
|
tone := strings.TrimSpace(req.Tone)
|
||||||
|
if tone == "" {
|
||||||
|
tone = "informativní, přátelský"
|
||||||
|
}
|
||||||
|
|
||||||
// Build system and user messages
|
// 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\": \"...\"}."
|
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
|
// Compose contextual notes
|
||||||
var notes []string
|
var notes []string
|
||||||
if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) }
|
if req.Title != "" {
|
||||||
if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) }
|
notes = append(notes, "Titulek: "+req.Title)
|
||||||
if req.Match != nil {
|
}
|
||||||
m := req.Match
|
if strings.TrimSpace(req.Content) != "" {
|
||||||
line := []string{}
|
notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content))
|
||||||
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(req.Category) != "" {
|
||||||
if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) }
|
notes = append(notes, "Kategorie: "+strings.TrimSpace(req.Category))
|
||||||
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 req.Match != nil {
|
||||||
if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) }
|
m := req.Match
|
||||||
}
|
line := []string{}
|
||||||
if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) }
|
if m.Home != "" || m.Away != "" {
|
||||||
if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) }
|
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, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
// Hard requirements
|
// Hard requirements
|
||||||
requirements := []string{
|
requirements := []string{
|
||||||
"Délka 80–140 slov, rozdělit do 2–3 krátkých odstavců.",
|
"Délka 50–90 slov. Max. 2 krátké odstavce, max. 2 věty v odstavci.",
|
||||||
"Použij maximálně 6 emotikonů (žádné dlouhé řetězy).",
|
"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.",
|
||||||
"Přidej 4–6 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
|
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem (jediný odkaz).",
|
||||||
"Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.",
|
"Přidej 4–6 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
|
||||||
fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience),
|
"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),
|
||||||
|
}
|
||||||
|
|
||||||
// Build user prompt
|
// Build user prompt
|
||||||
user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- "))
|
user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- "))
|
||||||
|
|
||||||
baseURL := getOpenRouterBaseURL()
|
baseURL := getOpenRouterBaseURL()
|
||||||
apiKey := getOpenRouterAPIKey()
|
apiKey := getOpenRouterAPIKey()
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
model := getOpenRouterModel()
|
model := getOpenRouterModel()
|
||||||
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
|
if model == "" {
|
||||||
fallbackModel := getOpenRouterFallbackModel()
|
model = "mistralai/mistral-small-3.2-24b-instruct:free"
|
||||||
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
|
}
|
||||||
|
fallbackModel := getOpenRouterFallbackModel()
|
||||||
|
if fallbackModel == "" {
|
||||||
|
fallbackModel = "mistralai/mistral-nemo:free"
|
||||||
|
}
|
||||||
|
|
||||||
callModel := func(modelName string) (string, int, error) {
|
callModel := func(modelName string) (string, int, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"model": modelName,
|
"model": modelName,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
},
|
},
|
||||||
"temperature": 0.5,
|
"temperature": 0.5,
|
||||||
"max_tokens": 800,
|
"max_tokens": 800,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
||||||
if err != nil { return "", http.StatusInternalServerError, err }
|
if err != nil {
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
return "", http.StatusInternalServerError, err
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
}
|
||||||
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
|
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
client := &http.Client{Timeout: 45 * time.Second}
|
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
|
||||||
resp, err := client.Do(reqHTTP)
|
reqHTTP.Header.Set("HTTP-Referer", ref)
|
||||||
if err != nil { return "", http.StatusBadGateway, err }
|
}
|
||||||
defer resp.Body.Close()
|
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
reqHTTP.Header.Set("X-Title", ttl)
|
||||||
var e map[string]interface{}
|
}
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
|
resp, err := client.Do(reqHTTP)
|
||||||
}
|
if err != nil {
|
||||||
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
|
return "", http.StatusBadGateway, err
|
||||||
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") }
|
defer resp.Body.Close()
|
||||||
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
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")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
||||||
|
}
|
||||||
|
|
||||||
content, _, err := callModel(model)
|
content, _, err := callModel(model)
|
||||||
if err != nil || strings.TrimSpace(content) == "" {
|
if err != nil || strings.TrimSpace(content) == "" {
|
||||||
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
||||||
content = fbContent
|
content = fbContent
|
||||||
} else {
|
} else {
|
||||||
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
|
if err != nil {
|
||||||
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 selhal (včetně fallbacku)", "details": err.Error()})
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sanitized := sanitizeAIResponse(content)
|
sanitized := sanitizeAIResponse(content)
|
||||||
var out aiInstagramResponse
|
var out aiInstagramResponse
|
||||||
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
||||||
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
||||||
if m := re.FindString(sanitized); m != "" {
|
if m := re.FindString(sanitized); m != "" {
|
||||||
_ = json.Unmarshal([]byte(m), &out)
|
_ = json.Unmarshal([]byte(m), &out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(out.Text) == "" {
|
if strings.TrimSpace(out.Text) == "" {
|
||||||
// minimal fallback
|
// minimal fallback
|
||||||
txt := req.Title
|
txt := req.Title
|
||||||
if txt == "" { txt = "Novinky z klubu" }
|
if txt == "" {
|
||||||
out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link))
|
txt = "Novinky z klubu"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, out)
|
out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link))
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
|
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
|
||||||
@@ -457,167 +556,173 @@ func (ac *AIController) GenerateBlog(c *gin.Context) {
|
|||||||
req.MinWords = 450
|
req.MinWords = 450
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build instruction in Czech - emphasizing user text as primary source, but allow expansion if needed
|
// 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ů, 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ů."
|
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.\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))
|
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 (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 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
|
// Prepare OpenRouter request
|
||||||
baseURL := getOpenRouterBaseURL()
|
baseURL := getOpenRouterBaseURL()
|
||||||
apiKey := getOpenRouterAPIKey()
|
apiKey := getOpenRouterAPIKey()
|
||||||
if strings.TrimSpace(apiKey) == "" {
|
if strings.TrimSpace(apiKey) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary and fallback models
|
// Primary and fallback models
|
||||||
model := getOpenRouterModel()
|
model := getOpenRouterModel()
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "mistralai/mistral-small-3.2-24b-instruct:free"
|
model = "mistralai/mistral-small-3.2-24b-instruct:free"
|
||||||
}
|
}
|
||||||
fallbackModel := getOpenRouterFallbackModel()
|
fallbackModel := getOpenRouterFallbackModel()
|
||||||
if fallbackModel == "" {
|
if fallbackModel == "" {
|
||||||
fallbackModel = "mistralai/mistral-nemo:free"
|
fallbackModel = "mistralai/mistral-nemo:free"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to call OpenRouter with a given model and return content
|
// Helper to call OpenRouter with a given model and return content
|
||||||
callModel := func(modelName string) (string, int, error) {
|
callModel := func(modelName string) (string, int, error) {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"model": modelName,
|
"model": modelName,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
},
|
},
|
||||||
"temperature": 0.5,
|
"temperature": 0.5,
|
||||||
"max_tokens": 2000,
|
"max_tokens": 2000,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
|
||||||
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", http.StatusInternalServerError, err
|
return "", http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
reqHTTP.Header.Set("Content-Type", "application/json")
|
reqHTTP.Header.Set("Content-Type", "application/json")
|
||||||
// Optional but recommended headers for OpenRouter
|
// Optional but recommended headers for OpenRouter
|
||||||
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
|
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
|
||||||
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
|
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}
|
client := &http.Client{Timeout: 45 * time.Second}
|
||||||
resp, err := client.Do(reqHTTP)
|
resp, err := client.Do(reqHTTP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", http.StatusBadGateway, err
|
return "", http.StatusBadGateway, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
var e map[string]interface{}
|
var e map[string]interface{}
|
||||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||||
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
|
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI-compatible response
|
// OpenAI-compatible response
|
||||||
var or struct {
|
var or struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
} `json:"choices"`
|
} `json:"choices"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
|
||||||
return "", http.StatusBadGateway, err
|
return "", http.StatusBadGateway, err
|
||||||
}
|
}
|
||||||
if len(or.Choices) == 0 {
|
if len(or.Choices) == 0 {
|
||||||
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
|
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try primary, then fallback
|
// Try primary, then fallback
|
||||||
content, _, err := callModel(model)
|
content, _, err := callModel(model)
|
||||||
if err != nil || strings.TrimSpace(content) == "" {
|
if err != nil || strings.TrimSpace(content) == "" {
|
||||||
// Attempt fallback model
|
// Attempt fallback model
|
||||||
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
|
||||||
content = fbContent
|
content = fbContent
|
||||||
} else {
|
} else {
|
||||||
// Provide the primary error if available
|
// Provide the primary error if available
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
|
||||||
} else if fbErr != nil {
|
} else if fbErr != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
|
||||||
} else {
|
} else {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize and parse JSON returned by the model
|
// Sanitize and parse JSON returned by the model
|
||||||
var out aiBlogResponse
|
var out aiBlogResponse
|
||||||
|
|
||||||
// Clean up the response: remove markdown code blocks, backticks, etc.
|
// Clean up the response: remove markdown code blocks, backticks, etc.
|
||||||
sanitized := sanitizeAIResponse(content)
|
sanitized := sanitizeAIResponse(content)
|
||||||
|
|
||||||
// Try to parse the sanitized content
|
// Try to parse the sanitized content
|
||||||
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
|
||||||
// Best-effort: try to find JSON block
|
// Best-effort: try to find JSON block
|
||||||
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
|
||||||
if m := re.FindString(sanitized); m != "" {
|
if m := re.FindString(sanitized); m != "" {
|
||||||
_ = json.Unmarshal([]byte(m), &out)
|
_ = json.Unmarshal([]byte(m), &out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode HTML entities in the html field
|
// Decode HTML entities in the html field
|
||||||
if out.HTML != "" {
|
if out.HTML != "" {
|
||||||
out.HTML = html.UnescapeString(out.HTML)
|
out.HTML = html.UnescapeString(out.HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallbacks if the model did not provide title/slug
|
// Fallbacks if the model did not provide title/slug
|
||||||
if out.Title == "" {
|
if out.Title == "" {
|
||||||
out.Title = deriveTitle(req.Prompt)
|
out.Title = deriveTitle(req.Prompt)
|
||||||
}
|
}
|
||||||
// Validate slug: short, independent from title. If not valid, derive from prompt.
|
// Validate slug: short, independent from title. If not valid, derive from prompt.
|
||||||
if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) {
|
if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) {
|
||||||
out.Slug = shortSlugFromPrompt(req.Prompt)
|
out.Slug = shortSlugFromPrompt(req.Prompt)
|
||||||
}
|
}
|
||||||
if out.HTML == "" {
|
if out.HTML == "" {
|
||||||
// Wrap raw content as paragraph fallback
|
// Wrap raw content as paragraph fallback
|
||||||
out.HTML = "<h1>" + htmlEscape(out.Title) + "</h1><p>" + htmlEscape(content) + "</p>"
|
out.HTML = "<h1>" + htmlEscape(out.Title) + "</h1><p>" + htmlEscape(content) + "</p>"
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, out)
|
c.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers for OpenRouter config
|
// Helpers for OpenRouter config
|
||||||
func getOpenRouterAPIKey() string {
|
func getOpenRouterAPIKey() string {
|
||||||
if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("OPENROUTER_API_KEY")), "\"")); v != "" {
|
if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("OPENROUTER_API_KEY")), "\"")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOpenRouterBaseURL() string {
|
func getOpenRouterBaseURL() string {
|
||||||
if v := strings.TrimSpace(getenv("OPENROUTER_BASE_URL")); v != "" {
|
if v := strings.TrimSpace(getenv("OPENROUTER_BASE_URL")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return "https://openrouter.ai/api/v1"
|
return "https://openrouter.ai/api/v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOpenRouterModel() string {
|
func getOpenRouterModel() string {
|
||||||
if v := strings.TrimSpace(getenv("OPENROUTER_MODEL")); v != "" {
|
if v := strings.TrimSpace(getenv("OPENROUTER_MODEL")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func getOpenRouterFallbackModel() string {
|
func getOpenRouterFallbackModel() string {
|
||||||
if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL")); v != "" {
|
if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL")); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small utility wrappers to avoid importing os directly multiple times
|
// 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
|
// deriveTitle returns a readable title from user prompt
|
||||||
func deriveTitle(s string) string {
|
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
|
// isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset
|
||||||
func isValidShortSlug(s string) bool {
|
func isValidShortSlug(s string) bool {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" { return false }
|
if s == "" {
|
||||||
if len(s) > 40 { return false }
|
return false
|
||||||
|
}
|
||||||
|
if len(s) > 40 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
parts := strings.Split(s, "-")
|
parts := strings.Split(s, "-")
|
||||||
// filter empty parts
|
// filter empty parts
|
||||||
w := 0
|
w := 0
|
||||||
for _, p := range parts { if p != "" { w++ } }
|
for _, p := range parts {
|
||||||
if w < 3 || w > 5 { return false }
|
if p != "" {
|
||||||
|
w++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w < 3 || w > 5 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
// allowed chars: a-z0-9-
|
// allowed chars: a-z0-9-
|
||||||
re := regexp.MustCompile(`^[a-z0-9-]+$`)
|
re := regexp.MustCompile(`^[a-z0-9-]+$`)
|
||||||
return re.MatchString(s)
|
return re.MatchString(s)
|
||||||
@@ -669,30 +784,48 @@ func isValidShortSlug(s string) bool {
|
|||||||
// shortSlugFromPrompt creates a compact, independent slug from the prompt text
|
// shortSlugFromPrompt creates a compact, independent slug from the prompt text
|
||||||
func shortSlugFromPrompt(prompt string) string {
|
func shortSlugFromPrompt(prompt string) string {
|
||||||
p := strings.ToLower(strings.TrimSpace(prompt))
|
p := strings.ToLower(strings.TrimSpace(prompt))
|
||||||
if p == "" { return "clanek" }
|
if p == "" {
|
||||||
|
return "clanek"
|
||||||
|
}
|
||||||
// basic diacritics removal via slugify, then split to words
|
// basic diacritics removal via slugify, then split to words
|
||||||
p = slugify(p)
|
p = slugify(p)
|
||||||
parts := strings.Split(p, "-")
|
parts := strings.Split(p, "-")
|
||||||
// simple Czech stopwords list (subset)
|
// 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
|
var kept []string
|
||||||
for _, w := range parts {
|
for _, w := range parts {
|
||||||
if w == "" { continue }
|
if w == "" {
|
||||||
if _, ok := stop[w]; ok { continue }
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := stop[w]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
kept = append(kept, w)
|
kept = append(kept, w)
|
||||||
if len(kept) >= 5 { break }
|
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
|
// prefer 3-5 words, trim to 4 if too many
|
||||||
if len(kept) > 5 { kept = kept[:5] }
|
if len(kept) > 5 {
|
||||||
if len(kept) >= 4 { kept = kept[:4] }
|
kept = kept[:5]
|
||||||
|
}
|
||||||
|
if len(kept) >= 4 {
|
||||||
|
kept = kept[:4]
|
||||||
|
}
|
||||||
s := strings.Join(kept, "-")
|
s := strings.Join(kept, "-")
|
||||||
if len(s) > 40 { s = s[:40] }
|
if len(s) > 40 {
|
||||||
|
s = s[:40]
|
||||||
|
}
|
||||||
s = strings.Trim(s, "-")
|
s = strings.Trim(s, "-")
|
||||||
if !isValidShortSlug(s) {
|
if !isValidShortSlug(s) {
|
||||||
// final fallback
|
// final fallback
|
||||||
s = slugify(deriveTitle(prompt))
|
s = slugify(deriveTitle(prompt))
|
||||||
if len(s) > 40 { s = s[:40] }
|
if len(s) > 40 {
|
||||||
|
s = s[:40]
|
||||||
|
}
|
||||||
s = strings.Trim(s, "-")
|
s = strings.Trim(s, "-")
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
|
"fotbal-club/internal/services"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -225,6 +226,10 @@ func getLogoBySearch(name string) string {
|
|||||||
best = payload.Results[0].LogoURL
|
best = payload.Results[0].LogoURL
|
||||||
}
|
}
|
||||||
if best != "" {
|
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
|
logoCache[key] = best
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
@@ -280,6 +285,9 @@ func getLogoBySearch(name string) string {
|
|||||||
best = partial
|
best = partial
|
||||||
}
|
}
|
||||||
if best != "" {
|
if best != "" {
|
||||||
|
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
logoCache[key] = best
|
logoCache[key] = best
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
@@ -292,11 +300,18 @@ func getLogo(teamName, teamID string) string {
|
|||||||
return placeholder
|
return placeholder
|
||||||
}
|
}
|
||||||
if logo := getLogoBySearch(teamName); logo != "" {
|
if logo := getLogoBySearch(teamName); logo != "" {
|
||||||
|
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
return logo
|
return logo
|
||||||
}
|
}
|
||||||
tid := strings.TrimSpace(teamID)
|
tid := strings.TrimSpace(teamID)
|
||||||
if tid != "" {
|
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
|
return placeholder
|
||||||
}
|
}
|
||||||
@@ -410,6 +425,10 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
img := a.Find("img").First()
|
img := a.Find("img").First()
|
||||||
logoURL, _ := img.Attr("src")
|
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())
|
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
|
||||||
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
|
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
|
||||||
clubType := "football"
|
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)})
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
|
||||||
return
|
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)
|
setCachedJSON(cacheKey, b)
|
||||||
c.Data(http.StatusOK, "application/json", 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)})
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
|
||||||
return
|
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)
|
setCachedJSON(cacheKey, b)
|
||||||
c.Data(http.StatusOK, "application/json", b)
|
c.Data(http.StatusOK, "application/json", b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func (nc *NavigationController) CreateNavigationItem(c *gin.Context) {
|
|||||||
if err := nc.DB.Create(&item).Error; err != nil {
|
if err := nc.DB.Create(&item).Error; err != nil {
|
||||||
// Log the actual error for debugging
|
// Log the actual error for debugging
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to create navigation item",
|
"error": "Failed to create navigation item",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -173,19 +173,29 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
updates := map[string]interface{}{}
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
if v, ok := raw["label"]; ok {
|
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 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 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 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 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 {
|
if v, ok := raw["page_id"]; ok {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
@@ -202,7 +212,9 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := raw["visible"]; ok {
|
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 {
|
if v, ok := raw["display_order"]; ok {
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
@@ -231,16 +243,24 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v, ok := raw["target"]; ok {
|
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 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 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 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 {
|
if len(updates) == 0 {
|
||||||
@@ -251,7 +271,7 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
|||||||
|
|
||||||
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
|
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"error": "Failed to update navigation item",
|
"error": "Failed to update navigation item",
|
||||||
"details": err.Error(),
|
"details": err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -524,17 +544,17 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
|
|||||||
// @Success 200 {object} map[string]interface{}
|
// @Success 200 {object} map[string]interface{}
|
||||||
// @Router /api/v1/admin/navigation/seed [post]
|
// @Router /api/v1/admin/navigation/seed [post]
|
||||||
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||||
// Check existing counts for frontend and admin separately
|
// Check existing counts for frontend and admin separately
|
||||||
var frontendCount int64
|
var frontendCount int64
|
||||||
var adminCount int64
|
var adminCount int64
|
||||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||||
|
|
||||||
// Default frontend navigation items
|
// Default frontend navigation items
|
||||||
frontendItems := []models.NavigationItem{
|
frontendItems := []models.NavigationItem{
|
||||||
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
|
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
|
||||||
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
|
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
|
||||||
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
|
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
|
||||||
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
|
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
|
||||||
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
|
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
|
||||||
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
|
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
|
||||||
@@ -546,127 +566,256 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
|||||||
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
|
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create items in a transaction with admin categories and children (seed missing parts only)
|
// Create items in a transaction with admin categories and children (seed missing parts only)
|
||||||
seededFrontend := false
|
seededFrontend := false
|
||||||
seededAdmin := false
|
seededAdmin := false
|
||||||
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
addedMissing := false
|
||||||
if frontendCount == 0 {
|
err := nc.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
for _, item := range frontendItems {
|
if frontendCount == 0 {
|
||||||
if err := tx.Create(&item).Error; err != nil {
|
for _, item := range frontendItems {
|
||||||
return err
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
}
|
return err
|
||||||
}
|
}
|
||||||
seededFrontend = true
|
}
|
||||||
}
|
seededFrontend = true
|
||||||
|
}
|
||||||
|
|
||||||
if adminCount == 0 {
|
if adminCount == 0 {
|
||||||
catOrder := 0
|
catOrder := 0
|
||||||
createCategory := func(label string) (*models.NavigationItem, error) {
|
createCategory := func(label string) (*models.NavigationItem, error) {
|
||||||
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
|
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
|
||||||
catOrder++
|
catOrder++
|
||||||
if err := tx.Create(cat).Error; err != nil {
|
if err := tx.Create(cat).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cat, nil
|
return cat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
|
||||||
pid := parent.ID
|
pid := parent.ID
|
||||||
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
|
||||||
child.ParentID = &pid
|
child.ParentID = &pid
|
||||||
return tx.Create(child).Error
|
return tx.Create(child).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
zakladni, err := createCategory("Základní")
|
zakladni, err := createCategory("Základní")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
|
return err
|
||||||
if err := createChild(zakladni, "Analytika", "analytics", 1); 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")
|
sport, err := createCategory("Sport")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
|
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, "Týmy", "teams", 0); err != nil {
|
||||||
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
|
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 := 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")
|
obsah, err := createCategory("Obsah")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
|
return err
|
||||||
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
|
}
|
||||||
// Kategorie admin page removed (categories derived from competition aliases)
|
if err := createChild(obsah, "Články", "articles", 0); err != nil {
|
||||||
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
|
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", 3); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
media, err := createCategory("Média")
|
media, err := createCategory("Média")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
|
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 := 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")
|
kom, err := createCategory("Komunikace")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
|
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 := 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")
|
marketing, err := createCategory("Marketing")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
|
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, "Sponzoři", "sponsors", 0); err != nil {
|
||||||
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
|
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, "Bannery", "banners", 1); err != nil {
|
||||||
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
|
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")
|
nastroje, err := createCategory("Nástroje")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
|
return err
|
||||||
|
}
|
||||||
|
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
nastaveni, err := createCategory("Nastavení")
|
nastaveni, err := createCategory("Nastavení")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
|
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 := 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")
|
napoveda, err := createCategory("Nápověda")
|
||||||
if err != nil { return err }
|
if err != nil {
|
||||||
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
|
return err
|
||||||
|
}
|
||||||
|
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
seededAdmin = true
|
seededAdmin = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since creation is split, compute counts again
|
// Also add missing admin "O klubu" item under "Obsah" when admin navigation exists but the item is missing
|
||||||
var total int64
|
if adminCount > 0 {
|
||||||
nc.DB.Model(&models.NavigationItem{}).Count(&total)
|
var aboutCount int64
|
||||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
// Check if an admin nav item with page_type 'about' exists
|
||||||
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
message := "Navigation items already exist"
|
// Since creation is split, compute counts again
|
||||||
if seededFrontend && seededAdmin {
|
var total int64
|
||||||
message = "Default frontend and admin navigation created successfully"
|
nc.DB.Model(&models.NavigationItem{}).Count(&total)
|
||||||
} else if seededFrontend {
|
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
|
||||||
message = "Default frontend navigation created successfully"
|
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
|
||||||
} else if seededAdmin {
|
|
||||||
message = "Default admin navigation created successfully"
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
message := "Navigation items already exist"
|
||||||
"message": message,
|
if seededFrontend && seededAdmin {
|
||||||
"count": total,
|
message = "Default frontend and admin navigation created successfully"
|
||||||
"frontend_count": frontendCount,
|
} else if seededFrontend {
|
||||||
"admin_count": adminCount,
|
message = "Default frontend navigation created successfully"
|
||||||
"seeded": seededFrontend || seededAdmin,
|
} else if seededAdmin {
|
||||||
"seeded_frontend": seededFrontend,
|
message = "Default admin navigation created successfully"
|
||||||
"seeded_admin": seededAdmin,
|
}
|
||||||
})
|
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 || addedMissing),
|
||||||
|
"seeded_frontend": seededFrontend,
|
||||||
|
"seeded_admin": seededAdmin,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -1,296 +1,354 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/png"
|
_ "image/gif"
|
||||||
_ "image/gif"
|
_ "image/jpeg"
|
||||||
_ "image/jpeg"
|
"image/png"
|
||||||
"mime/multipart"
|
"io"
|
||||||
"io"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fotbal-club/internal/config"
|
"fotbal-club/internal/config"
|
||||||
"fotbal-club/internal/models"
|
"fotbal-club/internal/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func uploadsBaseDir() string {
|
func uploadsBaseDir() string {
|
||||||
dir := config.AppConfig.UploadDir
|
dir := config.AppConfig.UploadDir
|
||||||
if strings.TrimSpace(dir) == "" {
|
if strings.TrimSpace(dir) == "" {
|
||||||
dir = "./uploads"
|
dir = "./uploads"
|
||||||
}
|
}
|
||||||
return dir
|
return dir
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
|
||||||
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
func sanitizeAndWriteLogo(data []byte, outPath string) error {
|
||||||
img, _, err := image.Decode(bytes.NewReader(data))
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
b := img.Bounds()
|
b := img.Bounds()
|
||||||
minX, minY := b.Max.X, b.Max.Y
|
minX, minY := b.Max.X, b.Max.Y
|
||||||
maxX, maxY := b.Min.X, b.Min.Y
|
maxX, maxY := b.Min.X, b.Min.Y
|
||||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||||
for x := b.Min.X; x < b.Max.X; x++ {
|
for x := b.Min.X; x < b.Max.X; x++ {
|
||||||
r, g, bl, a := img.At(x, y).RGBA()
|
r, g, bl, a := img.At(x, y).RGBA()
|
||||||
if a <= 0x10 { // near transparent
|
if a <= 0x10 { // near transparent
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
|
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
|
||||||
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
|
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if x < minX { minX = x }
|
if x < minX {
|
||||||
if y < minY { minY = y }
|
minX = x
|
||||||
if x > maxX { maxX = x }
|
}
|
||||||
if y > maxY { maxY = y }
|
if y < minY {
|
||||||
}
|
minY = y
|
||||||
}
|
}
|
||||||
if minX >= maxX || minY >= maxY {
|
if x > maxX {
|
||||||
// fallback to full image
|
maxX = x
|
||||||
minX, minY = b.Min.X, b.Min.Y
|
}
|
||||||
maxX, maxY = b.Max.X-1, b.Max.Y-1
|
if y > maxY {
|
||||||
}
|
maxY = y
|
||||||
cw, ch := maxX-minX+1, maxY-minY+1
|
}
|
||||||
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
|
}
|
||||||
for y := 0; y < ch; y++ {
|
}
|
||||||
for x := 0; x < cw; x++ {
|
if minX >= maxX || minY >= maxY {
|
||||||
nrgba.Set(x, y, img.At(minX+x, minY+y))
|
// fallback to full image
|
||||||
}
|
minX, minY = b.Min.X, b.Min.Y
|
||||||
}
|
maxX, maxY = b.Max.X-1, b.Max.Y-1
|
||||||
// resize to 64px height using nearest-neighbor
|
}
|
||||||
targetH := 64
|
cw, ch := maxX-minX+1, maxY-minY+1
|
||||||
if ch != targetH {
|
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
|
||||||
targetW := int(float64(cw) * float64(targetH) / float64(ch))
|
for y := 0; y < ch; y++ {
|
||||||
if targetW < 1 { targetW = 1 }
|
for x := 0; x < cw; x++ {
|
||||||
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
|
nrgba.Set(x, y, img.At(minX+x, minY+y))
|
||||||
for y2 := 0; y2 < targetH; y2++ {
|
}
|
||||||
srcY := y2 * ch / targetH
|
}
|
||||||
for x2 := 0; x2 < targetW; x2++ {
|
// resize to 64px height using nearest-neighbor
|
||||||
srcX := x2 * cw / targetW
|
targetH := 64
|
||||||
c := nrgba.NRGBAAt(srcX, srcY)
|
if ch != targetH {
|
||||||
resized.SetNRGBA(x2, y2, c)
|
targetW := int(float64(cw) * float64(targetH) / float64(ch))
|
||||||
}
|
if targetW < 1 {
|
||||||
}
|
targetW = 1
|
||||||
nrgba = resized
|
}
|
||||||
}
|
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
|
||||||
// write PNG
|
for y2 := 0; y2 < targetH; y2++ {
|
||||||
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err }
|
srcY := y2 * ch / targetH
|
||||||
f, err := os.Create(outPath)
|
for x2 := 0; x2 < targetW; x2++ {
|
||||||
if err != nil {
|
srcX := x2 * cw / targetW
|
||||||
return err
|
c := nrgba.NRGBAAt(srcX, srcY)
|
||||||
}
|
resized.SetNRGBA(x2, y2, c)
|
||||||
defer f.Close()
|
}
|
||||||
return png.Encode(f, nrgba)
|
}
|
||||||
|
nrgba = resized
|
||||||
|
}
|
||||||
|
// write PNG
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Create(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return png.Encode(f, nrgba)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc.
|
// ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc.
|
||||||
func ensureUniqueFilename(dir, name string) string {
|
func ensureUniqueFilename(dir, name string) string {
|
||||||
base := name
|
base := name
|
||||||
ext := ""
|
ext := ""
|
||||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||||
base = name[:i]
|
base = name[:i]
|
||||||
ext = name[i:]
|
ext = name[i:]
|
||||||
}
|
}
|
||||||
try := name
|
try := name
|
||||||
idx := 1
|
idx := 1
|
||||||
for {
|
for {
|
||||||
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
|
||||||
return try
|
return try
|
||||||
}
|
}
|
||||||
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
|
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
|
||||||
idx++
|
idx++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
|
||||||
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
|
||||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||||
entries, err := os.ReadDir(sponsorDir)
|
entries, err := os.ReadDir(sponsorDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusOK, []string{})
|
ctx.JSON(http.StatusOK, []string{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(entries))
|
out := make([]string, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() { continue }
|
if e.IsDir() {
|
||||||
name := e.Name()
|
continue
|
||||||
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") {
|
name := e.Name()
|
||||||
out = append(out, "/uploads/sponsors/"+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") {
|
||||||
}
|
out = append(out, "/uploads/sponsors/"+name)
|
||||||
ctx.JSON(http.StatusOK, out)
|
}
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadSponsors accepts multipart form files under field name "files" (or single "file")
|
// UploadSponsors accepts multipart form files under field name "files" (or single "file")
|
||||||
func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
|
||||||
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
|
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||||
|
|
||||||
saved := 0
|
saved := 0
|
||||||
created := make([]string, 0, 8)
|
created := make([]string, 0, 8)
|
||||||
if ctx.Request.MultipartForm != nil {
|
if ctx.Request.MultipartForm != nil {
|
||||||
files := ctx.Request.MultipartForm.File["files"]
|
files := ctx.Request.MultipartForm.File["files"]
|
||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
|
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
files = []*multipart.FileHeader{hdr}
|
files = []*multipart.FileHeader{hdr}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, hdr := range files {
|
for _, hdr := range files {
|
||||||
if hdr == nil { continue }
|
if hdr == nil {
|
||||||
src, err := hdr.Open()
|
continue
|
||||||
if err != nil { continue }
|
}
|
||||||
// do not defer: loop
|
src, err := hdr.Open()
|
||||||
name := sanitizeFilename(hdr.Filename)
|
if err != nil {
|
||||||
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
continue
|
||||||
base := name
|
}
|
||||||
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
|
// do not defer: loop
|
||||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
name := sanitizeFilename(hdr.Filename)
|
||||||
outPath := filepath.Join(sponsorDir, outName)
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
base := name
|
||||||
|
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||||
|
base = name[:i]
|
||||||
|
}
|
||||||
|
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||||
|
outPath := filepath.Join(sponsorDir, outName)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if _, err := io.Copy(&buf, src); err == nil {
|
if _, err := io.Copy(&buf, src); err == nil {
|
||||||
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
|
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
|
||||||
saved++
|
saved++
|
||||||
created = append(created, "/uploads/sponsors/"+outName)
|
created = append(created, "/uploads/sponsors/"+outName)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: write original bytes with original extension
|
// Fallback: write original bytes with original extension
|
||||||
rawName := ensureUniqueFilename(sponsorDir, name)
|
rawName := ensureUniqueFilename(sponsorDir, name)
|
||||||
rawPath := filepath.Join(sponsorDir, rawName)
|
rawPath := filepath.Join(sponsorDir, rawName)
|
||||||
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
|
||||||
saved++
|
saved++
|
||||||
created = append(created, "/uploads/sponsors/"+rawName)
|
created = append(created, "/uploads/sponsors/"+rawName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = src.Close()
|
_ = src.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
|
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteSponsor deletes a sponsor logo by filename (?name=)
|
// DeleteSponsor deletes a sponsor logo by filename (?name=)
|
||||||
func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
|
||||||
name := sanitizeFilename(ctx.Query("name"))
|
name := sanitizeFilename(ctx.Query("name"))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
|
||||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.Remove(p); err != nil {
|
if err := os.Remove(p); err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
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
|
// GetQR returns the current QR image URL if present
|
||||||
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
|
||||||
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
path := filepath.Join(uploadsBaseDir(), "qr.png")
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
|
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadQR accepts a single file and stores/overwrites uploads/qr.png
|
// UploadQR accepts a single file and stores/overwrites uploads/qr.png
|
||||||
func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
|
||||||
file, _, err := ctx.Request.FormFile("file")
|
file, _, err := ctx.Request.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
dir := uploadsBaseDir()
|
dir := uploadsBaseDir()
|
||||||
_ = os.MkdirAll(dir, 0o755)
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
out, err := os.Create(filepath.Join(dir, "qr.png"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
if _, err := io.Copy(out, file); err != nil {
|
if _, err := io.Copy(out, file); err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
ctx.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
|
// 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.
|
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
|
||||||
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
|
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
|
||||||
var body struct{ IDs []uint `json:"ids"` }
|
var body struct {
|
||||||
_ = ctx.ShouldBindJSON(&body)
|
IDs []uint `json:"ids"`
|
||||||
var list []models.Sponsor
|
}
|
||||||
q := c.DB.Model(&models.Sponsor{})
|
_ = ctx.ShouldBindJSON(&body)
|
||||||
if len(body.IDs) > 0 {
|
var list []models.Sponsor
|
||||||
q = q.Where("id IN ?", body.IDs)
|
q := c.DB.Model(&models.Sponsor{})
|
||||||
} else {
|
if len(body.IDs) > 0 {
|
||||||
q = q.Where("is_active = ?", true)
|
q = q.Where("id IN ?", body.IDs)
|
||||||
}
|
} else {
|
||||||
if err := q.Find(&list).Error; err != nil {
|
q = q.Where("is_active = ?", true)
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
|
}
|
||||||
return
|
if err := q.Find(&list).Error; err != nil {
|
||||||
}
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
|
||||||
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
return
|
||||||
_ = os.MkdirAll(sponsorDir, 0o755)
|
}
|
||||||
created := make([]string, 0, len(list))
|
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
|
||||||
for _, s := range list {
|
_ = os.MkdirAll(sponsorDir, 0o755)
|
||||||
logo := strings.TrimSpace(s.LogoURL)
|
created := make([]string, 0, len(list))
|
||||||
if logo == "" { continue }
|
for _, s := range list {
|
||||||
var data []byte
|
logo := strings.TrimSpace(s.LogoURL)
|
||||||
if strings.HasPrefix(logo, "/uploads/") {
|
if logo == "" {
|
||||||
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
|
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://") {
|
var data []byte
|
||||||
resp, err := http.Get(logo)
|
if strings.HasPrefix(logo, "/uploads/") {
|
||||||
if err != nil { continue }
|
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
|
||||||
func() {
|
if b, err := os.ReadFile(p); err == nil {
|
||||||
defer resp.Body.Close()
|
data = b
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
|
} else {
|
||||||
b, _ := io.ReadAll(resp.Body)
|
continue
|
||||||
if len(b) > 0 { data = b }
|
}
|
||||||
}()
|
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
|
||||||
if len(data) == 0 { continue }
|
resp, err := http.Get(logo)
|
||||||
} else {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
base := sanitizeFilename(s.Name)
|
func() {
|
||||||
if base == "" {
|
defer resp.Body.Close()
|
||||||
seg := logo
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
|
return
|
||||||
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
|
}
|
||||||
base = sanitizeFilename(seg)
|
b, _ := io.ReadAll(resp.Body)
|
||||||
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
|
if len(b) > 0 {
|
||||||
}
|
data = b
|
||||||
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
}
|
||||||
outPath := filepath.Join(sponsorDir, outName)
|
}()
|
||||||
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
|
if len(data) == 0 {
|
||||||
// fallback to raw write
|
continue
|
||||||
rawName := ensureUniqueFilename(sponsorDir, base+".png")
|
}
|
||||||
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
|
} else {
|
||||||
created = append(created, "/uploads/sponsors/"+rawName)
|
continue
|
||||||
} else {
|
}
|
||||||
created = append(created, "/uploads/sponsors/"+outName)
|
base := sanitizeFilename(s.Name)
|
||||||
}
|
if base == "" {
|
||||||
}
|
seg := logo
|
||||||
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||||
|
outPath := filepath.Join(sponsorDir, outName)
|
||||||
|
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
|
||||||
|
// fallback to raw write
|
||||||
|
rawName := ensureUniqueFilename(sponsorDir, base+".png")
|
||||||
|
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
|
||||||
|
created = append(created, "/uploads/sponsors/"+rawName)
|
||||||
|
} else {
|
||||||
|
created = append(created, "/uploads/sponsors/"+outName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -136,6 +136,23 @@ func codeFromHash(s string, n int) string {
|
|||||||
return string(out)
|
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 {
|
func getScheme(c *gin.Context) string {
|
||||||
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
|
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
|
||||||
return p
|
return p
|
||||||
@@ -256,18 +273,18 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
code := strings.TrimSpace(body.Code)
|
code := sanitizeCode(strings.TrimSpace(body.Code))
|
||||||
if code == "" {
|
if code == "" {
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
cnd, _ := randCode(7)
|
cnd, _ := randCode(7)
|
||||||
var cnt int64
|
var cnt int64
|
||||||
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
|
||||||
if cnt == 0 {
|
if cnt == 0 {
|
||||||
code = cnd
|
code = cnd
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if code == "" {
|
if code == "" {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ func ValidateContentType() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "/rembg/start") {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Require JSON for other API endpoints
|
// Require JSON for other API endpoints
|
||||||
if !strings.Contains(contentType, "application/json") {
|
if !strings.Contains(contentType, "application/json") {
|
||||||
c.JSON(http.StatusUnsupportedMediaType, gin.H{
|
c.JSON(http.StatusUnsupportedMediaType, gin.H{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user