Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eaf8430
test(web): define Together design preview
cursoragent Sep 6, 2026
ad973c2
feat(web): add Together UI direction preview
cursoragent Sep 6, 2026
54d5788
style(web): format design preview
cursoragent Sep 6, 2026
4d4d888
docs: design cinematic movie details rollout
cursoragent Sep 6, 2026
fefa2fa
docs: plan cinematic movie details implementation
cursoragent Sep 6, 2026
da28e25
feat(web): add UI version preference model
cursoragent Sep 6, 2026
077f89a
docs: include Vitest setup restoration
cursoragent Sep 6, 2026
e5cf507
feat(web): add UI version preference model
cursoragent Sep 6, 2026
4069cc4
feat(web): add UI version preference model
cursoragent Sep 6, 2026
25a0141
fix(web): preserve complete UI cookie value
cursoragent Sep 6, 2026
830019e
feat(web): add experimental UI appearance control
cursoragent Sep 6, 2026
cd56a5b
style(web): format UI appearance control
cursoragent Sep 6, 2026
4f763e3
fix(web): synchronize UI version control state
cursoragent Sep 6, 2026
ce2449e
test(web): cover shared movie presentation
cursoragent Sep 6, 2026
ea1e41b
refactor(web): share movie detail actions
cursoragent Sep 6, 2026
f022d4c
style(web): format movie action test
cursoragent Sep 6, 2026
8fd0cb3
test(web): cover movie detail renderer selection
cursoragent Sep 6, 2026
f9e49ab
feat(web): add cinematic movie detail renderer
cursoragent Sep 6, 2026
162bf60
style(web): organize cinematic renderer imports
cursoragent Sep 6, 2026
a3a8ab7
test(web): cover cinematic final review findings
cursoragent Sep 6, 2026
cc0e8fa
fix(web): address cinematic rollout review
cursoragent Sep 6, 2026
7e76222
style(web): format cinematic review fixes
cursoragent Sep 6, 2026
d2a468e
fix(web): correct cinematic desktop content grid
cursoragent Sep 6, 2026
81d9918
test(web): verify appearance labels in every locale
cursoragent Sep 6, 2026
6a1c02a
Merge remote-tracking branch 'origin/main' into cursor/together-ui-pr…
cursoragent Sep 6, 2026
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
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,11 @@
"or": "oder",
"sign_up": "Registrieren",
"login": "Anmelden",
"appearance": "Erscheinungsbild",
"theme": "Thema",
"new_interface": "Neue Benutzeroberfläche",
"experimental": "Experimentell",
"ui_preference_error": "Die Einstellung für die Benutzeroberfläche konnte nicht aktualisiert werden.",
"language": "Sprache",
"continue": "Weiter",
"do_not_have_an_account": "Haben Sie kein Konto?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,11 @@
"or": "or",
"sign_up": "Sign Up",
"login": "Log In",
"appearance": "Appearance",
"theme": "Theme",
"new_interface": "New interface",
"experimental": "Experimental",
"ui_preference_error": "Could not update the interface preference.",
"language": "Language",
"continue": "Continue",
"do_not_have_an_account": "Don't have an account?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,11 @@
"or": "o",
"sign_up": "Registrarse",
"login": "Iniciar sesión",
"appearance": "Apariencia",
"theme": "Tema",
"new_interface": "Nueva interfaz",
"experimental": "Experimental",
"ui_preference_error": "No se pudo actualizar la preferencia de interfaz.",
"language": "Idioma",
"continue": "Continuar",
"do_not_have_an_account": "¿No tienes una cuenta?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,11 @@
"or": "ou",
"sign_up": "S'inscrire",
"login": "Se connecter",
"appearance": "Apparence",
"theme": "Thème",
"new_interface": "Nouvelle interface",
"experimental": "Expérimental",
"ui_preference_error": "Impossible de mettre à jour la préférence d’interface.",
"language": "Langue",
"continue": "Continuer",
"do_not_have_an_account": "¿No tienes una cuenta?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@
"or": "o",
"sign_up": "Registrati",
"login": "Accedi",
"appearance": "Aspetto",
"theme": "Tema",
"new_interface": "Nuova interfaccia",
"experimental": "Sperimentale",
"ui_preference_error": "Impossibile aggiornare la preferenza dell'interfaccia.",
"language": "Lingua",
"continue": "Continua",
"do_not_have_an_account": "Non hai un account?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@
"or": "または",
"sign_up": "サインアップ",
"login": "ログイン",
"appearance": "外観",
"theme": "テーマ",
"new_interface": "新しいインターフェース",
"experimental": "実験的",
"ui_preference_error": "インターフェースの設定を更新できませんでした。",
"language": "言語",
"continue": "続ける",
"do_not_have_an_account": "アカウントをお持ちではありませんか?",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/public/dictionaries/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,11 @@
"or": "ou",
"sign_up": "Cadastrar-se",
"login": "Entrar",
"appearance": "Aparência",
"theme": "Tema",
"new_interface": "Nova interface",
"experimental": "Experimental",
"ui_preference_error": "Não foi possível atualizar a preferência da interface.",
"language": "Idioma",
"continue": "Continuar",
"do_not_have_an_account": "Não tem uma conta?",
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cookies } from 'next/headers'
import { Link } from 'next-view-transitions'
import type { GetUserPreferences200 } from '@/api/endpoints.schemas'
import { getUserPreferences } from '@/api/users'
Expand All @@ -11,6 +12,7 @@ import { LanguageContextProvider } from '@/context/language'
import { ListsContextProvider } from '@/context/lists'
import { SessionContextProvider } from '@/context/session'
import { UserPreferencesContextProvider } from '@/context/user-preferences'
import { parseUiVersion, UI_VERSION_COOKIE_NAME } from '@/lib/ui-version'
import type { Language } from '@/types/languages'
import { getDictionary } from '@/utils/dictionaries'
import { SUPPORTED_LANGUAGES } from '../../../languages'
Expand All @@ -34,6 +36,10 @@ export default async function RootLayout({

const dictionary = await getDictionary(lang)
const session = await verifySession()
const cookieStore = await cookies()
const uiVersion = parseUiVersion(
cookieStore.get(UI_VERSION_COOKIE_NAME)?.value
)

let userPreferences: GetUserPreferences200['userPreferences'] = null

Expand All @@ -56,7 +62,7 @@ export default async function RootLayout({
<UserPreferencesContextProvider userPreferences={userPreferences}>
<ListsContextProvider>
<LayoutWrapper
header={<Header />}
header={<Header uiVersion={uiVersion} />}
footer={<Footer dictionary={dictionary} language={lang} />}
proBadge={
session?.user.subscriptionType !== 'PRO' ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { format } from 'date-fns'
import { Instrument_Sans } from 'next/font/google'
import { type CSSProperties, Suspense } from 'react'
import { Banner } from '@/components/banner'
import { Poster } from '@/components/poster'
import type { Language, MovieDetails } from '@/services/tmdb'
import { locale } from '@/utils/date/locale'
import { MovieActions } from './movie-actions'
import { MovieCollection } from './movie-collection'
import { MovieGenres } from './movie-genres'
import { MovieRating } from './movie-rating'
import { MovieTabs } from './movie-tabs'

const instrumentSans = Instrument_Sans({
subsets: ['latin'],
display: 'swap',
})

const cinematicTheme = {
'--background': '48 10% 4%',
'--foreground': '43 38% 94%',
'--card': '40 7% 8%',
'--card-foreground': '43 38% 94%',
'--popover': '40 7% 8%',
'--popover-foreground': '43 38% 94%',
'--primary': '4 100% 68%',
'--primary-foreground': '48 10% 4%',
'--secondary': '40 7% 12%',
'--secondary-foreground': '43 38% 94%',
'--muted': '40 7% 12%',
'--muted-foreground': '43 18% 70%',
'--accent': '40 7% 16%',
'--accent-foreground': '43 38% 94%',
'--border': '43 18% 22%',
'--input': '43 18% 22%',
'--ring': '4 100% 68%',
'--cinematic-background': '#0b0b09',
'--cinematic-foreground': '#f7f3ea',
'--cinematic-muted': 'rgba(247, 243, 234, 0.66)',
'--cinematic-accent': '#ff645a',
colorScheme: 'dark',
} as CSSProperties

type CinematicMovieDetailsProps = {
movie: MovieDetails
language: Language
backdropUrl?: string
posterUrl?: string
}

export const CinematicMovieDetails = ({
movie,
language,
backdropUrl,
posterUrl,
}: CinematicMovieDetailsProps) => (
<div
className={`${instrumentSans.className} min-h-screen w-full overflow-hidden bg-[var(--cinematic-background)] text-[var(--cinematic-foreground)]`}
data-testid="cinematic-movie-details"
style={cinematicTheme}
>
<div className="relative h-[46vh] min-h-[340px] max-h-[620px] w-full">
<Banner
url={backdropUrl}
posterUrl={posterUrl}
title={movie.title}
className="absolute inset-0 h-full max-h-none w-full rounded-none border-0 md:rounded-none lg:border-0"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/10 via-black/35 to-[var(--cinematic-background)]" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,transparent_0%,rgba(11,11,9,0.68)_100%)]" />
</div>

<section className="relative z-10 mx-auto -mt-28 max-w-6xl px-5 pb-16 sm:-mt-36 sm:px-8 lg:-mt-44">
<div className="grid grid-cols-1 items-end gap-x-5 gap-y-6 sm:grid-cols-[180px_minmax(0,1fr)] sm:gap-x-8 lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-x-12">
<aside className="w-32 self-end sm:w-auto lg:row-span-2">
<Poster
url={movie.poster_path}
alt={movie.title}
className="rounded-xl border-white/15 bg-[#161513] shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
/>
</aside>

<article className="min-w-0 self-end pb-1 sm:pb-3">
{movie.release_date && (
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--cinematic-accent)] sm:text-xs">
{format(new Date(movie.release_date), 'PPP', {
locale: locale[language],
})}
</p>
)}

<h1 className="text-balance text-2xl font-semibold leading-[0.98] tracking-[-0.04em] [overflow-wrap:anywhere] sm:text-4xl lg:text-6xl">
{movie.title}
</h1>

<div className="mt-4 flex flex-wrap items-center gap-2">
<MovieGenres
genres={movie.genres}
className="max-w-full whitespace-normal break-words text-left"
/>
<MovieRating
movie={movie}
className="border-white/15 bg-white/10 text-[var(--cinematic-foreground)] hover:bg-white/15"
/>
</div>
</article>

<div className="col-span-1 space-y-5 sm:col-span-2 lg:col-start-2 lg:col-end-3">
<p className="max-w-3xl text-sm leading-7 text-[var(--cinematic-muted)] sm:text-base sm:leading-8">
{movie.overview}
</p>

<MovieActions
movie={movie}
language={language}
className="gap-2.5 [&_button]:min-h-11 [&_button]:min-w-11"
/>
</div>
</div>
</section>

<section className="mx-auto max-w-5xl space-y-8 px-0 pb-16 sm:px-6">
{movie.belongs_to_collection && (
<Suspense>
<MovieCollection
collectionId={movie.belongs_to_collection.id}
language={language}
/>
</Suspense>
)}

<Suspense>
<MovieTabs movie={movie} language={language} />
</Suspense>
</section>
</div>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Suspense } from 'react'
import { Banner } from '@/components/banner'
import type { Language, MovieDetails } from '@/services/tmdb'
import { MovieCollection } from './movie-collection'
import { MovieInfos } from './movie-infos'
import { MovieTabs } from './movie-tabs'

type ClassicMovieDetailsProps = {
movie: MovieDetails
language: Language
backdropUrl?: string
posterUrl?: string
}

export const ClassicMovieDetails = ({
movie,
language,
backdropUrl,
posterUrl,
}: ClassicMovieDetailsProps) => (
<div
className="relative mx-auto max-w-6xl"
data-testid="classic-movie-details"
>
<Banner url={backdropUrl} posterUrl={posterUrl} title={movie.title} />

<section className="mx-auto my-8 max-w-4xl space-y-6">
<MovieInfos movie={movie} language={language} />

{movie.belongs_to_collection && (
<Suspense>
<MovieCollection
collectionId={movie.belongs_to_collection.id}
language={language}
/>
</Suspense>
)}

<Suspense>
<MovieTabs movie={movie} language={language} />
</Suspense>
</section>
</div>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MovieDetails } from '@/services/tmdb'
import { MovieActions } from './movie-actions'
import { MovieRating } from './movie-rating'

vi.mock('@/components/lists', () => ({
ListsDropdown: ({ item }: { item: MovieDetails }) => (
<div data-testid="lists-dropdown" data-movie-id={item.id} />
),
}))

vi.mock('@/components/item-review', () => ({
ItemReview: () => <div data-testid="item-review" />,
}))

vi.mock('@/components/item-status', () => ({
ItemStatus: ({
mediaType,
tmdbId,
}: {
mediaType: string
tmdbId: number
}) => (
<div
data-testid="item-status"
data-media-type={mediaType}
data-movie-id={tmdbId}
/>
),
}))

vi.mock('@/components/share-page-button', () => ({
SharePageButton: ({ language, path }: { language: string; path: string }) => (
<div
data-testid="share-page-button"
data-language={language}
data-path={path}
/>
),
}))

vi.mock('next/image', () => ({
default: ({ alt }: { alt: string }) => <span role="img" aria-label={alt} />,
}))

const movie = {
id: 42,
vote_average: 7.26,
vote_count: 321,
} as MovieDetails

describe('shared movie presentation primitives', () => {
afterEach(() => cleanup())

it('passes the movie and localized media props to every action', () => {
render(<MovieActions movie={movie} language="pt-BR" />)

expect(screen.getByTestId('lists-dropdown').dataset.movieId).toBe('42')
expect(screen.getByTestId('item-review')).toBeTruthy()
expect(screen.getByTestId('item-status').dataset.mediaType).toBe('MOVIE')
expect(screen.getByTestId('item-status').dataset.movieId).toBe('42')
expect(screen.getByTestId('share-page-button').dataset.language).toBe(
'pt-BR'
)
expect(screen.getByTestId('share-page-button').dataset.path).toBe(
'movies/42'
)
})

it('renders the TMDB score rounded to one decimal place', () => {
render(<MovieRating movie={movie} />)

expect(screen.getByText('7.3')).toBeTruthy()
expect(screen.queryByText('7.26')).toBeNull()
})

it('exposes the TMDB score and vote count through a focusable trigger', () => {
render(<MovieRating movie={movie} />)

const trigger = screen.getByRole('button', {
name: 'TMDB 7.3/10 · 321',
})

expect(trigger.tabIndex).toBe(0)
})
})
Loading
Loading