Skip to content

Commit edca20a

Browse files
committed
Add ratings UI
1 parent 933c44a commit edca20a

8 files changed

Lines changed: 330 additions & 12 deletions

File tree

src/main/kotlin/com/embabel/movie/domain/MovieService.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ class MovieService(
3737
fun findMovieBuffByEmail(email: String): MovieBuff? {
3838
return movieBuffRepository.findById(email).orElse(null)
3939
}
40+
41+
@Transactional(readOnly = true)
42+
fun getUserRatings(movieBuff: MovieBuff, offset: Int, limit: Int): List<MovieRating> {
43+
return movieBuff.movieRatings
44+
.sortedByDescending { it.timestamp }
45+
.drop(offset)
46+
.take(limit)
47+
}
4048

4149
@Transactional(readOnly = true)
4250
fun findMovieBuffByName(name: String): MovieBuff? =

src/main/kotlin/com/embabel/movie/web/MovieHtmxController.kt

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,30 @@ import com.embabel.agent.web.htmx.GenericProcessingValues
2222
import com.embabel.agent.web.security.EmbabelAuth2User
2323
import com.embabel.movie.agent.MovieRequest
2424
import com.embabel.movie.domain.MovieBuff
25+
import com.embabel.movie.domain.MovieService
2526
import org.slf4j.LoggerFactory
27+
import org.springframework.http.MediaType
2628
import org.springframework.security.core.annotation.AuthenticationPrincipal
2729
import org.springframework.security.oauth2.core.user.OAuth2User
2830
import org.springframework.stereotype.Controller
2931
import org.springframework.ui.Model
30-
import org.springframework.web.bind.annotation.GetMapping
31-
import org.springframework.web.bind.annotation.ModelAttribute
32-
import org.springframework.web.bind.annotation.PostMapping
33-
import org.springframework.web.bind.annotation.RequestMapping
32+
import org.springframework.web.bind.annotation.*
3433

3534
@Controller
3635
@RequestMapping(value = ["/", "/movie"])
3736
class MovieHtmxController(
3837
private val agentPlatform: AgentPlatform,
38+
private val movieService: MovieService,
3939
) {
4040

4141
private val logger = LoggerFactory.getLogger(MovieHtmxController::class.java)
4242

4343
@GetMapping
44-
fun findMovies(model: Model): String {
44+
fun findMovies(
45+
model: Model,
46+
@AuthenticationPrincipal principal: OAuth2User,
47+
): String {
48+
model.addAttribute("user", movieBuff(principal))
4549
model.addAttribute(
4650
"movieRequest", MovieRequest(
4751
preference = "A film with a remarkable musical score",
@@ -57,9 +61,7 @@ class MovieHtmxController(
5761
@AuthenticationPrincipal
5862
principal: OAuth2User,
5963
): String {
60-
61-
val movieBuff = (principal as? EmbabelAuth2User)?.getUser() as? MovieBuff
62-
?: error("User is not a movie buff. Please register as a movie buff to find movies.")
64+
val movieBuff = movieBuff(principal)
6365
logger.info("Finding movies for user {} named {}", movieBuff.email, movieBuff.name)
6466

6567
// Convert form to domain objects
@@ -79,6 +81,7 @@ class MovieHtmxController(
7981
)
8082

8183
model.addAttribute("movieRequest", movieRequest)
84+
model.addAttribute("user", movieBuff)
8285
GenericProcessingValues(
8386
agentProcess = agentProcess,
8487
pageTitle = "Finding Movies",
@@ -89,5 +92,94 @@ class MovieHtmxController(
8992
agentPlatform.start(agentProcess)
9093
return "common/processing"
9194
}
95+
96+
/**
97+
* View all ratings for the current user
98+
*/
99+
@GetMapping("/ratings")
100+
fun viewRatings(
101+
model: Model,
102+
@AuthenticationPrincipal principal: OAuth2User
103+
): String {
104+
val movieBuff = movieBuff(principal)
105+
val limit = 10
106+
val ratings = movieService.getUserRatings(movieBuff, 0, limit)
107+
108+
model.addAttribute("movieBuff", movieBuff)
109+
model.addAttribute("ratings", ratings)
110+
model.addAttribute("offset", ratings.size)
111+
model.addAttribute("limit", limit)
112+
model.addAttribute("hasMore", ratings.size < movieBuff.movieRatings.size)
113+
114+
return "movie-ratings"
115+
}
116+
117+
/**
118+
* Load more ratings for infinite scroll
119+
*/
120+
@GetMapping("/ratings/more", produces = [MediaType.TEXT_HTML_VALUE])
121+
fun loadMoreRatings(
122+
@RequestParam offset: Int,
123+
@RequestParam limit: Int = 10,
124+
model: Model,
125+
@AuthenticationPrincipal principal: OAuth2User,
126+
): String {
127+
val movieBuff = movieBuff(principal)
128+
logger.info("Loading more ratings for user {} at offset {}", movieBuff.email, offset)
129+
val ratings = movieService.getUserRatings(movieBuff, offset, limit)
130+
131+
model.addAttribute("ratings", ratings)
132+
model.addAttribute("offset", offset + ratings.size)
133+
model.addAttribute("limit", limit)
134+
model.addAttribute("hasMore", offset + ratings.size < movieBuff.movieRatings.size)
135+
136+
return "fragments/rating-items"
137+
}
138+
139+
/**
140+
* Show form to add a new rating
141+
*/
142+
@GetMapping("/ratings/add")
143+
fun showRatingForm(model: Model): String {
144+
return "add-rating-form"
145+
}
146+
147+
/**
148+
* Process a new rating submission
149+
*/
150+
@PostMapping("/ratings/add")
151+
fun addRating(
152+
@RequestParam title: String,
153+
@RequestParam rating: Int,
154+
@AuthenticationPrincipal principal: OAuth2User,
155+
model: Model
156+
): String {
157+
val movieBuff = movieBuff(principal)
158+
159+
// Since OneThroughTen is just a typealias for Int, we can use the rating directly
160+
// after validating it's in the correct range
161+
if (rating !in 1..10) {
162+
error("Rating must be between 1 and 10")
163+
}
164+
165+
movieService.rate(movieBuff, title, rating)
166+
logger.info("Added rating for movie '{}' with score {} by user {}", title, rating, movieBuff.email)
167+
168+
// Return the newly added rating as HTML fragment
169+
val updatedMovieBuff = movieService.findMovieBuffByEmail(movieBuff.email)
170+
?: error("Could not find movie buff after adding rating")
171+
172+
val latestRating = updatedMovieBuff.movieRatings.minByOrNull { it.movie.title }
173+
174+
model.addAttribute("ratings", listOfNotNull(latestRating))
175+
return "fragments/rating-items"
176+
}
177+
178+
private fun movieBuff(
179+
principal: OAuth2User
180+
): MovieBuff {
181+
return (principal as? EmbabelAuth2User)?.getUser() as? MovieBuff
182+
?: error("User is not a movie buff. Please register as a movie buff to perform this action.")
183+
}
92184
}
93185

src/main/resources/static/css/project.css

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,96 @@ body {
6161

6262
.movie-button:visited {
6363
color: #ffffff;
64+
}/* Movie Ratings Styles */
65+
.rating-item {
66+
margin-bottom: 1.5rem;
67+
}
68+
69+
.rating-card {
70+
background-color: #2a2a2a;
71+
border-radius: 8px;
72+
padding: 1rem;
73+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
74+
}
75+
76+
.rating-card h3 {
77+
margin-top: 0;
78+
margin-bottom: 0.5rem;
79+
color: #e0e0e0;
80+
}
81+
82+
.rating-details {
83+
display: flex;
84+
justify-content: space-between;
85+
margin-bottom: 0.5rem;
86+
}
87+
88+
.rating-score {
89+
font-weight: bold;
90+
color: #ffd700;
91+
}
92+
93+
.rating-date {
94+
color: #a0a0a0;
95+
font-size: 0.9rem;
96+
}
97+
98+
.movie-details {
99+
margin-top: 0.5rem;
100+
font-size: 0.9rem;
101+
}
102+
103+
.imdb-link a {
104+
color: #f5c518;
105+
text-decoration: none;
106+
}
107+
108+
.imdb-link a:hover {
109+
text-decoration: underline;
110+
}
111+
112+
.loading-indicator {
113+
text-align: center;
114+
padding: 1rem;
115+
color: #a0a0a0;
116+
}
117+
118+
/* Rating Form Styles */
119+
.rating-form-container {
120+
background-color: #2a2a2a;
121+
border-radius: 8px;
122+
padding: 1.5rem;
123+
margin-bottom: 2rem;
124+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
125+
}
126+
127+
.rating-form-container h3 {
128+
margin-top: 0;
129+
margin-bottom: 1rem;
130+
color: #e0e0e0;
131+
}
132+
133+
.form-group {
134+
margin-bottom: 1rem;
135+
}
136+
137+
.form-group label {
138+
display: block;
139+
margin-bottom: 0.5rem;
140+
color: #c0c0c0;
141+
}
142+
143+
.form-control {
144+
width: 100%;
145+
padding: 0.5rem;
146+
background-color: #3a3a3a;
147+
border: 1px solid #4a4a4a;
148+
border-radius: 4px;
149+
color: #e0e0e0;
150+
}
151+
152+
.form-actions {
153+
display: flex;
154+
gap: 0.5rem;
155+
margin-top: 1rem;
64156
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!DOCTYPE html>
2+
<html xmlns:th="http://www.thymeleaf.org">
3+
<body>
4+
<div class="rating-form-container">
5+
<h3>Rate a Movie</h3>
6+
<form hx-post="/movie/ratings/add"
7+
hx-target="#ratings-container"
8+
hx-swap="afterbegin"
9+
hx-on::after-request="this.reset(); document.getElementById('rating-form-container').innerHTML = '';">
10+
<div class="form-group">
11+
<label for="title">Movie Title</label>
12+
<input type="text" id="title" name="title" class="form-control" required>
13+
</div>
14+
<div class="form-group">
15+
<label for="rating">Your Rating (1-10)</label>
16+
<select id="rating" name="rating" class="form-control" required>
17+
<option value="">Select a rating</option>
18+
<option value="1">1 - Terrible</option>
19+
<option value="2">2 - Very Bad</option>
20+
<option value="3">3 - Bad</option>
21+
<option value="4">4 - Below Average</option>
22+
<option value="5">5 - Average</option>
23+
<option value="6">6 - Above Average</option>
24+
<option value="7">7 - Good</option>
25+
<option value="8">8 - Very Good</option>
26+
<option value="9">9 - Excellent</option>
27+
<option value="10">10 - Masterpiece</option>
28+
</select>
29+
</div>
30+
<div class="form-actions">
31+
<button type="submit" class="btn btn-primary">Submit Rating</button>
32+
<button type="button" class="btn btn-secondary"
33+
onclick="document.getElementById('rating-form-container').innerHTML = '';">
34+
Cancel
35+
</button>
36+
</div>
37+
</form>
38+
</div>
39+
</body>
40+
</html>

src/main/resources/templates/common/fragments/user.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@
5555
style="color: #cbd5e1;
5656
font-size: 0.9rem;
5757
font-weight: 500;">User</span>
58+
<!-- <a href="/movie/ratings"-->
59+
<!-- style="color: #94a3b8;-->
60+
<!-- text-decoration: none;-->
61+
<!-- font-size: 0.85rem;-->
62+
<!-- padding: 4px 8px;-->
63+
<!-- background: rgba(148, 163, 184, 0.1);-->
64+
<!-- border-radius: 6px;-->
65+
<!-- transition: all 0.2s ease;"-->
66+
<!-- onmouseover="this.style.background='rgba(96, 165, 250, 0.15)';-->
67+
<!-- this.style.color='#60a5fa'"-->
68+
<!-- onmouseout="this.style.background='rgba(148, 163, 184, 0.1)';-->
69+
<!-- this.style.color='#94a3b8'">-->
70+
<!-- My Ratings-->
71+
<!-- </a>-->
5872
</span>
5973

6074
<!-- Logout Form -->

src/main/resources/templates/find-movies-form.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
<html xmlns:th="http://www.thymeleaf.org"
2-
th:replace="~{common/layout :: layout(~{::title}, ~{::section})}">
2+
th:replace="~{common/layout :: layout(~{::title}, ~{::section})}"
3+
th:with="_htmx=true">
34
<head>
4-
<title>Embabel Movie Finder</title>
5+
<title>Flicker</title>
56
</head>
67
<body>
78
<section>
8-
<h2>Embabel Movie Finder</h2>
9-
<form action="/find-movies" method="post" th:object="${movieRequest}">
9+
<h2>Embabel Flicker</h2>
10+
<p>
11+
<a href="/movie/ratings" class="movie-button">View My [[${user.movieRatings.size()}]] Ratings</a>
12+
</p>
13+
14+
<form action="/movie/find-movies" method="post" th:object="${movieRequest}">
1015
<div class="form-group">
1116
<label for="brief">What I want</label>
1217
<textarea id="brief" name="brief" rows="3" cols="40" th:field="*{preference}" required></textarea>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!DOCTYPE html>
2+
<html xmlns:th="http://www.thymeleaf.org">
3+
<body>
4+
<div th:fragment="rating-items(ratings, limit)">
5+
<div class="ratings-list">
6+
<div th:each="rating : ${ratings}" class="rating-item">
7+
<div class="rating-card">
8+
<h3 th:text="${rating.movie.title}"></h3>
9+
<div class="rating-details">
10+
<div class="rating-score" th:text="${'Rating: ' + rating.rating + '/10'}"></div>
11+
<div class="rating-date" th:text="${#temporals.format(rating.timestamp, 'MMM dd, yyyy')}"></div>
12+
</div>
13+
<div class="movie-details">
14+
<div th:if="${rating.movie.imdbId}" class="imdb-link">
15+
<a th:href="${'https://www.imdb.com/title/' + rating.movie.imdbId}" target="_blank">
16+
View on IMDB
17+
</a>
18+
</div>
19+
</div>
20+
</div>
21+
</div>
22+
</div>
23+
24+
<!-- Infinite scroll trigger -->
25+
<div th:if="${hasMore}"
26+
hx-get="@{/movie/ratings/more(offset=${offset}, limit=10)}"
27+
hx-trigger="revealed"
28+
hx-swap="outerHTML"
29+
hx-indicator=".loading-indicator">
30+
<div class="loading-indicator">Loading more...</div>
31+
</div>
32+
</div>
33+
</body>
34+
</html>

0 commit comments

Comments
 (0)