Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created November 26, 2025 13:53
Show Gist options
  • Select an option

  • Save sunmeat/caabc946763c1efd63c1c5b911e72c15 to your computer and use it in GitHub Desktop.

Select an option

Save sunmeat/caabc946763c1efd63c1c5b911e72c15 to your computer and use it in GitHub Desktop.
spring boot + API 2
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("&", "&amp;").replace("<", "&lt;");
}
}
@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