Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .specify/feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"feature_directory": "specs/025-normalize-movie-genres"
}
174 changes: 174 additions & 0 deletions .specify/inputs/github-issues/18-catalog-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# GitHub Issue Context

## Source

- Repository: marciomyst/SmartMovieCatalog
- Issue: #18
- URL: https://github.com/marciomyst/SmartMovieCatalog/issues/18
- State: OPEN
- Created: 2026-05-02T23:34:28Z
- Updated: 2026-05-02T23:34:28Z
- Milestone: M1 — Core Movie Catalog

## Title

Catalog page

## Labels

- type:feature
- priority:high
- area:frontend
- area:catalog
- area:ui
- v1

## Assignees

_No assignees_



## Issue Body

## Summary

Implement the main catalog browsing page.

The catalog page should provide a dedicated surface for browsing all movies and using basic title search.

## Goal

Allow users to browse the movie catalog outside of the home page.

## Scope

- Add a catalog route/page.
- Fetch paginated movie summaries from the API.
- Display movies using cards or a consistent catalog layout.
- Integrate basic title search using the movie listing query parameter.
- Support loading, empty, error, and no-result states.
- Support explicit pagination.
- Link each movie to the details page.
- Keep the layout responsive.

## Suggested Route

```text
/catalog
```

## Suggested API Usage

```http
GET /api/movies?query=central&page=1&pageSize=12
```

The response should use the shared paginated movie list shape:

```json
{
"items": [],
"page": 1,
"pageSize": 12,
"totalCount": 0,
"totalPages": 0,
"hasPreviousPage": false,
"hasNextPage": false
}
```

Invalid pagination or query parameters should use the shared invalid request response:

```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
```

```json
{
"type": "https://smartmoviecatalog.dev/problems/invalid-request",
"title": "Invalid request.",
"status": 400,
"detail": "The request could not be processed because it is malformed or contains invalid parameters.",
"instance": "/api/movies",
"errors": {
"page": [
"Page must be greater than or equal to 1."
]
}
}
```

## Suggested Initial UI

- Page title: `Catalog`
- Search input
- Movie grid/list
- Pagination controls
- Empty state
- Error state

## Acceptance Criteria

- Catalog page is reachable through app navigation.
- Catalog page lists movies from the API.
- Catalog page consumes the paginated movie list endpoint from issue #12.
- Catalog page integrates title search from issue #16.
- User can navigate from catalog item to movie details.
- Catalog item navigation targets the movie details route backed by issue #13.
- Catalog page handles loading state.
- Catalog page handles empty catalog state.
- Catalog page handles no search results state.
- Catalog page handles API error state.
- Pagination controls work consistently using `page`, `pageSize`, `totalCount`, `totalPages`, `hasPreviousPage`, and `hasNextPage`.
- Layout is responsive.
- Components use typed API services instead of direct `HttpClient`.
- No unsupported future architecture terms are shown in the UI.

## Technical Notes

- Keep this page focused on basic browsing for V1.
- Do not introduce advanced filter UI unless backend support exists.
- Search integration should remain basic title search through `GET /api/movies?query=...`.
- Reuse the movie card component from issue #17 if available.
- Keep catalog state simple and maintainable.

## Out of Scope

- Genre filter.
- Year filter.
- AI analyzed filter.
- Semantic search.
- Vector search.
- Recommendations.
- Poster similarity.
- RAG.
- Authentication-specific catalog behavior.


## Comments

_No comments_

## Instructions for Spec Kit

Use this GitHub issue as the primary source of truth.

Convert the issue into a Spec Kit feature specification before creating the implementation plan.

Preserve:

- business goal;
- user stories;
- acceptance criteria;
- technical constraints;
- non-goals;
- dependencies;
- open questions.

If information is missing, add it under a clearly marked **Clarifications Needed** section instead of inventing requirements.

If the issue conflicts with existing project documentation, explicitly call out the conflict.

Prefer a small, incremental implementation plan aligned with the repository's existing architecture, folder structure, language, framework, and conventions.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Do not read, modify, or base analysis on generated/vendor output unless explicit
- `test:`

<!-- SPECKIT START -->
Current Spec Kit plan: `specs/011-create-movie/plan.md`.
Current Spec Kit plan: `specs/025-normalize-movie-genres/plan.md`.

Before using Spec Kit skills, read `.specify/memory/constitution.md`.
If the spec, plan, or implementation touches backend, API, contracts, domain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ private static async Task<IResult> HandleAsync(
request.Director,
request.Synopsis,
request.DurationMinutes,
request.AgeRating),
request.AgeRating,
request.ExternalId,
request.Image),
cancellationToken);

MovieResponse response = new(
Expand All @@ -51,7 +53,9 @@ private static async Task<IResult> HandleAsync(
createdMovie.Director,
createdMovie.Synopsis,
createdMovie.DurationMinutes,
createdMovie.AgeRating);
createdMovie.AgeRating,
createdMovie.ExternalId,
createdMovie.Image);

return Results.Created($"/api/movies/{response.Id}", response);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,28 @@
RuleFor(request => request.DurationMinutes)
.GreaterThan(0)
.When(request => request.DurationMinutes.HasValue);

RuleFor(request => request.ExternalId)
.GreaterThan(0)
.When(request => request.ExternalId.HasValue);

RuleFor(request => request.Image)
.Must(IsRelativeImagePath)
.WithMessage("Image must be a relative path.")
.When(request => !string.IsNullOrWhiteSpace(request.Image));
}

private static bool IsRelativeImagePath(string? image)
{
if (image is null)
{
return false;
}

string trimmedImage = image.Trim();

return trimmedImage.StartsWith("/", StringComparison.Ordinal) &&

Check warning on line 54 in backend/src/SmartMovieCatalog.Api/Features/Movies/CreateMovie/CreateMovieRequestValidator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'string.StartsWith(char)' instead of 'string.StartsWith(string)' when you have a string with a single char

See more on https://sonarcloud.io/project/issues?id=marciomyst_SmartMovieCatalog&issues=AZ4J9DsoqhWZoRil_Avp&open=AZ4J9DsoqhWZoRil_Avp&pullRequest=27
!trimmedImage.StartsWith("//", StringComparison.Ordinal) &&
!trimmedImage.Contains('\\', StringComparison.Ordinal);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Mvc;
using SmartMovieCatalog.Api.Common;
using SmartMovieCatalog.Application.Features.Movies;
using SmartMovieCatalog.Contracts.Movies;
using Wolverine;

namespace SmartMovieCatalog.Api.Features.Movies.ListMovies;

public static class ListMoviesEndpoint
{
public static void MapListMovies(this IEndpointRouteBuilder app)
{
app.MapGet("/api/movies", HandleAsync)
.WithName("ListMovies")
.WithTags("Movies")
.AllowAnonymous()
.AddEndpointFilter<ValidationFilter<ListMoviesRequest>>()
.Produces<PagedMovieSummaryResponse>(StatusCodes.Status200OK)
.ProducesValidationProblem(StatusCodes.Status400BadRequest);
}

private static async Task<IResult> HandleAsync(
[AsParameters] ListMoviesRequest request,
IMessageBus messageBus,
CancellationToken cancellationToken)
{
PagedMovieSummaries movies = await messageBus.InvokeAsync<PagedMovieSummaries>(
new ListMoviesQuery(
NormalizeQuery(request.Query),
request.Page,
request.PageSize),
cancellationToken);

MovieSummaryResponse[] items = movies.Items
.Select(movie => new MovieSummaryResponse(
movie.Id.ToString(),
movie.Title,
movie.ReleaseYear,
movie.CountryCode,
movie.Genres,
movie.Director,
movie.PosterUrl,
movie.CreatedAt))
.ToArray();

return Results.Ok(new PagedMovieSummaryResponse(
items,
movies.Page,
movies.PageSize,
movies.TotalCount,
movies.TotalPages,
movies.HasPreviousPage,
movies.HasNextPage));
}

private static string? NormalizeQuery(string? query)
{
string? trimmedQuery = query?.Trim();

return string.IsNullOrEmpty(trimmedQuery) ? null : trimmedQuery;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SmartMovieCatalog.Api.Features.Movies.ListMovies;

public sealed record ListMoviesRequest
{
public string? Query { get; init; }

public int Page { get; init; } = 1;

public int PageSize { get; init; } = 12;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using FluentValidation;

namespace SmartMovieCatalog.Api.Features.Movies.ListMovies;

public sealed class ListMoviesRequestValidator : AbstractValidator<ListMoviesRequest>
{
public ListMoviesRequestValidator()
{
RuleFor(request => request.Page)
.GreaterThanOrEqualTo(1);

RuleFor(request => request.PageSize)
.GreaterThanOrEqualTo(1);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using SmartMovieCatalog.Api.Features.Movies.CreateMovie;
using SmartMovieCatalog.Api.Features.Movies.ListMovies;

namespace SmartMovieCatalog.Api.Features.Movies;

public static class MoviesEndpoints
{
public static void MapMovieEndpoints(this IEndpointRouteBuilder app)
{
app.MapListMovies();
app.MapCreateMovie();
}
}
2 changes: 2 additions & 0 deletions backend/src/SmartMovieCatalog.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using SmartMovieCatalog.Api.Features.Auth.Authenticate;
using SmartMovieCatalog.Api.Features.Movies;
using SmartMovieCatalog.Api.Features.Movies.CreateMovie;
using SmartMovieCatalog.Api.Features.Movies.ListMovies;
using SmartMovieCatalog.Application;
using SmartMovieCatalog.Contracts.Auth;
using SmartMovieCatalog.Contracts.Movies;
Expand Down Expand Up @@ -34,6 +35,7 @@ public static int Main(string[] args)
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddScoped<IValidator<AuthenticateRequest>, AuthenticateRequestValidator>();
builder.Services.AddScoped<IValidator<CreateMovieRequest>, CreateMovieRequestValidator>();
builder.Services.AddScoped<IValidator<ListMoviesRequest>, ListMoviesRequestValidator>();
builder.Services.AddHealthChecks();

builder.Services.AddOpenApi();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using SmartMovieCatalog.Domain.Movies;

namespace SmartMovieCatalog.Application.Abstractions.Persistence;

public interface IGenreRepository
{
Task<IReadOnlyCollection<Genre>> FindByNormalizedNamesAsync(
IReadOnlyCollection<string> normalizedNames,
CancellationToken cancellationToken);

Task AddRangeAsync(IReadOnlyCollection<Genre> genres, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,11 @@ public interface IMovieRepository
{
Task AddAsync(Movie movie, CancellationToken cancellationToken);

Task<PagedResult<Movie>> ListAsync(
string? query,
int page,
int pageSize,
CancellationToken cancellationToken);

Task SaveChangesAsync(CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace SmartMovieCatalog.Application.Abstractions.Persistence;

public sealed record PagedResult<T>(
IReadOnlyCollection<T> Items,
int Page,
int PageSize,
int TotalCount)
{
public int TotalPages => TotalCount == 0 ? 0 : (int)Math.Ceiling((double)TotalCount / PageSize);

public bool HasPreviousPage => Page > 1;

public bool HasNextPage => Page < TotalPages;
}
Loading
Loading