Created
November 26, 2025 13:53
-
-
Save sunmeat/caabc946763c1efd63c1c5b911e72c15 to your computer and use it in GitHub Desktop.
spring boot + API 2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| build.gradle: | |
| dependencies { | |
| implementation 'org.springframework.boot:spring-boot-starter-web' | |
| implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' | |
| implementation 'com.fasterxml.jackson.core:jackson-databind' | |
| developmentOnly 'org.springframework.boot:spring-boot-devtools' | |
| } | |
| ================================================================================================= | |
| HibernateApplication.java: | |
| package site.sunmeat.hibernate; | |
| import java.awt.Desktop; | |
| import java.net.URI; | |
| import org.springframework.boot.SpringApplication; | |
| import org.springframework.boot.autoconfigure.SpringBootApplication; | |
| import org.springframework.boot.context.event.ApplicationReadyEvent; | |
| import org.springframework.context.event.EventListener; | |
| import org.springframework.stereotype.Component; | |
| @SpringBootApplication | |
| public class HibernateApplication { | |
| public static void main(String[] args) { | |
| SpringApplication.run(HibernateApplication.class, args); | |
| } | |
| } | |
| @Component | |
| class BrowserLauncher { | |
| @EventListener(ApplicationReadyEvent.class) | |
| public void launchBrowser() { | |
| System.setProperty("java.awt.headless", "false"); | |
| Desktop desktop = Desktop.getDesktop(); | |
| try { | |
| desktop.browse(new URI("http://localhost:8080")); | |
| } catch (Exception e) { } | |
| } | |
| } | |
| ================================================================================================= | |
| WebController.java: | |
| package site.sunmeat.hibernate; | |
| import org.springframework.boot.SpringApplication; | |
| import org.springframework.boot.autoconfigure.SpringBootApplication; | |
| import org.springframework.context.annotation.Bean; | |
| import org.springframework.web.client.RestClient; | |
| import org.springframework.stereotype.Service; | |
| import java.util.*; | |
| import org.springframework.http.MediaType; | |
| import org.springframework.stereotype.Controller; | |
| import org.springframework.ui.Model; | |
| import org.springframework.web.bind.annotation.*; | |
| import java.time.LocalDate; | |
| import java.time.format.DateTimeFormatter; | |
| import java.time.format.FormatStyle; | |
| import java.util.Locale; | |
| @Controller | |
| public class WebController { | |
| private final TmdbService tmdbService; | |
| private List<TmdbService.Movie> top100; | |
| private final String[] frame = {"#8B0000","#006400","#00008B","#8B8000","#008B8B","#4B0082","#8B4500","#2F4F4F"}; | |
| private final String[] text = {"#FF3333","#00FF7F","#6495ED","#FFD700","#00FFFF","#FF00FF","#FF6347","#87CEEB"}; | |
| public WebController(TmdbService tmdbService) { | |
| this.tmdbService = tmdbService; | |
| } | |
| @GetMapping("/") | |
| public String index(Model model) { | |
| model.addAttribute("title", "Топ-100 фільмів"); | |
| return "index"; | |
| } | |
| @GetMapping(value = "/movie", produces = MediaType.TEXT_HTML_VALUE) | |
| @ResponseBody | |
| public String movie(@RequestParam(value = "n", defaultValue = "1") int n) { | |
| if (n < 1 || n > 100) n = 1; | |
| int i = n - 1; | |
| if (top100 == null) { | |
| top100 = tmdbService.loadTop50Movies(); | |
| } | |
| if (top100.isEmpty() || i >= top100.size()) { | |
| return error(n); | |
| } | |
| var movie = top100.get(i); | |
| var credits = tmdbService.credits(movie.id()); | |
| return card(movie, credits, n, i); | |
| } | |
| private String card(TmdbService.Movie m, TmdbService.CreditsResponse c, int n, int i) { | |
| var actors = (c.cast() != null ? c.cast().stream().limit(5) | |
| .map(a -> esc(a.name()) + (a.character() != null ? " (" + esc(a.character()) + ")" : "")) | |
| .toList() : List.of("Невідомо")); | |
| String ul = actors.stream().map(a -> "<li>" + a + "</li>").reduce("", String::concat); | |
| int stars = (int) Math.round(m.vote_average() != null ? m.vote_average() : 0); | |
| return """ | |
| <div class="movie-card" style="--frame:%s;--text:%s"> | |
| <div class="header">#%d — %s</div> | |
| <div class="info">Дата: %s | Оцінка: %s %.1f</div> | |
| <div class="credits"> | |
| Режисер: %s | Сценарист: %s | Композитор: %s | |
| </div> | |
| <div class="cast">Актори:<ul>%s</ul></div> | |
| <div class="overview">%s</div> | |
| </div> | |
| """.formatted( | |
| frame[i % frame.length], text[i % text.length], n, esc(m.title()), | |
| formatDate(m.release_date()), | |
| "★★★★★★★★★★".substring(0, stars) + "☆☆☆☆☆☆☆☆☆☆".substring(0, 10 - stars), | |
| m.vote_average() != null ? m.vote_average() : 0, | |
| crew(c, "Director"), crew(c, "Writer", "Screenplay"), crew(c, "Original Music Composer"), | |
| ul, esc(m.overview()) | |
| ); | |
| } | |
| private String crew(TmdbService.CreditsResponse c, String... jobs) { | |
| if (c.crew() == null) return "Невідомо"; | |
| return c.crew().stream() | |
| .filter(p -> p.job() != null && List.of(jobs).contains(p.job())) | |
| .findFirst() | |
| .map(p -> esc(p.name())) | |
| .orElse("Невідомо"); | |
| } | |
| private String error(int n) { | |
| return "<div class=\"movie-card\" style=\"--frame:%s;--text:%s\">#%d — Помилка завантаження</div>" | |
| .formatted(frame[(n-1) % frame.length], text[(n-1) % text.length], n); | |
| } | |
| private String formatDate(String d) { | |
| if (d == null || d.isBlank()) return "невідомо"; | |
| try { | |
| LocalDate date = LocalDate.parse(d); | |
| DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG) | |
| .withLocale(Locale.forLanguageTag("uk")); | |
| String formatted = date.format(formatter); | |
| return formatted.replace(" р.", ""); | |
| } catch (Exception e) { | |
| return d; | |
| } | |
| } | |
| private String esc(String s) { | |
| return s == null ? "" : s.replace("&", "&").replace("<", "<"); | |
| } | |
| } | |
| @Service | |
| class TmdbService { | |
| private final RestClient client; | |
| public TmdbService(RestClient client) { | |
| this.client = client; | |
| } | |
| public List<Movie> loadTop50Movies() { | |
| List<Movie> allMovies = new ArrayList<>(); | |
| for (int page = 1; page <= 5; page++) { | |
| TopRatedResponse response = topRated(page); | |
| if (response != null && response.results() != null) { | |
| allMovies.addAll(response.results()); | |
| } | |
| if (allMovies.size() >= 100) break; | |
| } | |
| return allMovies.stream().limit(100).toList(); | |
| } | |
| public TopRatedResponse topRated(int page) { | |
| return client.get() | |
| .uri(uriBuilder -> uriBuilder | |
| .path("/movie/top_rated") | |
| .queryParam("api_key", "{api_key}") | |
| .queryParam("language", "uk-UA") | |
| .queryParam("page", String.valueOf(page)) | |
| .build()) | |
| .retrieve() | |
| .body(TopRatedResponse.class); | |
| } | |
| public CreditsResponse credits(int movieId) { | |
| return client.get() | |
| .uri("/movie/" + movieId + "/credits?api_key={api_key}") | |
| .retrieve() | |
| .body(CreditsResponse.class); | |
| } | |
| public record Movie(int id, String title, String overview, String release_date, | |
| Double vote_average, Integer vote_count) {} | |
| public record TopRatedResponse(List<Movie> results) {} | |
| public record Person(String name, String character, String job) {} | |
| public record CreditsResponse(List<Person> cast, List<Person> crew) {} | |
| } | |
| @SpringBootApplication | |
| class TmdbApplication { | |
| public static void main(String[] args) { | |
| SpringApplication.run(TmdbApplication.class, args); | |
| } | |
| @Bean | |
| public RestClient restClient() { | |
| return RestClient.builder() | |
| .baseUrl("https://api.themoviedb.org/3") | |
| .defaultUriVariables(java.util.Map.of("api_key", "8f663caefb9c78b4b33f1c2ff31d13f3")) | |
| .build(); | |
| } | |
| } | |
| ================================================================================================= | |
| style.css: | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@700;900&display=swap'); | |
| body { | |
| margin: 0; | |
| min-height: 100vh; | |
| font-family: 'Inter', system-ui, sans-serif; | |
| background: #0b0c10; | |
| color: #e5e5e5; | |
| overflow-x: hidden; | |
| position: relative; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background: | |
| radial-gradient(circle at 30% 70%, rgba(139, 0, 0, 0.15) 0%, transparent 40%), | |
| radial-gradient(circle at 80% 20%, rgba(0, 20, 100, 0.15) 0%, transparent 40%), | |
| #0b0c10; | |
| pointer-events: none; | |
| z-index: 1; | |
| } | |
| body::after { | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| background-image: | |
| radial-gradient(circle at 20% 50%, rgba(255,255,255,0.04) 1px, transparent 1px), | |
| radial-gradient(circle at 80% 80%, rgba(255,255,255,0.03) 1px, transparent 1px); | |
| background-size: 80px 80px, 120px 120px; | |
| pointer-events: none; | |
| z-index: 2; | |
| animation: stars 180s linear infinite; | |
| } | |
| @keyframes stars { | |
| from { transform: translate(0, 0); } | |
| to { transform: translate(-100px, -100px); } | |
| } | |
| .container { | |
| max-width: 1000px; | |
| margin: 0 auto; | |
| padding: 80px 20px 120px; | |
| text-align: center; | |
| position: relative; | |
| z-index: 10; | |
| } | |
| h1 { | |
| font-family: 'Playfair Display', Georgia, serif; | |
| font-size: 5.5rem; | |
| font-weight: 900; | |
| background: linear-gradient(90deg, #ff6b6b, #ffa500, #5ce1e6); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| text-shadow: 0 0 60px rgba(255,107,107,0.5); | |
| margin: 0 0 20px 60px; | |
| letter-spacing: 4px; | |
| line-height: 1.1; | |
| } | |
| button { | |
| padding: 20px 60px; | |
| font-size: 1.7rem; | |
| font-weight: 600; | |
| font-family: 'Inter', sans-serif; | |
| background: linear-gradient(145deg, #1f0b3f, #0b1f3f); | |
| color: #fff; | |
| border: none; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| box-shadow: | |
| 0 10px 30px rgba(0,0,0,0.6), | |
| inset 0 1px 0 rgba(255,255,255,0.1); | |
| transition: all 0.4s ease; | |
| position: relative; | |
| overflow: hidden; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| } | |
| button::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(145deg, rgba(255,107,107,0.3), rgba(92,225,230,0.3)); | |
| opacity: 0; | |
| transition: opacity 0.4s; | |
| } | |
| button:hover { | |
| transform: translateY(-8px); | |
| box-shadow: 0 20px 50px rgba(255,107,107,0.4); | |
| } | |
| button:hover::before { | |
| opacity: 1; | |
| } | |
| button:active { | |
| transform: translateY(-2px); | |
| } | |
| .movie-card { | |
| --frame: #e63946; | |
| --text: #ff9e00; | |
| width: 92%; | |
| max-width: 860px; | |
| margin: 40px auto; | |
| background: rgba(15, 17, 25, 0.95); | |
| backdrop-filter: blur(12px); | |
| border-radius: 24px; | |
| overflow: hidden; | |
| box-shadow: | |
| 0 20px 60px rgba(0,0,0,0.7), | |
| 0 0 0 1px var(--frame); | |
| border-left: 8px solid var(--frame); | |
| position: relative; | |
| animation: cardAppear 0.8s ease-out forwards; | |
| opacity: 0; | |
| transform: translateY(50px); | |
| } | |
| @keyframes cardAppear { | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .movie-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: linear-gradient(135deg, var(--frame) 0%, transparent 70%); | |
| opacity: 0.07; | |
| pointer-events: none; | |
| } | |
| .header { | |
| font-family: 'Playfair Display', serif; | |
| font-size: 3rem; | |
| font-weight: 900; | |
| color: var(--text); | |
| padding: 32px 40px 16px; | |
| margin: 0; | |
| text-align: left; | |
| text-shadow: 0 0 20px currentColor; | |
| background: linear-gradient(to right, var(--text), var(--frame)); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .info { | |
| padding: 0 40px; | |
| font-size: 1.35rem; | |
| color: #a0d8ff; | |
| text-align: left; | |
| margin-bottom: 20px; | |
| } | |
| .info strong { | |
| color: #fff; | |
| } | |
| .credits { | |
| padding: 0 40px 20px; | |
| font-size: 1.25rem; | |
| color: #c9d1d9; | |
| text-align: left; | |
| line-height: 1.7; | |
| } | |
| .cast { | |
| padding: 0 40px 24px; | |
| text-align: left; | |
| } | |
| .cast ul { | |
| list-style: none; | |
| padding: 0; | |
| margin: 12px 0 0; | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| } | |
| .cast li { | |
| background: rgba(255,255,255,0.07); | |
| padding: 8px 16px; | |
| border-radius: 12px; | |
| font-size: 1.1rem; | |
| backdrop-filter: blur(4px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| } | |
| .overview { | |
| padding: 0 40px 40px; | |
| font-size: 1.35rem; | |
| line-height: 1.8; | |
| color: #e0e0e0; | |
| text-align: left; | |
| font-style: italic; | |
| border-top: 1px solid rgba(255,255,255,0.08); | |
| margin-top: 20px; | |
| padding-top: 24px; | |
| } | |
| .info::after { | |
| content: attr(data-stars); | |
| margin-left: 12px; | |
| font-size: 1.8rem; | |
| letter-spacing: 4px; | |
| } | |
| .htmx-indicator { | |
| margin: 60px auto; | |
| font-size: 2rem; | |
| color: #ff6b6b; | |
| text-shadow: 0 0 20px currentColor; | |
| animation: pulse 1.5s infinite; | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 0.6; } | |
| 50% { opacity: 1; } | |
| } | |
| @media (max-width: 768px) { | |
| h1 { font-size: 3.8rem; } | |
| .header { font-size: 2.4rem; padding: 24px 24px 12px; } | |
| .movie-card { border-radius: 18px; border-left-width: 6px; } | |
| .info, .credits, .overview, .cast { padding-left: 24px; padding-right: 24px; } | |
| button { padding: 16px 40px; font-size: 1.4rem; } | |
| } | |
| ================================================================================================= | |
| index.html: | |
| <!DOCTYPE html> | |
| <html xmlns:th="http://www.thymeleaf.org"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title th:text="${title}">Топ-100 фільмів</title> | |
| <script src="https://unpkg.com/[email protected]"></script> | |
| <link rel="stylesheet" href="/style.css"> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Топ-100 найкращих фільмів (TMDb)</h1> | |
| <button id="start" | |
| hx-get="/movie?n=1" | |
| hx-target="#movies" | |
| hx-swap="beforeend"> | |
| Почати завантаження | |
| </button> | |
| <div id="movies" class="movies-list"></div> | |
| </div> | |
| <script> | |
| document.body.addEventListener('htmx:afterSwap', function(evt) { | |
| if (evt.detail.xhr.responseURL.includes('/movie')) { | |
| const match = evt.detail.xhr.responseURL.match(/n=(\d+)/); | |
| if (match) { | |
| const current = parseInt(match[1]); | |
| if (current < 100) { | |
| setTimeout(() => { | |
| htmx.ajax('GET', `/movie?n=${current + 1}`, { | |
| target: '#movies', | |
| swap: 'beforeend' | |
| }); | |
| }, 120); | |
| } else { | |
| document.getElementById('start').textContent = "Готово!"; | |
| document.getElementById('start').disabled = true; | |
| } | |
| } | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment