добавил управление главными фото страницы

This commit is contained in:
Lobstervova
2026-04-14 10:34:00 +03:00
parent daa96f8480
commit 90f73bf7a4
12 changed files with 391 additions and 17 deletions

View File

@@ -51,7 +51,7 @@ public class SecurityConfig {
.addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION)
//.addFilterAfter(errorHandlingFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange(exchange -> exchange
.pathMatchers("/account/login","/error","/error/**", "/account/logout").permitAll()
.pathMatchers("/account/login","/error","/error/**", "/account/logout", "/api/admin/site-settings/public").permitAll()
.pathMatchers("/account/**", "/admin", "/api/admin/**").authenticated()
.anyExchange().permitAll()
)

View File

@@ -8,10 +8,12 @@ import com.example.dateplanner.dto.PageResponse;
import com.example.dateplanner.models.entities.AppUser;
import com.example.dateplanner.models.entities.Court;
import com.example.dateplanner.models.entities.EventApplication;
import com.example.dateplanner.models.entities.SiteSettings;
import com.example.dateplanner.models.entities.TennisClub;
import com.example.dateplanner.models.entities.TennisEvent;
import com.example.dateplanner.repositories.CourtRepository;
import com.example.dateplanner.repositories.EventApplicationRepository;
import com.example.dateplanner.repositories.SiteSettingsRepository;
import com.example.dateplanner.repositories.TennisClubRepository;
import com.example.dateplanner.repositories.TennisEventRepository;
import com.example.dateplanner.services.ApplicationService;
@@ -41,6 +43,7 @@ public class ApiAdminController {
private final EventApplicationRepository applicationRepository;
private final ApplicationService applicationService;
private final MinioService minioService;
private final SiteSettingsRepository siteSettingsRepository;
// ── Загрузка изображений ───────────────────────────────────────────────────
@@ -450,4 +453,49 @@ public class ApiAdminController {
dto.setCreatedAt(app.getCreatedAt());
return dto;
}
// ── Настройки сайта ───────────────────────────────────────────────────────
@GetMapping("/site-settings")
public Mono<SiteSettings> getSiteSettings(@AuthenticationPrincipal AppUser user) {
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.switchIfEmpty(Mono.defer(() -> {
SiteSettings settings = new SiteSettings();
settings.setHeroImageUrl(
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height");
settings.setBackgroundImageUrl(
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height");
return siteSettingsRepository.save(settings);
}));
}
@PutMapping("/site-settings")
public Mono<SiteSettings> updateSiteSettings(@RequestBody SiteSettings body,
@AuthenticationPrincipal AppUser user) {
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.switchIfEmpty(Mono.defer(() -> {
SiteSettings settings = new SiteSettings();
return siteSettingsRepository.save(settings);
}))
.flatMap(existing -> {
existing.setHeroImageUrl(body.getHeroImageUrl());
existing.setBackgroundImageUrl(body.getBackgroundImageUrl());
existing.setUpdatedAt(LocalDateTime.now());
return siteSettingsRepository.save(existing);
});
}
// Публичный endpoint для главной страницы
@GetMapping("/site-settings/public")
public Mono<SiteSettings> getPublicSiteSettings() {
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.switchIfEmpty(Mono.defer(() -> {
SiteSettings settings = new SiteSettings();
settings.setHeroImageUrl(
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height");
settings.setBackgroundImageUrl(
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height");
return siteSettingsRepository.save(settings);
}));
}
}

View File

@@ -1,6 +1,8 @@
package com.example.dateplanner.controllers.web;
import com.example.dateplanner.models.entities.AppUser;
import com.example.dateplanner.models.entities.SiteSettings;
import com.example.dateplanner.repositories.SiteSettingsRepository;
import com.example.dateplanner.services.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,15 +24,29 @@ import java.util.Map;
public class AccountController extends BaseWebController {
private final UserService userService;
private final SiteSettingsRepository siteSettingsRepository;
private static final String DEFAULT_BG_IMAGE =
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height";
@GetMapping("/login")
public Mono<Rendering> loginPage() {
Map<String, Object> model = new HashMap<>();
model.put("title", "Login");
model.put("index", "login");
return addAuthToModel(model).map(m ->
Rendering.view("template").model(m).build()
);
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.map(settings -> {
model.put("backgroundImageUrl",
settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_BG_IMAGE);
return model;
})
.switchIfEmpty(Mono.fromCallable(() -> {
model.put("backgroundImageUrl", DEFAULT_BG_IMAGE);
return model;
}))
.flatMap(this::addAuthToModel)
.map(m -> Rendering.view("template").model(m).build());
}
@GetMapping("/profile")

View File

@@ -1,5 +1,7 @@
package com.example.dateplanner.controllers.web;
import com.example.dateplanner.models.entities.SiteSettings;
import com.example.dateplanner.repositories.SiteSettingsRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
@@ -17,14 +19,30 @@ import java.util.UUID;
@RequiredArgsConstructor
public class HomeController extends BaseWebController {
private final SiteSettingsRepository siteSettingsRepository;
private static final String DEFAULT_HERO_IMAGE =
"https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height";
@GetMapping("/")
public Mono<Rendering> home() {
Map<String, Object> model = new HashMap<>();
model.put("title", "Home");
model.put("index", "home");
return addAuthToModel(model).map(m ->
Rendering.view("template").model(m).build()
);
return siteSettingsRepository.findFirstByOrderByUpdatedAtDesc()
.map(settings -> {
model.put("heroImageUrl", settings.getHeroImageUrl() != null ? settings.getHeroImageUrl() : DEFAULT_HERO_IMAGE);
model.put("backgroundImageUrl", settings.getBackgroundImageUrl() != null ? settings.getBackgroundImageUrl() : DEFAULT_HERO_IMAGE);
return model;
})
.switchIfEmpty(Mono.fromCallable(() -> {
model.put("heroImageUrl", DEFAULT_HERO_IMAGE);
model.put("backgroundImageUrl", DEFAULT_HERO_IMAGE);
return model;
}))
.flatMap(this::addAuthToModel)
.map(m -> Rendering.view("template").model(m).build());
}
@GetMapping("/clubs/{id}")

View File

@@ -0,0 +1,28 @@
package com.example.dateplanner.models.entities;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@NoArgsConstructor
@Table(name = "site_settings")
public class SiteSettings {
@Id
@Column("uuid")
private UUID uuid;
@Column("hero_image_url")
private String heroImageUrl;
@Column("background_image_url")
private String backgroundImageUrl;
@Column("updated_at")
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,11 @@
package com.example.dateplanner.repositories;
import com.example.dateplanner.models.entities.SiteSettings;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Mono;
import java.util.UUID;
public interface SiteSettingsRepository extends R2dbcRepository<SiteSettings, UUID> {
Mono<SiteSettings> findFirstByOrderByUpdatedAtDesc();
}

View File

@@ -0,0 +1,16 @@
-- Site settings for managing hero and background images
CREATE TABLE IF NOT EXISTS site_settings (
uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
hero_image_url TEXT,
background_image_url TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings with the previously hardcoded URLs
INSERT INTO site_settings (uuid, hero_image_url, background_image_url, updated_at)
VALUES (
gen_random_uuid(),
'https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height',
'https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height',
CURRENT_TIMESTAMP
) ON CONFLICT DO NOTHING;

View File

@@ -1,6 +1,7 @@
import { initAdminClubs } from './clubsSection.js';
import { initAdminEvents } from './eventsSection.js';
import { initApplicationsSection } from './applicationsSection.js';
import { initSiteImages } from './siteImagesSection.js';
function onStats(stats) {
if (stats.clubs != null) $('#stat-clubs').text(stats.clubs);
@@ -12,3 +13,4 @@ function onStats(stats) {
initAdminClubs($('#admin-clubs-container'), onStats);
initAdminEvents($('#admin-events-container'), onStats);
initApplicationsSection($('#admin-applications-container'));
initSiteImages();

View File

@@ -0,0 +1,201 @@
import { getCsrfToken } from '../../utils/helpers.js';
const API = '/api/admin';
async function uploadImage(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API}/upload/image`, {
method: 'POST',
headers: { 'X-XSRF-TOKEN': getCsrfToken() },
body: formData,
});
if (!res.ok) throw new Error('Ошибка загрузки изображения');
const data = await res.json();
return data.url;
}
function initImageUploader(zoneId) {
const zone = document.getElementById(zoneId);
if (!zone) return { getUrl: () => null, setUrl: () => {}, clear: () => {} };
let currentUrl = null;
zone.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
zone.classList.add('uploading');
zone.innerHTML = '<div class="img-uploader__loading"><div class="spinner-border text-light"></div></div>';
try {
currentUrl = await uploadImage(file);
const cdn = window.cdn || '';
zone.innerHTML = `<img src="${cdn}${currentUrl}" class="img-uploader__preview" alt="preview">
<button class="img-uploader__remove" type="button"><i class="bi bi-x-lg"></i></button>`;
zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => {
ev.stopPropagation();
currentUrl = null;
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
if (window.onSiteImagesChange) window.onSiteImagesChange();
});
if (window.onSiteImagesChange) window.onSiteImagesChange();
} catch (err) {
console.error(err);
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-exclamation-triangle text-danger"></i>
<span class="text-danger">Ошибка загрузки</span>
</div>`;
setTimeout(() => {
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
}, 2000);
} finally {
zone.classList.remove('uploading');
}
};
input.click();
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', async (e) => {
e.preventDefault();
zone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) return;
zone.classList.add('uploading');
zone.innerHTML = '<div class="img-uploader__loading"><div class="spinner-border text-light"></div></div>';
try {
currentUrl = await uploadImage(file);
const cdn = window.cdn || '';
zone.innerHTML = `<img src="${cdn}${currentUrl}" class="img-uploader__preview" alt="preview">
<button class="img-uploader__remove" type="button"><i class="bi bi-x-lg"></i></button>`;
zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => {
ev.stopPropagation();
currentUrl = null;
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
if (window.onSiteImagesChange) window.onSiteImagesChange();
});
if (window.onSiteImagesChange) window.onSiteImagesChange();
} catch (err) {
console.error(err);
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-exclamation-triangle text-danger"></i>
<span class="text-danger">Ошибка загрузки</span>
</div>`;
setTimeout(() => {
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
}, 2000);
} finally {
zone.classList.remove('uploading');
}
});
return {
getUrl: () => currentUrl,
setUrl: (url) => {
currentUrl = url;
if (url) {
const cdn = window.cdn || '';
zone.innerHTML = `<img src="${cdn}${url}" class="img-uploader__preview" alt="preview">
<button class="img-uploader__remove" type="button"><i class="bi bi-x-lg"></i></button>`;
zone.querySelector('.img-uploader__remove').addEventListener('click', (ev) => {
ev.stopPropagation();
currentUrl = null;
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
if (window.onSiteImagesChange) window.onSiteImagesChange();
});
}
},
clear: () => {
currentUrl = null;
zone.innerHTML = `<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>`;
}
};
}
export async function initSiteImages() {
const heroUploader = initImageUploader('hero-image-zone');
const bgUploader = initImageUploader('bg-image-zone');
const saveBtn = document.getElementById('save-site-images-btn');
if (!saveBtn) return;
window.onSiteImagesChange = () => {
saveBtn.disabled = false;
};
// Загрузка текущих настроек
try {
const res = await fetch(`${API}/site-settings`, {
headers: {
'Accept': 'application/json',
'X-XSRF-TOKEN': getCsrfToken()
}
});
if (res.ok) {
const settings = await res.json();
if (settings.heroImageUrl) heroUploader.setUrl(settings.heroImageUrl);
if (settings.backgroundImageUrl) bgUploader.setUrl(settings.backgroundImageUrl);
}
} catch (err) {
console.error('Ошибка загрузки настроек изображений:', err);
}
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Сохранение...';
try {
const body = {
heroImageUrl: heroUploader.getUrl(),
backgroundImageUrl: bgUploader.getUrl()
};
const res = await fetch(`${API}/site-settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': getCsrfToken()
},
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('Ошибка сохранения настроек');
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Сохранено!';
setTimeout(() => {
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Сохранить изображения';
}, 2000);
} catch (err) {
console.error(err);
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-x-circle me-1"></i>Ошибка сохранения';
setTimeout(() => {
saveBtn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Сохранить изображения';
}, 2000);
}
});
}

View File

@@ -689,6 +689,45 @@
<div id="admin-applications-container"></div>
</div>
<!-- ── Site Images section ── -->
<div class="section-card" id="site-images-section">
<div class="section-title mb-3">
<span><i class="bi bi-image me-2"></i>Изображения главной страницы</span>
</div>
<p class="text-muted mb-4" style="font-size:0.9rem;">Настройте изображения для hero-секции и фона главной страницы</p>
<div class="row g-4">
<div class="col-md-6">
<h6 class="fw-bold mb-3"><i class="bi bi-card-image me-2"></i>Hero изображение</h6>
<p class="text-muted mb-2" style="font-size:0.8rem;">Основное изображение справа в hero-секции</p>
<div id="hero-image-uploader" class="img-uploader">
<div class="img-uploader__zone" id="hero-image-zone">
<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<h6 class="fw-bold mb-3"><i class="bi bi-aspect-ratio me-2"></i>Фоновое изображение</h6>
<p class="text-muted mb-2" style="font-size:0.8rem;">Фон hero-секции и страницы входа</p>
<div id="bg-image-uploader" class="img-uploader">
<div class="img-uploader__zone" id="bg-image-zone">
<div class="img-uploader__placeholder">
<i class="bi bi-cloud-upload"></i>
<span>Нажмите или перетащите изображение</span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button id="save-site-images-btn" class="btn-admin" disabled>
<i class="bi bi-check-circle me-1"></i>Сохранить изображения
</button>
</div>
</div>
</div>
<script type="module" src="/js/site/pages/admin/admin.js"></script>

View File

@@ -1,7 +1,7 @@
<style>
<style th:inline="css">
.tennis-bg {
background: linear-gradient(rgba(44,62,80,0.9), rgba(44,62,80,0.9)),
url('https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height');
url([(${cdn != null ? cdn + backgroundImageUrl : backgroundImageUrl})]);
background-size: cover;
background-position: center;
color: white;
@@ -94,7 +94,7 @@
</div>
</div>
<div class="col-lg-6 hero-img mt-4 mt-lg-0">
<img src="https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height"
<img th:src="${cdn != null ? cdn + heroImageUrl : heroImageUrl}"
class="img-fluid rounded-3 shadow-lg" alt="Теннисный корт">
</div>
</div>

View File

@@ -1,12 +1,7 @@
<style>
:root {
--tennis-green: #2ecc71;
--tennis-dark: #2c3e50;
}
<style th:inline="css">
.login-bg {
background: linear-gradient(rgba(44,62,80,0.85), rgba(44,62,80,0.85)),
url('https://avatars.mds.yandex.net/get-altay/1880524/2a0000016ef11d02f18395e01a44f0ddbdb7/XXL_height');
url([(${cdn != null ? cdn + backgroundImageUrl : backgroundImageUrl})]);
background-size: cover;
background-position: center;
}