11 | >
12 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/home/_components/network-activity.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useGetNetworkActivitiesSuspense } from '@/api/user-activities'
4 | import { useLanguage } from '@/context/language'
5 | import { useSession } from '@/context/session'
6 | import { UserActivity } from '../../[username]/_components/user-activity'
7 |
8 | export function NetworkActivity() {
9 | const { user } = useSession()
10 | const { dictionary } = useLanguage()
11 |
12 | if (!user) return null
13 |
14 | const { data } = useGetNetworkActivitiesSuspense({
15 | userId: user.id,
16 | pageSize: '15',
17 | })
18 |
19 | return (
20 |
21 |
22 |
{dictionary.network_activity}
23 |
24 |
25 |
26 | {data.userActivities.map(activity => (
27 |
28 | ))}
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-items/index.ts:
--------------------------------------------------------------------------------
1 | export * from './list-item-actions'
2 | export * from './list-item-card'
3 | export * from './list-items'
4 | export * from './list-items-grid'
5 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-items/list-item-actions.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Trash } from 'lucide-react'
4 |
5 | import { Button } from '@plotwist/ui/components/ui/button'
6 |
7 | import { useLanguage } from '@/context/language'
8 | import { useListMode } from '@/context/list-mode'
9 |
10 | import type { GetListItemsByListId200Item } from '@/api/endpoints.schemas'
11 | import {
12 | getGetListItemsByListIdQueryKey,
13 | useDeleteListItemId,
14 | } from '@/api/list-item'
15 | import { useQueryClient } from '@tanstack/react-query'
16 | import { toast } from 'sonner'
17 |
18 | type ListItemActionsProps = {
19 | listItem: GetListItemsByListId200Item
20 | }
21 |
22 | export const ListItemActions = ({ listItem }: ListItemActionsProps) => {
23 | const deleteListItem = useDeleteListItemId()
24 | const queryClient = useQueryClient()
25 |
26 | const { dictionary, language } = useLanguage()
27 | const { mode } = useListMode()
28 |
29 | if (mode === 'SHOW') return
30 |
31 | return (
32 |
58 | )
59 | }
60 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-items/list-items-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
2 | import { v4 } from 'uuid'
3 |
4 | export function ListItemsSkeleton() {
5 | return (
6 |
7 |
8 | {Array.from({ length: 10 }).map((_, index) => (
9 |
10 | ))}
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-items/list-items.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 |
5 | import { useGetListItemsByListIdSuspense } from '@/api/list-item'
6 | import { ListItemsGrid } from './list-items-grid'
7 |
8 | type ListItemsProps = {
9 | listId: string
10 | }
11 |
12 | export const ListItems = ({ listId }: ListItemsProps) => {
13 | const { language } = useLanguage()
14 | const { data } = useGetListItemsByListIdSuspense(listId, { language })
15 |
16 | return
17 | }
18 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-page-results.tsx:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '@/utils/dictionaries'
2 | import { Link } from 'next-view-transitions'
3 |
4 | export type ListPageEmptyResultsProps = { dictionary: Dictionary }
5 | export const ListPageEmptyResults = ({
6 | dictionary,
7 | }: ListPageEmptyResultsProps) => {
8 | return (
9 |
10 |
11 |
12 |
13 | {dictionary.list_page.list_not_found}
14 |
15 |
16 |
17 | {dictionary.list_page.see_your_lists_or_create_new}{' '}
18 |
19 | {dictionary.list_page.here}
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/list-private.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Pattern } from '@/components/pattern'
4 | import { useLanguage } from '@/context/language'
5 | import { Button } from '@plotwist/ui/components/ui/button'
6 | import { ArrowLeft } from 'lucide-react'
7 | import { Link } from 'next-view-transitions'
8 |
9 | export const ListPrivate = () => {
10 | const { language, dictionary } = useLanguage()
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 | {dictionary.private_list.title}
20 |
21 |
22 |
23 | {dictionary.private_list.description}
24 |
25 |
26 |
27 |
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/user-resume/index.ts:
--------------------------------------------------------------------------------
1 | export * from './user-resume'
2 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/[id]/_components/user-resume/user-resume.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { GetListById200List } from '@/api/endpoints.schemas'
4 | import { useGetUserById } from '@/api/users'
5 | import { ProBadge } from '@/components/pro-badge'
6 | import { useLanguage } from '@/context/language'
7 | import {
8 | Avatar,
9 | AvatarFallback,
10 | AvatarImage,
11 | } from '@plotwist/ui/components/ui/avatar'
12 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
13 | import { Link } from 'next-view-transitions'
14 |
15 | type UserResumeProps = {
16 | list: GetListById200List
17 | }
18 |
19 | export const UserResume = ({ list }: UserResumeProps) => {
20 | const { language } = useLanguage()
21 | const { data, isLoading } = useGetUserById(list.userId)
22 |
23 | if (isLoading || !data) {
24 | return
25 | }
26 |
27 | const { user } = data
28 |
29 | const username = user.username
30 | const profileHref = `/${language}/${username}`
31 |
32 | return (
33 |
34 |
35 |
36 | {user.avatarUrl && (
37 |
38 | )}
39 |
40 |
41 | {username?.at(0)}
42 |
43 |
44 |
45 |
46 |
{username}
47 |
48 | {user.subscriptionType === 'PRO' &&
}
49 |
50 | )
51 | }
52 |
53 | export const UserResumeSkeleton = () => {
54 | return (
55 |
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/_components/latest-lists.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useGetLists } from '@/api/list'
4 | import { useLanguage } from '@/context/language'
5 | import { useMemo } from 'react'
6 | import { v4 } from 'uuid'
7 | import { PopularListCard, PopularListCardSkeleton } from './popular-list-card'
8 |
9 | const LIMIT = 5
10 |
11 | export const LatestLists = () => {
12 | const { dictionary } = useLanguage()
13 | const { data, isLoading } = useGetLists({
14 | limit: LIMIT,
15 | visibility: 'PUBLIC',
16 | hasBanner: true,
17 | })
18 |
19 | const content = useMemo(() => {
20 | if (isLoading)
21 | return (
22 |
23 | {Array.from({ length: LIMIT }).map(_ => (
24 |
25 | ))}
26 |
27 | )
28 |
29 | if (!data?.lists.length) {
30 | return (
31 |
32 | {dictionary.no_lists_found}
33 |
34 | )
35 | }
36 |
37 | return (
38 |
39 | {data.lists.map(list => (
40 |
41 | ))}
42 |
43 | )
44 | }, [data, isLoading, dictionary])
45 |
46 | return (
47 |
48 |
49 |
{dictionary.latest_lists}
50 |
51 |
52 | {content}
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/_components/list-form-schema.tsx:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '@/utils/dictionaries'
2 | import { z } from 'zod'
3 |
4 | export const listFormSchema = (dictionary: Dictionary) =>
5 | z.object({
6 | title: z.string().min(1, dictionary.list_form.name_required),
7 | description: z.string(),
8 | visibility: z.enum(['PUBLIC', 'NETWORK', 'PRIVATE']),
9 | })
10 |
11 | export type ListFormValues = z.infer>
12 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/lists/_components/see-all-lists.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { useSession } from '@/context/session'
5 | import { cn } from '@/lib/utils'
6 | import { Link } from 'next-view-transitions'
7 | import type { ComponentProps } from 'react'
8 |
9 | export const SeeAllLists = ({ className }: ComponentProps<'div'>) => {
10 | const { user } = useSession()
11 | const { dictionary, language } = useLanguage()
12 |
13 | return (
14 | <>
15 | {user && (
16 |
23 | {dictionary.see_all_list}
24 |
25 | )}
26 | >
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/[id]/_components/movie-collection.tsx:
--------------------------------------------------------------------------------
1 | import { tmdb } from '@/services/tmdb'
2 |
3 | import { MovieCollectionDialog } from './movie-collection-dialog'
4 |
5 | import { getDictionary } from '@/utils/dictionaries'
6 | import { tmdbImage } from '@/utils/tmdb/image'
7 |
8 | import type { Language } from '@/types/languages'
9 | type MovieCollectionProps = {
10 | collectionId: number
11 | language: Language
12 | }
13 |
14 | export const MovieCollection = async ({
15 | collectionId,
16 | language,
17 | }: MovieCollectionProps) => {
18 | const collection = await tmdb.collections.details(collectionId, language)
19 | const backdropURL = tmdbImage(collection.backdrop_path)
20 | const dictionary = await getDictionary(language)
21 |
22 | return (
23 |
24 |
32 |
33 |
34 |
35 |
36 | {dictionary.movie_collection.part_of}
37 |
38 |
39 |
40 | {collection.name}
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx:
--------------------------------------------------------------------------------
1 | import { tmdb } from '@/services/tmdb'
2 |
3 | import { Banner } from '@/components/banner'
4 |
5 | import type { Language } from '@/types/languages'
6 | import { tmdbImage } from '@/utils/tmdb/image'
7 |
8 | import { Suspense } from 'react'
9 | import { MovieCollection } from './movie-collection'
10 | import { MovieInfos } from './movie-infos'
11 | import { MovieTabs } from './movie-tabs'
12 |
13 | type MovieDetailsProps = {
14 | id: number
15 | language: Language
16 | }
17 |
18 | export const MovieDetails = async ({ id, language }: MovieDetailsProps) => {
19 | const movie = await tmdb.movies.details(id, language)
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 | {movie.belongs_to_collection && (
29 |
30 |
34 |
35 | )}
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/[id]/_components/movie-genres.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import type { MovieDetails } from '@/services/tmdb'
5 | import { Badge } from '@plotwist/ui/components/ui/badge'
6 | import { Link } from 'next-view-transitions'
7 |
8 | type MovieGenresProps = { genres: MovieDetails['genres'] }
9 |
10 | export const MovieGenres = ({ genres }: MovieGenresProps) => {
11 | const { language } = useLanguage()
12 |
13 | const hasGenres = genres.length > 0
14 | if (!hasGenres) return null
15 |
16 | return (
17 | <>
18 | {genres.map(({ id, name }) => {
19 | return (
20 |
21 |
22 | {name}
23 |
24 |
25 | )
26 | })}
27 | >
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/[id]/_components/movie-related.tsx:
--------------------------------------------------------------------------------
1 | import { type MovieRelatedType, tmdb } from '@/services/tmdb'
2 | import { Link } from 'next-view-transitions'
3 |
4 | import { PosterCard } from '@/components/poster-card'
5 | import type { Language } from '@/types/languages'
6 | import { tmdbImage } from '@/utils/tmdb/image'
7 |
8 | type MovieRelatedProps = {
9 | movieId: number
10 | variant: MovieRelatedType
11 | language: Language
12 | }
13 |
14 | export const MovieRelated = async ({
15 | movieId,
16 | variant,
17 | language,
18 | }: MovieRelatedProps) => {
19 | const { results } = await tmdb.movies.related(movieId, variant, language)
20 |
21 | return (
22 |
23 | {results.map(movie => (
24 |
25 |
26 |
30 |
31 |
32 | ))}
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | import { MovieDetails } from './_components/movie-details'
4 |
5 | import { getMovieMetadata } from '@/utils/seo/get-movie-metadata'
6 | import { getMoviesIds } from '@/utils/seo/get-movies-ids'
7 |
8 | import type { PageProps } from '@/types/languages'
9 | import { Suspense } from 'react'
10 |
11 | type MoviePageProps = PageProps<{ id: string }>
12 |
13 | export async function generateStaticParams() {
14 | const moviesIds = await getMoviesIds()
15 | return moviesIds.map(id => ({ id: String(id) }))
16 | }
17 |
18 | export async function generateMetadata(
19 | props: MoviePageProps
20 | ): Promise {
21 | const { lang, id } = await props.params
22 | const metadata = await getMovieMetadata(Number(id), lang)
23 |
24 | return metadata
25 | }
26 |
27 | export default async function MoviePage(props: MoviePageProps) {
28 | const { id, lang } = await props.params
29 |
30 | return (
31 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/discover/page.tsx:
--------------------------------------------------------------------------------
1 | import { MovieList } from '@/components/movie-list'
2 | import { MoviesListFilters } from '@/components/movies-list-filters'
3 | import type { PageProps } from '@/types/languages'
4 | import { getDictionary } from '@/utils/dictionaries'
5 | import type { Metadata } from 'next'
6 | import { Container } from '../../_components/container'
7 |
8 | export async function generateMetadata(props: PageProps): Promise {
9 | const params = await props.params
10 | const {
11 | movie_pages: {
12 | discover: { title, description },
13 | },
14 | } = await getDictionary(params.lang)
15 |
16 | return {
17 | title,
18 | description,
19 | openGraph: {
20 | title,
21 | description,
22 | siteName: 'Plotwist',
23 | },
24 | twitter: {
25 | title,
26 | description,
27 | },
28 | }
29 | }
30 |
31 | const DiscoverMoviesPage = async (props: PageProps) => {
32 | const params = await props.params
33 | const { lang } = params
34 | const dictionary = await getDictionary(lang)
35 |
36 | return (
37 |
38 |
39 |
40 |
41 | {dictionary.movie_pages.discover.title}
42 |
43 |
44 |
45 | {dictionary.movie_pages.discover.description}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
57 | export default DiscoverMoviesPage
58 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/now-playing/page.tsx:
--------------------------------------------------------------------------------
1 | import { MovieList } from '@/components/movie-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | movie_pages: {
11 | now_playing: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const NowPlayingMoviesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const dictionary = await getDictionary(lang)
36 |
37 | return (
38 |
39 |
40 |
41 | {dictionary.movie_pages.now_playing.title}
42 |
43 |
44 |
45 | {dictionary.movie_pages.now_playing.description}
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default NowPlayingMoviesPage
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/popular/page.tsx:
--------------------------------------------------------------------------------
1 | import { MovieList } from '@/components/movie-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | movie_pages: {
11 | popular: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const PopularMoviesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const dictionary = await getDictionary(lang)
36 |
37 | return (
38 |
39 |
40 |
41 | {dictionary.movie_pages.popular.title}
42 |
43 |
44 |
45 | {dictionary.movie_pages.popular.description}
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default PopularMoviesPage
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/sitemap.xml/route.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/services/tmdb'
2 | import { getMoviesIds } from '@/utils/seo/get-movies-ids'
3 | import { SitemapStream, streamToPromise } from 'sitemap'
4 |
5 | export async function GET(request: Request) {
6 | const url = new URL(request.url)
7 | const pathSegments = url.pathname.split('/').filter(Boolean)
8 | const language = pathSegments[0] as Language
9 |
10 | const sitemapStream = new SitemapStream({
11 | hostname: `https://${url.host}/${language}`,
12 | })
13 | const xmlPromise = streamToPromise(sitemapStream)
14 |
15 | const moviesIds = await getMoviesIds()
16 |
17 | for (const movieId of moviesIds) {
18 | sitemapStream.write({
19 | url: `/${language}/movies/${movieId}`,
20 | changefreq: 'weekly',
21 | lastmodISO: new Date().toISOString(),
22 | })
23 | }
24 |
25 | sitemapStream.end()
26 | const xml = await xmlPromise
27 | const xmlString = xml.toString()
28 |
29 | const response = new Response(xmlString, {
30 | status: 200,
31 | statusText: 'ok',
32 | })
33 | response.headers.append('content-type', 'text/xml')
34 |
35 | return response
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/top-rated/page.tsx:
--------------------------------------------------------------------------------
1 | import { MovieList } from '@/components/movie-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | movie_pages: {
11 | top_rated: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const TopRatedMoviesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const dictionary = await getDictionary(lang)
36 |
37 | return (
38 |
39 |
40 |
41 | {dictionary.movie_pages.top_rated.title}
42 |
43 |
44 |
45 | {dictionary.movie_pages.top_rated.description}
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default TopRatedMoviesPage
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/movies/upcoming/page.tsx:
--------------------------------------------------------------------------------
1 | import { MovieList } from '@/components/movie-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | movie_pages: {
11 | upcoming: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const UpcomingMoviesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const dictionary = await getDictionary(lang)
36 |
37 | return (
38 |
39 |
40 |
41 | {dictionary.movie_pages.upcoming.title}
42 |
43 |
44 |
45 | {dictionary.movie_pages.upcoming.description}
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default UpcomingMoviesPage
55 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/people/[id]/_biography.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import {
5 | Credenza,
6 | CredenzaBody,
7 | CredenzaContent,
8 | CredenzaHeader,
9 | CredenzaTitle,
10 | } from '@plotwist/ui/components/ui/credenza'
11 | import { ScrollArea } from '@plotwist/ui/components/ui/scroll-area'
12 | import { useState } from 'react'
13 |
14 | type BiographyProps = {
15 | content: string
16 | title: string
17 | }
18 |
19 | export function Biography({ content, title }: BiographyProps) {
20 | const [isOpen, setIsOpen] = useState(false)
21 | const { dictionary } = useLanguage()
22 |
23 | if (!content.length) return <>>
24 |
25 | return (
26 | <>
27 |
28 |
29 | {content}
30 |
31 |
32 |
39 |
40 |
41 |
42 |
43 |
44 | {title}
45 |
46 |
47 |
48 |
49 | {content}
50 |
51 |
52 |
53 |
54 |
55 | >
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/people/[id]/_person_tabs.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { Tabs, TabsList, TabsTrigger } from '@plotwist/ui/components/ui/tabs'
5 | import { Link } from 'next-view-transitions'
6 | import { usePathname } from 'next/navigation'
7 | import { useMemo } from 'react'
8 |
9 | type PersonTabsProps = {
10 | personId: string
11 | }
12 |
13 | export function PersonTabs({ personId }: PersonTabsProps) {
14 | const pathname = usePathname()
15 | const { language, dictionary } = useLanguage()
16 |
17 | const splittedPathname = pathname.split('/')
18 | const minSegments = 4
19 |
20 | const value = useMemo(() => {
21 | if (splittedPathname.length === minSegments) {
22 | return 'credits'
23 | }
24 |
25 | return splittedPathname[splittedPathname.length - 1]
26 | }, [splittedPathname])
27 |
28 | return (
29 |
30 |
31 |
32 |
33 | {dictionary.tabs.credits}
34 |
35 |
36 |
37 |
38 |
39 | {dictionary.biography}
40 |
41 |
42 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/people/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { tmdb } from '@/services/tmdb'
2 | import type { PageProps } from '@/types/languages'
3 | import { CreditsPage } from './_credits-page'
4 |
5 | type Props = PageProps<{ id: string }>
6 |
7 | export default async function Page({ params }: Props) {
8 | const { id, lang } = await params
9 |
10 | const movieCredits = await tmdb.person.movieCredits(Number(id), lang)
11 | const tvCredits = await tmdb.person.tvCredits(Number(id), lang)
12 |
13 | const credits = [
14 | ...movieCredits.cast,
15 | ...movieCredits.crew,
16 | ...tvCredits.cast,
17 | ...tvCredits.crew,
18 | ]
19 | .sort((a, b) => b.vote_count - a.vote_count)
20 | .filter(({ poster_path }) => poster_path)
21 |
22 | const uniqueRoles = new Set()
23 |
24 | for (const credit of credits) {
25 | if ('job' in credit) {
26 | uniqueRoles.add(credit.job)
27 | }
28 |
29 | if ('character' in credit) {
30 | uniqueRoles.add('Actor')
31 | }
32 | }
33 |
34 | return
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/pricing/page.tsx:
--------------------------------------------------------------------------------
1 | import { Pattern } from '@/components/pattern'
2 | import { Pricing } from '@/components/pricing'
3 | import type { PageProps } from '@/types/languages'
4 | import { getDictionary } from '@/utils/dictionaries'
5 | import type { Metadata } from 'next'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 |
10 | const { lang } = params
11 |
12 | const {
13 | home_prices: { title, description },
14 | } = await getDictionary(lang)
15 |
16 | return {
17 | title,
18 | description,
19 | openGraph: {
20 | title,
21 | description,
22 | siteName: 'Plotwist',
23 | },
24 | twitter: {
25 | title,
26 | description,
27 | },
28 | }
29 | }
30 |
31 | const PricingPage = async () => {
32 | return (
33 | <>
34 |
35 |
36 |
39 | >
40 | )
41 | }
42 |
43 | export default PricingPage
44 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/reset-password/_components/reset-password-form.schema.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '@/utils/dictionaries'
2 | import { z } from 'zod'
3 |
4 | export const resetPasswordFormSchema = (dictionary: Dictionary) =>
5 | z.object({
6 | password: z
7 | .string()
8 | .min(1, dictionary.password_required)
9 | .min(8, dictionary.password_length),
10 | })
11 |
12 | export type ResetPasswordFormValues = z.infer<
13 | ReturnType
14 | >
15 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/sign-in/page.tsx:
--------------------------------------------------------------------------------
1 | import { signIn } from '@/actions/auth/sign-in'
2 | import { Pattern } from '@/components/pattern'
3 | import type { PageProps } from '@/types/languages'
4 | import { getDictionary } from '@/utils/dictionaries'
5 | import { Link } from 'next-view-transitions'
6 | import { SignInForm } from './_sign-in-form'
7 |
8 | export default async function SignInPage(props: PageProps) {
9 | const params = await props.params
10 |
11 | const { lang } = params
12 |
13 | const dictionary = await getDictionary(lang)
14 |
15 | return (
16 | <>
17 |
18 |
19 |
20 |
21 |
22 |
23 | {dictionary.access_plotwist}
24 |
25 |
26 |
27 |
28 |
29 |
33 | {dictionary.do_not_have_an_account} {dictionary.create_now}
34 |
35 |
36 |
37 |
38 |
39 | >
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/sign-up/_components/sign-up-form.schema.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '@/utils/dictionaries'
2 | import { z } from 'zod'
3 |
4 | export const credentialsFormSchema = (dictionary: Dictionary) =>
5 | z.object({
6 | email: z
7 | .string()
8 | .min(1, dictionary.sign_up_form.email_required)
9 | .email(dictionary.sign_up_form.email_invalid),
10 |
11 | password: z
12 | .string()
13 | .min(1, dictionary.sign_up_form.password_required)
14 | .min(8, dictionary.sign_up_form.password_length),
15 | })
16 |
17 | export type CredentialsFormValues = z.infer<
18 | ReturnType
19 | >
20 |
21 | export const usernameFormSchema = (dictionary: Dictionary) =>
22 | z.object({
23 | username: z
24 | .string()
25 | .min(1, dictionary.username_required)
26 | .regex(/^[a-zA-Z0-9_]+$/, dictionary.username_invalid),
27 | })
28 |
29 | export type UsernameFormValues = z.infer>
30 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/sitemap.xml/route.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 | import { SitemapStream, streamToPromise } from 'sitemap'
3 |
4 | export const dynamic = 'force-dynamic'
5 |
6 | const APP_ROUTES = [
7 | '/',
8 | '/home',
9 | '/lists',
10 | '/sign-in',
11 | '/sign-up',
12 | '/movies/discover',
13 | '/movies/now-playing',
14 | '/movies/popular',
15 | '/movies/top-rated',
16 | '/tv-series/airing-today',
17 | '/tv-series/discover',
18 | '/tv-series/on-the-air',
19 | '/tv-series/popular',
20 | '/tv-series/top-rated',
21 | ]
22 |
23 | export async function GET(request: Request) {
24 | const url = new URL(request.url)
25 | const pathSegments = url.pathname.split('/').filter(Boolean)
26 | const language = pathSegments[0] as Language
27 |
28 | const sitemapStream = new SitemapStream({
29 | hostname: `https://${url.host}/${language}`,
30 | })
31 | const xmlPromise = streamToPromise(sitemapStream)
32 |
33 | for (const route of APP_ROUTES) {
34 | sitemapStream.write({
35 | url: `${language}${route}`,
36 | changefreq: 'daily',
37 | priority: 0.7,
38 | })
39 | }
40 |
41 | sitemapStream.end()
42 | const xml = await xmlPromise
43 | const xmlString = xml.toString()
44 |
45 | const response = new Response(xmlString, {
46 | status: 200,
47 | statusText: 'ok',
48 | })
49 | response.headers.append('content-type', 'text/xml')
50 |
51 | return response
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-details.tsx:
--------------------------------------------------------------------------------
1 | import { Banner } from '@/components/banner'
2 |
3 | import { tmdbImage } from '@/utils/tmdb/image'
4 |
5 | import { tmdb } from '@/services/tmdb'
6 | import type { Language } from '@/types/languages'
7 | import { Suspense } from 'react'
8 | import { TvSerieInfos } from './tv-serie-infos'
9 | import { TvSerieTabs } from './tv-serie-tabs'
10 |
11 | type TvSerieDetailsProps = {
12 | id: number
13 | language: Language
14 | }
15 |
16 | export const TvSerieDetails = async ({ id, language }: TvSerieDetailsProps) => {
17 | const tvSerie = await tmdb.tv.details(id, language)
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-genres.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import type { TvSerieDetails } from '@/services/tmdb'
5 | import { Badge } from '@plotwist/ui/components/ui/badge'
6 | import { Link } from 'next-view-transitions'
7 |
8 | type TvSerieGenresProps = { genres: TvSerieDetails['genres'] }
9 |
10 | export const TvSeriesGenres = ({ genres }: TvSerieGenresProps) => {
11 | const { language } = useLanguage()
12 |
13 | const hasGenres = genres.length > 0
14 | if (!hasGenres) return null
15 |
16 | return (
17 | <>
18 | {genres.map(({ id, name }) => {
19 | return (
20 |
21 |
22 | {name}
23 |
24 |
25 | )
26 | })}
27 | >
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-related.tsx:
--------------------------------------------------------------------------------
1 | import { PosterCard } from '@/components/poster-card'
2 | import { tmdb } from '@/services/tmdb'
3 | import type { Language } from '@/types/languages'
4 | import { tmdbImage } from '@/utils/tmdb/image'
5 | import { Link } from 'next-view-transitions'
6 |
7 | type TvSerieRelatedProps = {
8 | id: number
9 | variant: 'similar' | 'recommendations'
10 | language: Language
11 | }
12 |
13 | export const TvSerieRelated = async ({
14 | id,
15 | variant,
16 | language,
17 | }: TvSerieRelatedProps) => {
18 | const { results } = await tmdb.tv.related(id, variant, language)
19 |
20 | return (
21 |
22 | {results.map(tv => (
23 |
24 |
25 |
29 |
30 |
31 | ))}
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next'
2 |
3 | import type { PageProps } from '@/types/languages'
4 | import { getTvMetadata } from '@/utils/seo/get-tv-metadata'
5 | import { getTvSeriesIds } from '@/utils/seo/get-tv-series-ids'
6 | import { TvSerieDetails } from './_components/tv-serie-details'
7 |
8 | export type TvSeriePageProps = PageProps<{ id: string }>
9 |
10 | export async function generateStaticParams() {
11 | const tvSeriesIds = await getTvSeriesIds()
12 | return tvSeriesIds.map(id => ({ id: String(id) }))
13 | }
14 |
15 | export async function generateMetadata(
16 | props: TvSeriePageProps
17 | ): Promise {
18 | const { lang, id } = await props.params
19 | const metadata = await getTvMetadata(Number(id), lang)
20 |
21 | return metadata
22 | }
23 |
24 | export default async function TvSeriePage(props: TvSeriePageProps) {
25 | const { id, lang } = await props.params
26 |
27 | return
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/airing-today/page.tsx:
--------------------------------------------------------------------------------
1 | import { TvSeriesList } from '@/components/tv-series-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | tv_serie_pages: {
11 | airing_today: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const AiringTodayTvSeriesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const {
36 | tv_serie_pages: {
37 | airing_today: { title, description },
38 | },
39 | } = await getDictionary(lang)
40 |
41 | return (
42 |
43 |
44 |
45 |
{title}
46 |
{description}
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | export default AiringTodayTvSeriesPage
56 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/discover/page.tsx:
--------------------------------------------------------------------------------
1 | import { TvSeriesList } from '@/components/tv-series-list'
2 | import { TvSeriesListFilters } from '@/components/tv-series-list-filters'
3 | import type { PageProps } from '@/types/languages'
4 | import { getDictionary } from '@/utils/dictionaries'
5 | import type { Metadata } from 'next'
6 | import { Container } from '../../_components/container'
7 |
8 | export async function generateMetadata(props: PageProps): Promise {
9 | const params = await props.params
10 | const {
11 | tv_serie_pages: {
12 | discover: { title, description },
13 | },
14 | } = await getDictionary(params.lang)
15 |
16 | return {
17 | title,
18 | description,
19 | openGraph: {
20 | title,
21 | description,
22 | siteName: 'Plotwist',
23 | },
24 | twitter: {
25 | title,
26 | description,
27 | },
28 | }
29 | }
30 |
31 | const DiscoverTvSeriesPage = async (props: PageProps) => {
32 | const params = await props.params
33 |
34 | const { lang } = params
35 |
36 | const {
37 | tv_serie_pages: {
38 | discover: { title, description },
39 | },
40 | } = await getDictionary(lang)
41 |
42 | return (
43 |
44 |
45 |
46 |
{title}
47 |
{description}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | export default DiscoverTvSeriesPage
59 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/on-the-air/page.tsx:
--------------------------------------------------------------------------------
1 | import { TvSeriesList } from '@/components/tv-series-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | tv_serie_pages: {
11 | on_the_air: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const OnTheAirTvSeriesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const {
36 | tv_serie_pages: {
37 | on_the_air: { title, description },
38 | },
39 | } = await getDictionary(lang)
40 |
41 | return (
42 |
43 |
44 |
45 |
{title}
46 |
{description}
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | export default OnTheAirTvSeriesPage
56 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/popular/page.tsx:
--------------------------------------------------------------------------------
1 | import { TvSeriesList } from '@/components/tv-series-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | tv_serie_pages: {
11 | popular: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const PopularTvSeriesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const {
36 | tv_serie_pages: {
37 | popular: { title, description },
38 | },
39 | } = await getDictionary(lang)
40 |
41 | return (
42 |
43 |
44 |
45 |
{title}
46 |
{description}
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | export default PopularTvSeriesPage
56 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/sitemap.xml/route.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/services/tmdb'
2 | import { getTvSeriesIds } from '@/utils/seo/get-tv-series-ids'
3 | import { SitemapStream, streamToPromise } from 'sitemap'
4 |
5 | export async function GET(request: Request) {
6 | const url = new URL(request.url)
7 | const pathSegments = url.pathname.split('/').filter(Boolean)
8 | const language = pathSegments[0] as Language
9 |
10 | const sitemapStream = new SitemapStream({
11 | hostname: `https://${url.host}/${language}`,
12 | })
13 | const xmlPromise = streamToPromise(sitemapStream)
14 |
15 | const tvSeriesIds = await getTvSeriesIds()
16 |
17 | for (const tvSerieId of tvSeriesIds) {
18 | sitemapStream.write({
19 | url: `/${language}/tv-series/${tvSerieId}`,
20 | changefreq: 'weekly',
21 | lastmodISO: new Date().toISOString(),
22 | })
23 | }
24 |
25 | sitemapStream.end()
26 | const xml = await xmlPromise
27 | const xmlString = xml.toString()
28 |
29 | const response = new Response(xmlString, {
30 | status: 200,
31 | statusText: 'ok',
32 | })
33 | response.headers.append('content-type', 'text/xml')
34 |
35 | return response
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/app/[lang]/tv-series/top-rated/page.tsx:
--------------------------------------------------------------------------------
1 | import { TvSeriesList } from '@/components/tv-series-list'
2 | import type { PageProps } from '@/types/languages'
3 | import { getDictionary } from '@/utils/dictionaries'
4 | import type { Metadata } from 'next'
5 | import { Container } from '../../_components/container'
6 |
7 | export async function generateMetadata(props: PageProps): Promise {
8 | const params = await props.params
9 | const {
10 | tv_serie_pages: {
11 | top_rated: { title, description },
12 | },
13 | } = await getDictionary(params.lang)
14 |
15 | return {
16 | title,
17 | description,
18 | openGraph: {
19 | title,
20 | description,
21 | siteName: 'Plotwist',
22 | },
23 | twitter: {
24 | title,
25 | description,
26 | },
27 | }
28 | }
29 |
30 | const TopRatedTvSeriesPage = async (props: PageProps) => {
31 | const params = await props.params
32 |
33 | const { lang } = params
34 |
35 | const {
36 | tv_serie_pages: {
37 | top_rated: { title, description },
38 | },
39 | } = await getDictionary(lang)
40 |
41 | return (
42 |
43 |
44 |
45 |
{title}
46 |
{description}
47 |
48 |
49 |
50 |
51 |
52 | )
53 | }
54 |
55 | export default TopRatedTvSeriesPage
56 |
--------------------------------------------------------------------------------
/apps/web/src/app/api/proxy/route.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import { NextResponse } from 'next/server'
3 |
4 | export async function GET(request: Request) {
5 | const { searchParams } = new URL(request.url)
6 | const url = searchParams.get('url')
7 |
8 | if (!url) {
9 | return NextResponse.json({ error: 'URL é necessária' }, { status: 400 })
10 | }
11 |
12 | try {
13 | const response = await axios.get(url, { responseType: 'arraybuffer' })
14 | const contentType =
15 | response.headers['content-type'] || 'application/octet-stream'
16 |
17 | return new Response(response.data, {
18 | headers: {
19 | 'Content-Type': contentType,
20 | },
21 | })
22 | } catch (error) {
23 | console.error('Erro ao buscar imagem:', error)
24 | return NextResponse.json(
25 | { error: 'Erro ao buscar imagem' },
26 | { status: 500 }
27 | )
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import '@plotwist/ui/globals.css'
2 |
3 | import { GTag } from '@/components/gtag'
4 | import type { Language } from '@/types/languages'
5 | import type { Metadata, Viewport } from 'next'
6 | import { ViewTransitions } from 'next-view-transitions'
7 | import { Space_Grotesk as SpaceGrotesk } from 'next/font/google'
8 |
9 | const spaceGrotesk = SpaceGrotesk({ subsets: ['latin'], preload: true })
10 |
11 | export const metadata: Metadata = {
12 | title: {
13 | template: '%s • Plotwist',
14 | default: 'Plotwist',
15 | },
16 | }
17 |
18 | export const viewport: Viewport = {
19 | colorScheme: 'dark',
20 | themeColor: '#09090b',
21 | initialScale: 1,
22 | maximumScale: 1,
23 | userScalable: false,
24 | }
25 |
26 | export default async function RootLayout(props: {
27 | children: React.ReactNode
28 | params: Promise<{ lang: Language }>
29 | }) {
30 | const params = await props.params
31 | const { children } = props
32 |
33 | return (
34 |
35 |
40 |
41 |
42 |
48 |
49 |
50 |
51 |
52 | {children}
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/web/src/app/lib/dal.ts:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 |
3 | import { getMe } from '@/api/users'
4 | import { decrypt } from '@/app/lib/session'
5 | import { AXIOS_INSTANCE } from '@/services/axios-instance'
6 | import { cookies } from 'next/headers'
7 |
8 | export const verifySession = async () => {
9 | const cookie = (await cookies()).get('session')?.value
10 | const session = await decrypt(cookie)
11 |
12 | if (session) {
13 | AXIOS_INSTANCE.defaults.headers.Authorization = `Bearer ${session.token}`
14 |
15 | try {
16 | const { user } = await getMe()
17 | return { token: session.token, user }
18 | } catch {
19 | return undefined
20 | }
21 | }
22 |
23 | return undefined
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/app/lib/definitions.ts:
--------------------------------------------------------------------------------
1 | import type { PostLogin200 } from '@/api/endpoints.schemas'
2 |
3 | export type SessionPayload = PostLogin200
4 |
--------------------------------------------------------------------------------
/apps/web/src/app/lib/session.ts:
--------------------------------------------------------------------------------
1 | import 'server-only'
2 | import { SignJWT, jwtVerify } from 'jose'
3 | import type { SessionPayload } from './definitions'
4 |
5 | import { cookies } from 'next/headers'
6 |
7 | const secretKey = process.env.SESSION_SECRET
8 | const encodedKey = new TextEncoder().encode(secretKey)
9 |
10 | export async function encrypt(payload: SessionPayload) {
11 | return new SignJWT(payload)
12 | .setProtectedHeader({ alg: 'HS256' })
13 | .setIssuedAt()
14 | .setExpirationTime('7d')
15 | .sign(encodedKey)
16 | }
17 |
18 | export async function decrypt(session: string | undefined = '') {
19 | try {
20 | const { payload } = await jwtVerify(session, encodedKey, {
21 | algorithms: ['HS256'],
22 | })
23 |
24 | return payload as SessionPayload
25 | } catch (error) {
26 | console.log('Failed to verify session')
27 | }
28 | }
29 |
30 | export async function createSession(payload: SessionPayload) {
31 | const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
32 | const session = await encrypt({ ...payload })
33 | const cookieStore = await cookies()
34 |
35 | cookieStore.set('session', session, {
36 | httpOnly: true,
37 | secure: true,
38 | expires: expiresAt,
39 | sameSite: 'lax',
40 | path: '/',
41 | })
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/app/robots.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next'
2 | import { APP_URL } from '../../constants'
3 |
4 | export default async function robots(): Promise {
5 | return {
6 | rules: {
7 | userAgent: '*',
8 | allow: '/',
9 | },
10 | sitemap: `${APP_URL}/sitemap.xml`,
11 | host: APP_URL,
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/apps/web/src/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next'
2 | import { APP_URL } from '../../constants'
3 | import { SUPPORTED_LANGUAGES } from '../../languages'
4 |
5 | export default function sitemap(): MetadataRoute.Sitemap {
6 | const sitemaps = SUPPORTED_LANGUAGES.flatMap(language => [
7 | {
8 | url: `${APP_URL}/${language.value}/sitemap.xml`,
9 | changeFrequency: 'weekly' as const,
10 | lastModified: new Date().toISOString(),
11 | priority: 1,
12 | },
13 | {
14 | url: `${APP_URL}/${language.value}/movies/sitemap.xml`,
15 | changeFrequency: 'weekly' as const,
16 | lastModified: new Date().toISOString(),
17 | priority: 1,
18 | },
19 | {
20 | url: `${APP_URL}/${language.value}/tv-series/sitemap.xml`,
21 | changeFrequency: 'weekly' as const,
22 | lastModified: new Date().toISOString(),
23 | priority: 1,
24 | },
25 | ])
26 |
27 | return [...sitemaps]
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/components/animated-link/animated-link.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import { Link } from 'next-view-transitions'
3 |
4 | interface AnimatedLinkProps extends React.ComponentProps<'div'> {
5 | href: string
6 | children: React.ReactNode
7 | }
8 |
9 | export const AnimatedLink = ({
10 | href,
11 | className,
12 | children,
13 | ...props
14 | }: AnimatedLinkProps) => {
15 | return (
16 |
23 |
24 | {children}
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/src/components/animated-link/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './animated-link'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/animes-list/anime-list.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { usePathname, useRouter, useSearchParams } from 'next/navigation'
4 |
5 | import { useLanguage } from '@/context/language'
6 |
7 | import { Button } from '@plotwist/ui/components/ui/button'
8 | import { StreamingServicesBadge } from '../streaming-services-badge'
9 | import { AnimeListContent } from './anime-list-content'
10 |
11 | export type AnimeListType = 'tv' | 'movies'
12 |
13 | export const AnimeList = () => {
14 | const { dictionary } = useLanguage()
15 | const { replace } = useRouter()
16 | const pathname = usePathname()
17 | const searchParams = useSearchParams()
18 |
19 | const type = (searchParams.get('type') ?? 'tv') as AnimeListType
20 |
21 | const handleReplaceType = (type: AnimeListType) => {
22 | replace(`${pathname}?type=${type}`)
23 | }
24 |
25 | return (
26 |
27 |
28 |
29 |
37 |
38 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/apps/web/src/components/animes-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './anime-list'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/banner/banner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import type { ComponentProps } from 'react'
3 |
4 | type BannerProps = {
5 | url?: string
6 | } & ComponentProps<'div'>
7 |
8 | export const Banner = ({ url, className, ...props }: BannerProps) => {
9 | return (
10 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/src/components/banner/index.ts:
--------------------------------------------------------------------------------
1 | export * from './banner'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/collection-filters-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const collectionFiltersSchema = z.object({
4 | status: z.enum(['ALL', 'WATCHED', 'WATCHING', 'WATCHLIST', 'DROPPED']),
5 | userId: z.string(),
6 | rating: z.array(z.number()),
7 | mediaType: z.array(z.enum(['TV_SHOW', 'MOVIE'])),
8 | orderBy: z.enum([
9 | 'addedAt.desc',
10 | 'addedAt.asc',
11 | 'updatedAt.desc',
12 | 'updatedAt.asc',
13 | 'rating.desc',
14 | 'rating.asc',
15 | ]),
16 | onlyItemsWithoutReview: z.boolean().default(false),
17 | })
18 |
19 | export type CollectionFiltersFormValues = z.infer<
20 | typeof collectionFiltersSchema
21 | >
22 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tabs/filters'
2 | export * from './tabs/sort-by'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/filters/(fields)/index.ts:
--------------------------------------------------------------------------------
1 | export * from './media_type'
2 | export * from './rating'
3 | export * from './only_items_without_review'
4 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/filters/(fields)/media_type.tsx:
--------------------------------------------------------------------------------
1 | import type { CollectionFiltersFormValues } from '@/components/collection-filters/collection-filters-schema'
2 | import { useLanguage } from '@/context/language'
3 | import { Badge } from '@plotwist/ui/components/ui/badge'
4 | import {
5 | FormControl,
6 | FormItem,
7 | FormLabel,
8 | } from '@plotwist/ui/components/ui/form'
9 | import { useFormContext } from 'react-hook-form'
10 |
11 | export const MediaTypeField = () => {
12 | const {
13 | dictionary: { collection_filters },
14 | } = useLanguage()
15 | const { setValue, watch } = useFormContext()
16 |
17 | const selectedMediaTypes = watch('mediaType') || []
18 |
19 | return (
20 |
21 | {collection_filters.media_type_field_label}
22 |
23 |
24 |
25 | {Object.entries(collection_filters.options).map(([key, value]) => {
26 | const mediaType = key as 'TV_SHOW' | 'MOVIE'
27 | const isSelected = selectedMediaTypes.includes(mediaType)
28 |
29 | return (
30 | {
35 | const newValue = isSelected
36 | ? selectedMediaTypes.filter(type => type !== mediaType)
37 | : [...selectedMediaTypes, mediaType]
38 | setValue('mediaType', newValue)
39 | }}
40 | >
41 | {value}
42 |
43 | )
44 | })}
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/filters/(fields)/only_items_without_review.tsx:
--------------------------------------------------------------------------------
1 | import type { CollectionFiltersFormValues } from '@/components/collection-filters/collection-filters-schema'
2 | import { useLanguage } from '@/context/language'
3 | import { Checkbox } from '@plotwist/ui/components/ui/checkbox'
4 | import {
5 | FormControl,
6 | FormItem,
7 | FormLabel,
8 | } from '@plotwist/ui/components/ui/form'
9 | import { useFormContext } from 'react-hook-form'
10 |
11 | export const OnlyItemsWithoutReviewField = () => {
12 | const { setValue, watch } = useFormContext()
13 | const {
14 | dictionary: { collection_filters },
15 | } = useLanguage()
16 |
17 | return (
18 |
19 |
20 | {collection_filters.only_items_without_review_field_label}
21 |
22 |
23 |
24 |
25 |
28 | setValue('onlyItemsWithoutReview', value as boolean)
29 | }
30 | />
31 |
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/filters/filters.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | MediaTypeField,
3 | OnlyItemsWithoutReviewField,
4 | RatingField,
5 | } from './(fields)'
6 |
7 | export const Filters = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 | export * from './sort-by'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/collection-filters/tabs/sort-by/index.ts:
--------------------------------------------------------------------------------
1 | export * from './sort-by'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/command-search/command-search-group.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 |
3 | type CommandSearchGroupProps = {
4 | heading: string
5 | children: ReactNode
6 | }
7 |
8 | export const CommandSearchGroup = ({
9 | children,
10 | heading,
11 | }: CommandSearchGroupProps) => {
12 | return (
13 |
14 |
{heading}
15 |
16 |
{children}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/components/command-search/command-search-icon.tsx:
--------------------------------------------------------------------------------
1 | import { detectOperatingSystem } from '@/utils/operating-system'
2 | import { CommandIcon } from 'lucide-react'
3 | import { useEffect, useState } from 'react'
4 |
5 | export const CommandSearchIcon = () => {
6 | const [os, setOS] = useState(undefined)
7 |
8 | useEffect(() => {
9 | setOS(detectOperatingSystem())
10 | }, [])
11 |
12 | if (!os || os === 'iOS') {
13 | return null
14 | }
15 |
16 | if (os === 'Mac OS') {
17 | return (
18 |
19 | K
20 |
21 | )
22 | }
23 |
24 | return (
25 |
26 | CTRL + K
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/components/command-search/index.ts:
--------------------------------------------------------------------------------
1 | export * from './command-search'
2 | export * from './command-search-group'
3 | export * from './command-search-items'
4 |
--------------------------------------------------------------------------------
/apps/web/src/components/credits/credit-card.tsx:
--------------------------------------------------------------------------------
1 | import { tmdbImage } from '@/utils/tmdb/image'
2 | import { Link } from 'next-view-transitions'
3 | import Image from 'next/image'
4 |
5 | type CreditCardProps = {
6 | imagePath: string
7 | name: string
8 | role: string
9 | href: string
10 | }
11 |
12 | export const CreditCard = ({
13 | imagePath,
14 | name,
15 | role,
16 | href,
17 | }: CreditCardProps) => {
18 | return (
19 |
20 |
24 | {imagePath ? (
25 |
33 | ) : (
34 | {name[0]}
35 | )}
36 |
37 |
38 |
39 |
40 | {name}
41 |
42 |
43 |
44 | {role}
45 |
46 |
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/components/credits/index.ts:
--------------------------------------------------------------------------------
1 | export * from './credit-card'
2 | export * from './credits'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/dorama-list/dorama-list.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { StreamingServicesBadge } from '../streaming-services-badge'
4 | import { DoramaListContent } from './dorama-list-content'
5 |
6 | export const DoramaList = () => {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/apps/web/src/components/dorama-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './dorama-list'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/follow-button/index.ts:
--------------------------------------------------------------------------------
1 | export * from './follow-button'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/followers/followers.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { Separator } from '@plotwist/ui/components/ui/separator'
5 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
6 |
7 | export const Followers = () => {
8 | const { dictionary } = useLanguage()
9 | const isLoading = false
10 |
11 | return (
12 |
13 |
14 | {isLoading ? (
15 |
16 | ) : (
17 |
{0}
18 | )}
19 |
20 |
{dictionary.followers}
21 |
22 |
23 |
24 |
25 |
26 | {isLoading ? (
27 |
28 | ) : (
29 |
{0}
30 | )}
31 |
32 |
{dictionary.following}
33 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/components/followers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './followers'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/footer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './footer'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/full-review/index.ts:
--------------------------------------------------------------------------------
1 | export * from './full-review'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/gtag/gtag.tsx:
--------------------------------------------------------------------------------
1 | import { env } from '@/env.mjs'
2 | import Script from 'next/script'
3 |
4 | const GA_MEASUREMENT_ID = env.NEXT_PUBLIC_MEASUREMENT_ID
5 |
6 | export const GTag = () => {
7 | return (
8 | <>
9 |
13 |
14 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/gtag/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './gtag'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/header/header-navigation-drawer-user.tsx:
--------------------------------------------------------------------------------
1 | import { LogOut } from 'lucide-react'
2 |
3 | import {
4 | Avatar,
5 | AvatarFallback,
6 | AvatarImage,
7 | } from '@plotwist/ui/components/ui/avatar'
8 |
9 | import { logout } from '@/actions/auth/logout'
10 | import { useLanguage } from '@/context/language'
11 | import type { User } from '@/types/user'
12 | import { Link } from 'next-view-transitions'
13 |
14 | type HeaderNavigationDrawerUserProps = {
15 | user: User
16 | }
17 |
18 | export const HeaderNavigationDrawerUser = ({
19 | user,
20 | }: HeaderNavigationDrawerUserProps) => {
21 | const { language, dictionary } = useLanguage()
22 |
23 | if (!user) return
24 |
25 | return (
26 |
27 |
31 |
{user.username}
32 |
33 |
34 | {user.avatarUrl && (
35 |
36 | )}
37 |
38 | {user.username?.at(0)}
39 |
40 |
41 |
42 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/apps/web/src/components/header/header.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useMediaQuery } from '@/hooks/use-media-query'
4 | import { CommandSearch } from '../command-search'
5 | import { Logo } from '../logo'
6 | import { HeaderAccount } from './header-account'
7 | import { HeaderNavigationDrawer } from './header-navigation-drawer'
8 | import { HeaderNavigationMenu } from './header-navigation-menu'
9 |
10 | export const Header = () => {
11 | const isDesktop = useMediaQuery('(min-width: 1024px)')
12 |
13 | return (
14 | <>
15 |
26 |
27 |
34 | >
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/components/header/index.ts:
--------------------------------------------------------------------------------
1 | export * from './header'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/image-picker/image-picker-item.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
2 | import Image from 'next/image'
3 | import type { ComponentProps, PropsWithChildren } from 'react'
4 |
5 | export const ImagePickerItem = {
6 | Root: (props: PropsWithChildren & ComponentProps<'div'>) => (
7 |
11 | ),
12 | Image: ({ src }: { src: string }) => (
13 |
19 | ),
20 | Title: (props: PropsWithChildren) => (
21 |
22 | ),
23 | }
24 |
25 | export const ImagePickerItemSkeleton = () => {
26 | return
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/src/components/image-picker/image-picker.ts:
--------------------------------------------------------------------------------
1 | import { ImagePickerRoot, ImagePickerTrigger } from './image-picker-root'
2 |
3 | export const ImagePicker = {
4 | Root: ImagePickerRoot,
5 | Trigger: ImagePickerTrigger,
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/components/image-picker/index.ts:
--------------------------------------------------------------------------------
1 | export * from './image-picker'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/images/index.ts:
--------------------------------------------------------------------------------
1 | export * from './images'
2 | export * from './images-masonry'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/item-hover-card/index.ts:
--------------------------------------------------------------------------------
1 | export * from './item-hover-card'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/item-hover-card/item-hover-card.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react'
2 |
3 | const Banner = (props: PropsWithChildren) => {
4 | return (
5 |
9 | )
10 | }
11 |
12 | const Information = (props: PropsWithChildren) => {
13 | return
14 | }
15 |
16 | const Poster = (props: PropsWithChildren) => {
17 | return (
18 |
19 |
23 |
24 | )
25 | }
26 |
27 | const Summary = (props: PropsWithChildren) => {
28 | return
29 | }
30 |
31 | const Title = (props: PropsWithChildren) => {
32 | return
33 | }
34 |
35 | const Overview = (props: PropsWithChildren) => {
36 | return (
37 |
38 | )
39 | }
40 |
41 | export const ItemHoverCard = {
42 | Banner,
43 | Information,
44 | Poster,
45 | Summary,
46 | Title,
47 | Overview,
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/src/components/item-review/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useGetReviewSuspense } from '@/api/reviews'
4 | import { useLanguage } from '@/context/language'
5 | import { useSession } from '@/context/session'
6 | import { cn } from '@/lib/utils'
7 | import { Button } from '@plotwist/ui/components/ui/button'
8 | import { Star } from 'lucide-react'
9 | import { useParams, usePathname } from 'next/navigation'
10 | import { Suspense } from 'react'
11 | import { ReviewFormDialog } from '../reviews/review-form-dialog'
12 |
13 | function ItemReviewContent() {
14 | const pathname = usePathname()
15 | const { id, seasonNumber, episodeNumber } = useParams<{
16 | id: string
17 | seasonNumber?: string
18 | episodeNumber?: string
19 | }>()
20 |
21 | const mediaType = pathname.includes('tv-series') ? 'TV_SHOW' : 'MOVIE'
22 | const tmdbId = Number(id)
23 |
24 | const { data } = useGetReviewSuspense({
25 | mediaType,
26 | tmdbId: String(tmdbId),
27 | seasonNumber,
28 | episodeNumber,
29 | })
30 |
31 | const { dictionary } = useLanguage()
32 |
33 | return (
34 |
40 |
47 |
48 | )
49 | }
50 |
51 | export function ItemReview() {
52 | const { user } = useSession()
53 | if (!user) return
54 |
55 | return (
56 |
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/apps/web/src/components/item-status/index.ts:
--------------------------------------------------------------------------------
1 | export * from './item-status'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/likes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './likes'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/list-card/index.ts:
--------------------------------------------------------------------------------
1 | export * from './list-card'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/list-command/index.ts:
--------------------------------------------------------------------------------
1 | export * from './list-command'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/list-command/list-command-group.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react'
2 |
3 | const Root = (props: PropsWithChildren) => (
4 |
5 | )
6 |
7 | const Label = (props: PropsWithChildren) => (
8 |
9 | )
10 |
11 | const Items = (props: PropsWithChildren) => (
12 |
13 | )
14 |
15 | export const ListCommandGroup = {
16 | Root,
17 | Label,
18 | Items,
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/components/list-command/list-command-item.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@plotwist/ui/components/ui/button'
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuTrigger,
6 | } from '@plotwist/ui/components/ui/dropdown-menu'
7 | import { MoreVertical } from 'lucide-react'
8 | import type { PropsWithChildren } from 'react'
9 |
10 | const Root = (props: PropsWithChildren) => {
11 | return (
12 |
16 | )
17 | }
18 |
19 | const Label = (props: PropsWithChildren) => {
20 | return
21 | }
22 |
23 | const Year = (props: PropsWithChildren) => {
24 | return (
25 |
29 | )
30 | }
31 |
32 | const Dropdown = ({ children }: PropsWithChildren) => {
33 | return (
34 |
35 |
36 |
39 |
40 |
41 | {children}
42 |
43 | )
44 | }
45 |
46 | export const ListCommandItem = {
47 | Root,
48 | Label,
49 | Year,
50 | Dropdown,
51 | }
52 |
--------------------------------------------------------------------------------
/apps/web/src/components/lists/index.ts:
--------------------------------------------------------------------------------
1 | export * from './lists-dropdown'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/logo/index.ts:
--------------------------------------------------------------------------------
1 | export * from './logo'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/logo/logo.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { useSession } from '@/context/session'
5 | import { Link } from 'next-view-transitions'
6 | import Image from 'next/image'
7 |
8 | type LogoProps = { size?: number }
9 | export const Logo = ({ size = 24 }: LogoProps) => {
10 | const { language } = useLanguage()
11 |
12 | const { user } = useSession()
13 |
14 | return (
15 |
19 |
26 |
27 |
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/components/movie-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './movie-list'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/movie-list/movie-list.types.ts:
--------------------------------------------------------------------------------
1 | import type { MovieListType } from '@/services/tmdb'
2 |
3 | export type MovieListVariant = MovieListType | 'discover'
4 | export type MovieListProps = {
5 | variant: MovieListVariant
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './movies-list-filters'
2 | export * from './movies-list-filters-schema'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/movies-list-filters-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const moviesListFiltersSchema = z.object({
4 | release_date: z.object({
5 | gte: z.date().optional(),
6 | lte: z.date().optional(),
7 | }),
8 | genres: z.array(z.number()),
9 | with_original_language: z.string().optional(),
10 | sort_by: z.string().optional(),
11 |
12 | with_watch_providers: z.array(z.number()),
13 | watch_region: z.string().optional(),
14 |
15 | vote_average: z.object({
16 | gte: z.number().min(0).max(10),
17 | lte: z.number().min(0).max(10),
18 | }),
19 |
20 | vote_count: z.object({
21 | gte: z.number().min(0).max(500),
22 | }),
23 | })
24 |
25 | export type MoviesListFiltersFormValues = z.infer<
26 | typeof moviesListFiltersSchema
27 | >
28 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/filters/(fields)/index.ts:
--------------------------------------------------------------------------------
1 | export * from './genres'
2 | export * from './language'
3 | export * from './release-date'
4 | export * from './vote-average'
5 | export * from './vote-count'
6 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/filters/(fields)/vote-average.tsx:
--------------------------------------------------------------------------------
1 | import type { MoviesListFiltersFormValues } from '@/components/movies-list-filters'
2 | import { useLanguage } from '@/context/language'
3 | import {
4 | FormControl,
5 | FormItem,
6 | FormLabel,
7 | } from '@plotwist/ui/components/ui/form'
8 | import { Slider } from '@plotwist/ui/components/ui/slider'
9 | import { useFormContext } from 'react-hook-form'
10 | import { v4 } from 'uuid'
11 |
12 | export const VoteAverageField = () => {
13 | const {
14 | dictionary: {
15 | movies_list_filters: {
16 | vote_average_field: { label },
17 | },
18 | },
19 | } = useLanguage()
20 | const { setValue, watch } = useFormContext()
21 |
22 | return (
23 |
24 | {label}
25 |
26 |
27 |
28 |
{
35 | setValue('vote_average.gte', start)
36 | setValue('vote_average.lte', end)
37 | }}
38 | />
39 |
40 |
41 | {Array.from({ length: 11 }).map((_, index) => (
42 |
49 | ))}
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/filters/(fields)/vote-count.tsx:
--------------------------------------------------------------------------------
1 | import type { MoviesListFiltersFormValues } from '@/components/movies-list-filters'
2 | import { useLanguage } from '@/context/language'
3 | import {
4 | FormControl,
5 | FormItem,
6 | FormLabel,
7 | } from '@plotwist/ui/components/ui/form'
8 | import { Slider } from '@plotwist/ui/components/ui/slider'
9 | import { useFormContext } from 'react-hook-form'
10 | import { v4 } from 'uuid'
11 |
12 | export const VoteCountField = () => {
13 | const {
14 | dictionary: {
15 | movies_list_filters: {
16 | vote_count_field: { label },
17 | },
18 | },
19 | } = useLanguage()
20 |
21 | const { setValue, watch } = useFormContext()
22 |
23 | return (
24 |
25 | {label}
26 |
27 |
28 |
29 |
{
36 | setValue('vote_count.gte', start)
37 | }}
38 | />
39 |
40 |
41 | {Array.from({ length: 11 }).map((_, index) => (
42 |
46 |
47 |
{index * 50}
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/filters/filters.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | GenresField,
3 | LanguageField,
4 | ReleaseDateField,
5 | VoteAverageField,
6 | VoteCountField,
7 | } from './(fields)'
8 |
9 | export const Filters = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 | export * from './sort-by'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/movies-list-filters/tabs/sort-by/index.ts:
--------------------------------------------------------------------------------
1 | export * from './sort-by'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/no-account-tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export * from './no-account-tooltip'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/no-account-tooltip/no-account-tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Tooltip,
5 | TooltipContent,
6 | TooltipProvider,
7 | TooltipTrigger,
8 | } from '@plotwist/ui/components/ui/tooltip'
9 | import type { PropsWithChildren } from 'react'
10 |
11 | import { useLanguage } from '@/context/language'
12 | import { TooltipPortal } from '@radix-ui/react-tooltip'
13 | import { Link } from 'next-view-transitions'
14 |
15 | export const NoAccountTooltip = ({ children }: PropsWithChildren) => {
16 | const { dictionary, language } = useLanguage()
17 |
18 | return (
19 |
20 |
21 | {children}
22 |
23 |
24 |
25 |
26 | {dictionary.no_account_tooltip}
27 |
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/src/components/pattern/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pattern'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/people-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './people-list'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/person-card/index.ts:
--------------------------------------------------------------------------------
1 | export * from './person-card'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/poster-card/index.ts:
--------------------------------------------------------------------------------
1 | export * from './poster-card'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/poster-card/poster-card.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
2 | import NextImage, { type ImageProps } from 'next/image'
3 | import { type ComponentProps, forwardRef } from 'react'
4 |
5 | const Root = forwardRef>((props, ref) => {
6 | return
7 | })
8 | Root.displayName = 'Root'
9 |
10 | const Image = (props: ImageProps) => {
11 | return (
12 |
13 |
14 |
15 | )
16 | }
17 |
18 | const Details = (props: ComponentProps<'div'>) => {
19 | return
20 | }
21 |
22 | const Title = (props: ComponentProps<'h3'>) => {
23 | return
24 | }
25 |
26 | const Year = (props: ComponentProps<'span'>) => {
27 | return
28 | }
29 |
30 | const PosterCardSkeleton = forwardRef((_, ref) => (
31 |
32 |
33 |
34 | ))
35 | PosterCardSkeleton.displayName = 'Skeleton'
36 |
37 | export const PosterCard = {
38 | Root,
39 | Image,
40 | Details,
41 | Title,
42 | Year,
43 | Skeleton: PosterCardSkeleton,
44 | }
45 |
--------------------------------------------------------------------------------
/apps/web/src/components/poster/index.ts:
--------------------------------------------------------------------------------
1 | export * from './poster'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/poster/poster.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 | import { tmdbImage } from '@/utils/tmdb/image'
3 | import { Image as LucideImage } from 'lucide-react'
4 | import Image from 'next/image'
5 | import type { ComponentProps } from 'react'
6 |
7 | type PosterProps = {
8 | url?: string | null
9 | alt: string
10 | } & ComponentProps<'div'>
11 |
12 | export const Poster = ({ url, alt, className, ...props }: PosterProps) => {
13 | return (
14 |
21 | {url ? (
22 |
30 | ) : (
31 |
32 | )}
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/src/components/pricing/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pricing'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/pro-badge/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pro-badge'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/pro-badge/pro-badge.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { cn } from '@/lib/utils'
5 | import { Link } from 'next-view-transitions'
6 |
7 | type ProBadgeProps = { className?: string; isLink?: boolean }
8 |
9 | export const ProBadge = ({ className, isLink }: ProBadgeProps) => {
10 | const { language } = useLanguage()
11 |
12 | if (isLink) {
13 | return (
14 |
21 | PRO
22 |
23 | )
24 | }
25 |
26 | return (
27 |
33 | PRO
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/components/pro-feature-tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export * from './pro-feature-tooltip'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/pro-feature-tooltip/pro-feature-tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { useLanguage } from '@/context/language'
2 | import {
3 | Tooltip,
4 | TooltipContent,
5 | TooltipProvider,
6 | TooltipTrigger,
7 | } from '@plotwist/ui/components/ui/tooltip'
8 | import { Link } from 'next-view-transitions'
9 | import type { PropsWithChildren } from 'react'
10 |
11 | export function ProFeatureTooltip({ children }: PropsWithChildren) {
12 | const { language, dictionary } = useLanguage()
13 |
14 | return (
15 |
16 |
17 |
18 | {children}
19 |
20 |
21 |
22 | {dictionary.feature_only_in_pro_plan}
23 |
24 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/src/components/reviews/index.ts:
--------------------------------------------------------------------------------
1 | export * from './reviews'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/reviews/review-item/index.ts:
--------------------------------------------------------------------------------
1 | export * from './review-item'
2 | export * from './review-item-skeleton'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/reviews/review-item/review-item-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Rating } from '@plotwist/ui/components/ui/rating'
2 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton'
3 |
4 | export const ReviewItemSkeleton = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/apps/web/src/components/reviews/review-reply/index.ts:
--------------------------------------------------------------------------------
1 | export * from './review-reply'
2 | export * from './review-reply-actions'
3 | export * from './review-reply-form'
4 |
--------------------------------------------------------------------------------
/apps/web/src/components/streaming-services-badge.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useLanguage } from '@/context/language'
4 | import { useSession } from '@/context/session'
5 | import { Badge } from '@plotwist/ui/components/ui/badge'
6 | import { Link } from 'next-view-transitions'
7 |
8 | export function StreamingServicesBadge() {
9 | const { user } = useSession()
10 | const { language, dictionary } = useLanguage()
11 |
12 | if (!user) return null
13 |
14 | return (
15 |
16 |
17 | {dictionary.available_on_streaming_services}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tv-series-list-filters'
2 | export * from './tv-series-list-filters-schema'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/filters/(fields)/index.ts:
--------------------------------------------------------------------------------
1 | export * from './genres'
2 | export * from './language'
3 | export * from './air-date'
4 | export * from './vote-average'
5 | export * from './vote-count'
6 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/filters/(fields)/vote-count.tsx:
--------------------------------------------------------------------------------
1 | import type { TvSeriesListFiltersFormValues } from '@/components/tv-series-list-filters'
2 | import { useLanguage } from '@/context/language'
3 | import {
4 | FormControl,
5 | FormItem,
6 | FormLabel,
7 | } from '@plotwist/ui/components/ui/form'
8 | import { Slider } from '@plotwist/ui/components/ui/slider'
9 | import { useFormContext } from 'react-hook-form'
10 | import { v4 } from 'uuid'
11 |
12 | export const VoteCountField = () => {
13 | const {
14 | dictionary: {
15 | movies_list_filters: {
16 | vote_count_field: { label },
17 | },
18 | },
19 | } = useLanguage()
20 |
21 | const { setValue, watch } = useFormContext()
22 |
23 | return (
24 |
25 | {label}
26 |
27 |
28 |
29 |
{
36 | setValue('vote_count.gte', start)
37 | }}
38 | />
39 |
40 |
41 | {Array.from({ length: 11 }).map((_, index) => (
42 |
46 |
47 |
{index * 50}
48 |
49 | ))}
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/filters/filters.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AirDateField,
3 | GenresField,
4 | LanguageField,
5 | VoteAverageField,
6 | VoteCountField,
7 | } from './(fields)'
8 |
9 | export const Filters = () => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/filters/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/index.ts:
--------------------------------------------------------------------------------
1 | export * from './filters'
2 | export * from './sort-by'
3 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tabs/sort-by/index.ts:
--------------------------------------------------------------------------------
1 | export * from './sort-by'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list-filters/tv-series-list-filters-schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const tvSeriesListFiltersSchema = z.object({
4 | genres: z.array(z.number()),
5 | air_date: z.object({
6 | gte: z.date().optional(),
7 | lte: z.date().optional(),
8 | }),
9 | sort_by: z.string().optional(),
10 | vote_average: z.object({
11 | gte: z.number().min(0).max(10),
12 | lte: z.number().min(0).max(10),
13 | }),
14 | vote_count: z.object({
15 | gte: z.number().min(0).max(500),
16 | }),
17 | watch_region: z.string().optional(),
18 | with_watch_providers: z.array(z.number()),
19 | with_original_language: z.string().optional(),
20 | })
21 |
22 | export type TvSeriesListFiltersFormValues = z.infer<
23 | typeof tvSeriesListFiltersSchema
24 | >
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tv-series-list'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/tv-series-list/tv-series-list.types.ts:
--------------------------------------------------------------------------------
1 | import type { TvSeriesListType } from '@/services/tmdb'
2 |
3 | export type TvSeriesListVariant = TvSeriesListType | 'discover'
4 | export type TvSeriesListProps = {
5 | variant: TvSeriesListVariant
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/components/videos/index.ts:
--------------------------------------------------------------------------------
1 | export * from './videos'
2 |
--------------------------------------------------------------------------------
/apps/web/src/components/videos/videos.test.tsx:
--------------------------------------------------------------------------------
1 | import { cleanup, render, screen } from '@testing-library/react'
2 | import { afterEach, describe, expect, it } from 'vitest'
3 | import { Videos, type VideosProps } from '.'
4 |
5 | const PROPS: VideosProps = {
6 | tmdbId: 673, // // Harry Potter and the Prisoner of Azkaban
7 | variant: 'movie',
8 | }
9 |
10 | describe('Videos', () => {
11 | afterEach(() => cleanup())
12 |
13 | it('should be able to render Videos server component', async () => {
14 | render(await Videos(PROPS))
15 |
16 | const element = screen.getByTestId('videos')
17 | expect(element).toBeTruthy()
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/apps/web/src/components/where-to-watch/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './where-to-watch'
2 |
--------------------------------------------------------------------------------
/apps/web/src/context/language.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { Language } from '@/types/languages'
4 | import type { Dictionary } from '@/utils/dictionaries'
5 | import { type ReactNode, createContext, useContext } from 'react'
6 |
7 | type LanguageContextProviderProps = {
8 | children: ReactNode
9 | language: Language
10 | dictionary: Dictionary
11 | }
12 |
13 | type LanguageContextType = Pick<
14 | LanguageContextProviderProps,
15 | 'dictionary' | 'language'
16 | >
17 |
18 | export const languageContext = createContext({} as LanguageContextType)
19 |
20 | export const LanguageContextProvider = ({
21 | children,
22 | language,
23 | dictionary,
24 | }: LanguageContextProviderProps) => {
25 | return (
26 |
27 | {children}
28 |
29 | )
30 | }
31 |
32 | export const useLanguage = () => {
33 | const context = useContext(languageContext)
34 |
35 | if (!context) {
36 | throw new Error(
37 | 'LanguageContext must be used within LanguageContextProvider'
38 | )
39 | }
40 |
41 | return context
42 | }
43 |
--------------------------------------------------------------------------------
/apps/web/src/context/list-mode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { type ReactNode, createContext, useContext } from 'react'
4 |
5 | type Mode = 'EDIT' | 'SHOW'
6 |
7 | type ListModeContextProviderProps = {
8 | children: ReactNode
9 | mode: Mode
10 | }
11 |
12 | type ListModeContextType = Pick
13 |
14 | export const listModeContext = createContext({} as ListModeContextType)
15 | export const ListModeContextProvider = ({
16 | children,
17 | mode,
18 | }: ListModeContextProviderProps) => {
19 | return (
20 |
21 | {children}
22 |
23 | )
24 | }
25 |
26 | export const useListMode = () => {
27 | const context = useContext(listModeContext)
28 |
29 | if (!context) {
30 | throw new Error(
31 | 'ListModeContext must be used within ListModeContextProvider'
32 | )
33 | }
34 |
35 | return context
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/context/lists.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { createContext, useContext } from 'react'
4 |
5 | import { useGetLists } from '@/api/list'
6 |
7 | import type { GetLists200ListsItem } from '@/api/endpoints.schemas'
8 | import type { ReactNode } from 'react'
9 | import { useSession } from './session'
10 |
11 | export type ListsContextType = {
12 | lists: GetLists200ListsItem[]
13 | isLoading: boolean
14 | }
15 |
16 | export type ListsContextProviderProps = { children: ReactNode }
17 |
18 | export const ListsContext = createContext(
19 | {} as ListsContextType
20 | )
21 |
22 | export const ListsContextProvider = ({
23 | children,
24 | }: ListsContextProviderProps) => {
25 | const { user } = useSession()
26 | const { data, isLoading } = useGetLists({ userId: user?.id })
27 |
28 | return (
29 |
35 | {children}
36 |
37 | )
38 | }
39 |
40 | export const useLists = () => {
41 | const context = useContext(ListsContext)
42 |
43 | if (!context) {
44 | throw new Error('ListsContext must be used within ListsContextProvider')
45 | }
46 |
47 | return context
48 | }
49 |
--------------------------------------------------------------------------------
/apps/web/src/context/session.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { verifySession } from '@/app/lib/dal'
4 | import { AXIOS_INSTANCE } from '@/services/axios-instance'
5 | import type { User } from '@/types/user'
6 | import {
7 | type PropsWithChildren,
8 | createContext,
9 | useContext,
10 | useEffect,
11 | useState,
12 | } from 'react'
13 |
14 | type SessionContextProviderProps = PropsWithChildren & {
15 | initialSession: Awaited>
16 | }
17 |
18 | type SessionContext = {
19 | user: User
20 | }
21 |
22 | export const SessionContext = createContext({} as SessionContext)
23 |
24 | export const SessionContextProvider = ({
25 | children,
26 | initialSession,
27 | }: SessionContextProviderProps) => {
28 | const [user, setUser] = useState(initialSession?.user)
29 |
30 | useEffect(() => {
31 | if (!initialSession) {
32 | setUser(undefined)
33 | AXIOS_INSTANCE.defaults.headers.Authorization = ''
34 |
35 | return
36 | }
37 |
38 | setUser(initialSession.user)
39 | AXIOS_INSTANCE.defaults.headers.Authorization = `Bearer ${initialSession.token}`
40 | }, [initialSession])
41 |
42 | return (
43 |
44 | {children}
45 |
46 | )
47 | }
48 |
49 | export const useSession = () => {
50 | const context = useContext(SessionContext)
51 |
52 | if (!context) {
53 | throw new Error('SessionContext must be used within SessionContextProvider')
54 | }
55 |
56 | return context
57 | }
58 |
--------------------------------------------------------------------------------
/apps/web/src/context/user-preferences.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { GetUserPreferences200 } from '@/api/endpoints.schemas'
4 | import { type ReactNode, createContext, useContext } from 'react'
5 |
6 | export type UserPreferencesContextType = {
7 | userPreferences: GetUserPreferences200['userPreferences'] | undefined
8 | formatWatchProvidersIds: (watchProvidersIds: number[]) => string
9 | }
10 |
11 | export type UserPreferencesContextProviderProps = {
12 | children: ReactNode
13 | } & Pick
14 |
15 | export const UserPreferencesContext = createContext(
16 | {} as UserPreferencesContextType | undefined
17 | )
18 |
19 | export const UserPreferencesContextProvider = ({
20 | children,
21 | userPreferences,
22 | }: UserPreferencesContextProviderProps) => {
23 | const formatWatchProvidersIds = (watchProvidersIds: number[]) => {
24 | return watchProvidersIds.join('|')
25 | }
26 |
27 | return (
28 |
31 | {children}
32 |
33 | )
34 | }
35 |
36 | export const useUserPreferences = () => {
37 | const context = useContext(UserPreferencesContext)
38 |
39 | if (!context) {
40 | throw new Error(
41 | 'UserPreferencesContext must be used within UserPreferencesContextProvider'
42 | )
43 | }
44 |
45 | return context
46 | }
47 |
--------------------------------------------------------------------------------
/apps/web/src/env.mjs:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs'
2 | import { z } from 'zod'
3 |
4 | export const env = createEnv({
5 | shared: {},
6 | server: {},
7 | client: {
8 | // Client-side variables (accessible from the browser)
9 | NEXT_PUBLIC_TMDB_API_KEY: z.string(),
10 | NEXT_PUBLIC_MEASUREMENT_ID: z.string().optional(),
11 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
12 | },
13 | runtimeEnv: {
14 | // Destructure all variables from `process.env` to ensure they aren't tree-shaken away
15 | NEXT_PUBLIC_TMDB_API_KEY: process.env.NEXT_PUBLIC_TMDB_API_KEY,
16 | NEXT_PUBLIC_MEASUREMENT_ID: process.env.NEXT_PUBLIC_MEASUREMENT_ID,
17 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
18 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
19 | },
20 | })
21 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/use-media-query/index.ts:
--------------------------------------------------------------------------------
1 | export * from './use-media-query'
2 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/use-media-query/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = React.useState(false)
5 |
6 | React.useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches)
9 | }
10 |
11 | const result = matchMedia(query)
12 | result.addEventListener('change', onChange)
13 | setValue(result.matches)
14 |
15 | return () => result.removeEventListener('change', onChange)
16 | }, [query])
17 |
18 | return value
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | export * from '@plotwist/ui/lib/utils'
2 |
--------------------------------------------------------------------------------
/apps/web/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { type NextRequest, NextResponse } from 'next/server'
2 |
3 | import { match } from '@formatjs/intl-localematcher'
4 | import Negotiator from 'negotiator'
5 | import { languages as appLanguages } from '../languages'
6 |
7 | const headers = { 'accept-language': 'en-US' }
8 | const languages = new Negotiator({ headers }).languages()
9 |
10 | const DEFAULT_LOCALE = 'en-US'
11 |
12 | match(languages, appLanguages, DEFAULT_LOCALE)
13 |
14 | export async function middleware(req: NextRequest) {
15 | const headers = new Headers(req.headers)
16 | headers.set('x-current-path', req.nextUrl.pathname)
17 |
18 | const browserLanguage =
19 | req.headers.get('accept-language')?.split(',')[0] ?? 'en'
20 |
21 | const language =
22 | appLanguages.find(language => language.startsWith(browserLanguage)) ??
23 | DEFAULT_LOCALE
24 |
25 | const { pathname } = req.nextUrl
26 |
27 | const pathnameHasLocale = appLanguages.some(
28 | locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
29 | )
30 |
31 | if (!pathnameHasLocale) {
32 | req.nextUrl.pathname = `/${language}${pathname}`
33 | return NextResponse.redirect(req.nextUrl)
34 | }
35 |
36 | return NextResponse.next({ headers })
37 | }
38 |
39 | export const config = {
40 | matcher: '/((?!api|static|.*\\..*|_next).*)',
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/src/services/api.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 | const URL = process.env.VERCEL_PROJECT_PRODUCTION_URL
4 |
5 | export const api = axios.create({
6 | baseURL: `http://${URL}/api` || 'http://localhost:3000/api',
7 | })
8 |
--------------------------------------------------------------------------------
/apps/web/src/services/axios-instance.ts:
--------------------------------------------------------------------------------
1 | import Axios, { type AxiosRequestConfig } from 'axios'
2 |
3 | export const AXIOS_INSTANCE = Axios.create({
4 | baseURL: process.env.NEXT_PUBLIC_API_URL,
5 | })
6 |
7 | export const axiosInstance = (config: AxiosRequestConfig): Promise => {
8 | const source = Axios.CancelToken.source()
9 | const promise = AXIOS_INSTANCE({
10 | ...config,
11 | cancelToken: source.token,
12 | }).then(({ data }) => data)
13 |
14 | // @ts-ignore
15 | promise.cancel = () => {
16 | source.cancel('Query was cancelled')
17 | }
18 |
19 | return promise
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/src/services/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe'
2 |
3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '')
4 |
--------------------------------------------------------------------------------
/apps/web/src/services/tmdb.ts:
--------------------------------------------------------------------------------
1 | import { TMDB } from '@plotwist_app/tmdb'
2 |
3 | export const tmdb = TMDB(process.env.NEXT_PUBLIC_TMDB_API_KEY || '')
4 | export * from '@plotwist_app/tmdb'
5 |
--------------------------------------------------------------------------------
/apps/web/src/types/languages/index.ts:
--------------------------------------------------------------------------------
1 | export type Language =
2 | | 'en-US'
3 | | 'es-ES'
4 | | 'fr-FR'
5 | | 'de-DE'
6 | | 'it-IT'
7 | | 'pt-BR'
8 | | 'ja-JP'
9 |
10 | export type PageProps = {
11 | params: Promise<
12 | {
13 | lang: Language
14 | } & T
15 | >
16 | }
17 |
--------------------------------------------------------------------------------
/apps/web/src/types/media-type.ts:
--------------------------------------------------------------------------------
1 | export type MediaType = 'TV_SHOW' | 'MOVIE'
2 |
--------------------------------------------------------------------------------
/apps/web/src/types/user-item.ts:
--------------------------------------------------------------------------------
1 | export type UserItemStatus = 'WATCHED' | 'WATCHING' | 'WATCHLIST' | 'DROPPED'
2 | export const userItemStatus: UserItemStatus[] = [
3 | 'WATCHED',
4 | 'WATCHING',
5 | 'WATCHLIST',
6 | 'DROPPED',
7 | ]
8 |
--------------------------------------------------------------------------------
/apps/web/src/types/user.ts:
--------------------------------------------------------------------------------
1 | import type { GetUsersUsername200User } from '@/api/endpoints.schemas'
2 |
3 | export type User = GetUsersUsername200User | undefined
4 |
--------------------------------------------------------------------------------
/apps/web/src/utils/array/get-random-items.ts:
--------------------------------------------------------------------------------
1 | export function getRandomItems(array: T[], count: number): T[] {
2 | const maxStartIndex = array.length - count
3 | const startIndex = Math.floor(Math.random() * (maxStartIndex + 1))
4 |
5 | return array.slice(startIndex, startIndex + count)
6 | }
7 |
--------------------------------------------------------------------------------
/apps/web/src/utils/array/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-random-items'
2 |
--------------------------------------------------------------------------------
/apps/web/src/utils/currency/format.spec.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 | import { expect, test } from 'vitest'
3 | import { formatCurrency } from './format'
4 |
5 | test('formatCurrency should format the amount in USD currency (default language)', () => {
6 | const amount = 1234567.89
7 | const result = formatCurrency(amount)
8 |
9 | expect(result).toBe('$1,234,567.89')
10 | })
11 |
12 | test.each([
13 | [1234567.89, 'es-ES', '1.234.567,89 €'],
14 | [1234.56, 'fr-FR', '1 234,56 €'],
15 | [987654, 'de-DE', '987.654 €'],
16 | [5678, 'it-IT', '5.678 €'],
17 | [12345.67, 'pt-BR', 'R$ 12.345,67'],
18 | ])(
19 | 'formatCurrency should format the amount in the correct currency for the specified language',
20 | (amount, language, expected) => {
21 | const result = formatCurrency(amount, language as Language)
22 |
23 | const formattedResult = result
24 | .replaceAll(/\u00a0/g, ' ')
25 | .replaceAll(/\u202f/g, ' ')
26 |
27 | expect(formattedResult).toBe(expected)
28 | }
29 | )
30 |
31 | test('formatCurrency should handle decimal values correctly (default language)', () => {
32 | const amount = 1234.56
33 | const result = formatCurrency(amount)
34 |
35 | expect(result).toBe('$1,234.56')
36 | })
37 |
38 | test('formatCurrency should handle integer values correctly (default language)', () => {
39 | const amount = 987654
40 | const result = formatCurrency(amount)
41 |
42 | expect(result).toBe('$987,654')
43 | })
44 |
--------------------------------------------------------------------------------
/apps/web/src/utils/currency/format.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 |
3 | export const formatCurrency = (
4 | amount: number,
5 | language: Language = 'en-US'
6 | ) => {
7 | const commonOptions = {
8 | style: 'currency' as const,
9 | minimumFractionDigits: 0,
10 | }
11 |
12 | const amountByLanguage = {
13 | 'en-US': amount.toLocaleString('en-US', {
14 | ...commonOptions,
15 | currency: 'USD',
16 | }),
17 | 'es-ES': amount.toLocaleString('es-ES', {
18 | ...commonOptions,
19 | currency: 'EUR',
20 | }),
21 | 'fr-FR': amount.toLocaleString('fr-FR', {
22 | ...commonOptions,
23 | currency: 'EUR',
24 | }),
25 | 'de-DE': amount.toLocaleString('de-DE', {
26 | ...commonOptions,
27 | currency: 'EUR',
28 | }),
29 | 'it-IT': amount.toLocaleString('it-IT', {
30 | ...commonOptions,
31 | currency: 'EUR',
32 | }),
33 | 'pt-BR': amount.toLocaleString('pt-BR', {
34 | ...commonOptions,
35 | currency: 'BRL',
36 | }),
37 | 'ja-JP': amount.toLocaleString('ja-JP', {
38 | ...commonOptions,
39 | currency: 'JPY',
40 | }),
41 | }
42 |
43 | return amountByLanguage[language]
44 | }
45 |
--------------------------------------------------------------------------------
/apps/web/src/utils/date/format-date-to-url.ts:
--------------------------------------------------------------------------------
1 | import { format } from 'date-fns'
2 |
3 | export const formatDateToURL = (rawDate: Date) => {
4 | return format(rawDate, 'yyyy-MM-dd')
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/src/utils/date/locale.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 | import type { Locale } from 'date-fns'
3 | import { de, enUS, es, fr, it, ja, ptBR } from 'date-fns/locale'
4 |
5 | export const locale: Record = {
6 | 'de-DE': de,
7 | 'en-US': enUS,
8 | 'es-ES': es,
9 | 'fr-FR': fr,
10 | 'it-IT': it,
11 | 'ja-JP': ja,
12 | 'pt-BR': ptBR,
13 | }
14 |
--------------------------------------------------------------------------------
/apps/web/src/utils/date/time-from-now.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 | import { intlFormatDistance } from 'date-fns'
3 |
4 | type TimeFromNowParams = {
5 | date: Date
6 | language: Language
7 | }
8 |
9 | export function timeFromNow({ date, language: locale }: TimeFromNowParams) {
10 | return intlFormatDistance(date, new Date(), { locale })
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/src/utils/dictionaries/get-dictionaries.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 |
3 | const dictionaries = {
4 | 'en-US': () =>
5 | import('../../../public/dictionaries/en-US.json').then(r => r.default),
6 | 'pt-BR': () =>
7 | import('../../../public/dictionaries/pt-BR.json').then(r => r.default),
8 | 'de-DE': () =>
9 | import('../../../public/dictionaries/de-DE.json').then(r => r.default),
10 | 'es-ES': () =>
11 | import('../../../public/dictionaries/es-ES.json').then(r => r.default),
12 | 'fr-FR': () =>
13 | import('../../../public/dictionaries/fr-FR.json').then(r => r.default),
14 | 'it-IT': () =>
15 | import('../../../public/dictionaries/it-IT.json').then(r => r.default),
16 | 'ja-JP': () =>
17 | import('../../../public/dictionaries/ja-JP.json').then(r => r.default),
18 | } as const
19 |
20 | export const getDictionary = (lang: Language) => {
21 | const langFn = dictionaries[lang]
22 |
23 | return langFn ? langFn() : dictionaries['en-US']()
24 | }
25 |
26 | export type Dictionary = Awaited>
27 |
--------------------------------------------------------------------------------
/apps/web/src/utils/dictionaries/index.ts:
--------------------------------------------------------------------------------
1 | export * from './get-dictionaries'
2 |
--------------------------------------------------------------------------------
/apps/web/src/utils/list/index.ts:
--------------------------------------------------------------------------------
1 | export * from './list-page-query-key'
2 |
--------------------------------------------------------------------------------
/apps/web/src/utils/list/list-page-query-key.tsx:
--------------------------------------------------------------------------------
1 | export const listPageQueryKey = (id: string) => ['list', id]
2 |
--------------------------------------------------------------------------------
/apps/web/src/utils/number/format-number.ts:
--------------------------------------------------------------------------------
1 | export function formatNumber(num: number): string {
2 | const units = ['k', 'm', 'b', 't']
3 | let unitIndex = -1
4 | let scaledNum = num
5 |
6 | while (scaledNum >= 1000 && unitIndex < units.length - 1) {
7 | scaledNum /= 1000
8 | unitIndex++
9 | }
10 |
11 | return unitIndex === -1
12 | ? num.toString()
13 | : `${scaledNum % 1 === 0 ? scaledNum : scaledNum.toFixed(1)}${
14 | units[unitIndex]
15 | }`
16 | }
17 |
--------------------------------------------------------------------------------
/apps/web/src/utils/operating-system/detect-operating-system.ts:
--------------------------------------------------------------------------------
1 | const MAC_OS_PLATFORMS = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']
2 | const WINDOWS_PLATFORMS = ['Win32', 'Win64', 'Windows', 'WinCE']
3 | const IOS_PLATFORMS = ['iPhone', 'iPad', 'iPod']
4 |
5 | function isPlatformAmong(platform: string, platformsArray: string[]) {
6 | return platformsArray.includes(platform)
7 | }
8 |
9 | export function detectOperatingSystem() {
10 | if (typeof window === 'undefined') {
11 | return 'Unknown OS - possibly server-side'
12 | }
13 |
14 | const userAgent = window?.navigator.userAgent
15 | const platform = window?.navigator.platform
16 |
17 | if (isPlatformAmong(platform, MAC_OS_PLATFORMS)) {
18 | return 'Mac OS'
19 | }
20 |
21 | if (isPlatformAmong(platform, IOS_PLATFORMS)) {
22 | return 'iOS'
23 | }
24 |
25 | if (isPlatformAmong(platform, WINDOWS_PLATFORMS)) {
26 | return 'Windows'
27 | }
28 |
29 | if (/Android/.test(userAgent)) {
30 | return 'Android'
31 | }
32 |
33 | if (/Linux/.test(platform)) {
34 | return 'Linux'
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/src/utils/operating-system/index.ts:
--------------------------------------------------------------------------------
1 | export * from './detect-operating-system'
2 |
--------------------------------------------------------------------------------
/apps/web/src/utils/review.ts:
--------------------------------------------------------------------------------
1 | import type { Language } from '@/types/languages'
2 | import type { MediaType } from '@/types/media-type'
3 |
4 | type EpisodeBadgeProps = {
5 | seasonNumber?: number | null
6 | episodeNumber?: number | null
7 | }
8 |
9 | export function getEpisodeBadge({
10 | seasonNumber,
11 | episodeNumber,
12 | }: EpisodeBadgeProps) {
13 | if (seasonNumber && episodeNumber) {
14 | return ` (S${String(seasonNumber).padStart(2, '0')}E${String(episodeNumber).padStart(2, '0')})`
15 | }
16 |
17 | if (seasonNumber) {
18 | return ` (S${String(seasonNumber).padStart(2, '0')})`
19 | }
20 |
21 | return undefined
22 | }
23 |
24 | type ReviewHrefProps = {
25 | language: Language
26 | mediaType: MediaType
27 | tmdbId: number
28 | seasonNumber?: number | null
29 | episodeNumber?: number | null
30 | }
31 |
32 | export function getReviewHref({
33 | language,
34 | mediaType,
35 | tmdbId,
36 | seasonNumber,
37 | episodeNumber,
38 | }: ReviewHrefProps) {
39 | if (mediaType === 'MOVIE') {
40 | return `/${language}/movies/${tmdbId}`
41 | }
42 |
43 | if (seasonNumber && episodeNumber) {
44 | return `/${language}/tv-series/${tmdbId}/seasons/${seasonNumber}/episodes/${episodeNumber}`
45 | }
46 |
47 | if (seasonNumber) {
48 | return `/${language}/tv-series/${tmdbId}/seasons/${seasonNumber}`
49 | }
50 |
51 | return `/${language}/tv-series/${tmdbId}`
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/utils/seo/get-movies-ids.ts:
--------------------------------------------------------------------------------
1 | import { tmdb } from '@/services/tmdb'
2 |
3 | const DEFAULT_PAGES = 10
4 |
5 | export const getMoviesIds = async (pages: number = DEFAULT_PAGES) => {
6 | const types = ['now_playing', 'popular', 'top_rated', 'upcoming'] as const
7 |
8 | const lists = await Promise.all(
9 | Array.from({ length: pages }).map(
10 | async (_, index) =>
11 | await Promise.all(
12 | types.map(
13 | async type =>
14 | await tmdb.movies.list({
15 | language: 'en-US',
16 | list: type,
17 | page: index + 1,
18 | })
19 | )
20 | )
21 | )
22 | )
23 |
24 | const results = lists.flatMap(list => list.map(list => list.results))
25 | const ids = results.flatMap(result => result.map(movie => movie.id))
26 |
27 | const combinedIds = [...ids]
28 | const uniqueIds = Array.from(new Set(combinedIds))
29 |
30 | return uniqueIds
31 | }
32 |
--------------------------------------------------------------------------------
/apps/web/src/utils/seo/get-tv-metadata.ts:
--------------------------------------------------------------------------------
1 | import { type Language, tmdb } from '@/services/tmdb'
2 | import { tmdbImage } from '@/utils/tmdb/image'
3 | import type { Metadata } from 'next'
4 | import { APP_URL } from '../../../constants'
5 | import { SUPPORTED_LANGUAGES } from '../../../languages'
6 |
7 | export async function getTvMetadata(
8 | id: number,
9 | lang: Language
10 | ): Promise {
11 | const {
12 | name,
13 | overview,
14 | backdrop_path: backdrop,
15 | } = await tmdb.tv.details(id, lang)
16 |
17 | const keywords = await tmdb.keywords('tv', id)
18 | const canonicalUrl = `${APP_URL}/${lang}/tv-series/${id}`
19 |
20 | const languageAlternates = SUPPORTED_LANGUAGES.reduce(
21 | (acc, lang) => {
22 | if (lang.enabled) {
23 | acc[lang.hreflang] = `${APP_URL}/${lang.value}/tv-series/${id}`
24 | }
25 | return acc
26 | },
27 | {} as Record
28 | )
29 |
30 | return {
31 | title: name,
32 | description: overview,
33 | keywords: keywords?.map(keyword => keyword.name).join(','),
34 | openGraph: {
35 | images: [tmdbImage(backdrop)],
36 | title: name,
37 | description: overview,
38 | siteName: 'Plotwist',
39 | type: 'video.tv_show',
40 | },
41 | twitter: {
42 | title: name,
43 | description: overview,
44 | images: tmdbImage(backdrop),
45 | card: 'summary_large_image',
46 | },
47 | alternates: {
48 | canonical: canonicalUrl,
49 | languages: languageAlternates,
50 | },
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/apps/web/src/utils/seo/get-tv-series-ids.ts:
--------------------------------------------------------------------------------
1 | import { tmdb } from '@/services/tmdb'
2 |
3 | const DEFAULT_PAGES = 10
4 |
5 | export const getTvSeriesIds = async (pages: number = DEFAULT_PAGES) => {
6 | const types = ['airing_today', 'on_the_air', 'popular', 'top_rated'] as const
7 |
8 | const lists = await Promise.all(
9 | Array.from({ length: pages }).map(
10 | async (_, index) =>
11 | await Promise.all(
12 | types.map(
13 | async type =>
14 | await tmdb.tv.list({
15 | language: 'en-US',
16 | list: type,
17 | page: index + 1,
18 | })
19 | )
20 | )
21 | )
22 | )
23 | const results = lists.flatMap(list => list.map(list => list.results))
24 | const ids = results.flatMap(result => result.map(tv => tv.id))
25 |
26 | const uniqueIds = Array.from(new Set(ids))
27 |
28 | return uniqueIds
29 | }
30 |
--------------------------------------------------------------------------------
/apps/web/src/utils/tmdb/department.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '../dictionaries'
2 |
3 | export function getDepartmentLabel(dictionary: Dictionary, department: string) {
4 | const label: Record = {
5 | Directing: dictionary.directing,
6 | Acting: dictionary.acting,
7 | Production: dictionary.production,
8 | Writing: dictionary.writing,
9 | Camera: dictionary.camera,
10 | Editing: dictionary.editing,
11 | Sound: dictionary.sound,
12 | Art: dictionary.art,
13 | 'Costume & Make-Up': dictionary.costume_and_make_up,
14 | 'Visual Effects': dictionary.visual_effects,
15 | Crew: dictionary.crew,
16 | Lighting: dictionary.lighting,
17 | }
18 |
19 | return label[department] || department
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/src/utils/tmdb/image.ts:
--------------------------------------------------------------------------------
1 | export const tmdbImage = (
2 | path: string,
3 | type: 'original' | 'w500' = 'original'
4 | ) => `https://image.tmdb.org/t/p/${type}/${path}`
5 |
--------------------------------------------------------------------------------
/apps/web/src/utils/tmdb/job.ts:
--------------------------------------------------------------------------------
1 | import type { Dictionary } from '../dictionaries'
2 |
3 | export function getJobLabel(dictionary: Dictionary, job: string) {
4 | const label: Record = {
5 | Actor: dictionary.actor,
6 | 'Executive Producer': dictionary.executive_producer,
7 | Musician: dictionary.musician,
8 | Director: dictionary.director,
9 | Writer: dictionary.writer,
10 | Novel: dictionary.novel,
11 | 'Audio Post Coordinator': dictionary.audio_post_coordinator,
12 | Producer: dictionary.producer,
13 | Screenplay: dictionary.screenplay,
14 | 'Original Series Creator': dictionary.original_series_creator,
15 | Creator: dictionary.creator,
16 | 'Comic Book': dictionary.comic_book,
17 | Characters: dictionary.characters,
18 | Thanks: dictionary.thanks,
19 | 'In Memory Of': dictionary.in_memory_of,
20 | 'Original Film Writer': dictionary.original_film_writer,
21 | 'Co-Executive Producer': dictionary.co_executive_producer,
22 | Presenter: dictionary.presenter,
23 | 'Script Consultant': dictionary.script_consultant,
24 | 'Consulting Producer': dictionary.consulting_producer,
25 | Story: dictionary.story,
26 | 'Executive Story Editor': dictionary.executive_story_editor,
27 | 'Creative Consultant': dictionary.creative_consultant,
28 | 'Supervising Producer': dictionary.supervising_producer,
29 | 'Story Editor': dictionary.story_editor,
30 | 'Costume Design': dictionary.costume_design,
31 | 'Production Design': dictionary.production_design,
32 | Editor: dictionary.editor,
33 | }
34 |
35 | return label[job] || job
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | export * from '@plotwist/ui/tailwind.config'
2 |
--------------------------------------------------------------------------------
/apps/web/test/mocks.ts:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest'
2 |
3 | const signInWithCredentialsSpy = vi.fn()
4 | const signUpWithCredentialsSpy = vi.fn()
5 | vi.mock('@/hooks/use-auth', () => ({
6 | useAuth: () => ({
7 | signInWithCredentials: signInWithCredentialsSpy,
8 | signUpWithCredentials: signUpWithCredentialsSpy,
9 | }),
10 | }))
11 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@plotwist/typescript-config/nextjs.json",
3 | "compilerOptions": {
4 | "target": "ES2015",
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["./src/*"]
8 | },
9 | "plugins": [
10 | {
11 | "name": "next"
12 | }
13 | ]
14 | },
15 | "include": [
16 | "next-env.d.ts",
17 | "next.config.mjs",
18 | ".next/types/**/*.ts",
19 | "**/*.ts",
20 | "**/*.tsx",
21 | "**/*.mjs",
22 | "**/*.js",
23 | ".eslintrc.js",
24 | "src/app/[lang]/about/content.mdx"
25 | ],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/web/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path'
2 | import react from '@vitejs/plugin-react'
3 | import { defineConfig } from 'vitest/config'
4 |
5 | const r = (p: string) => resolve(__dirname, p)
6 |
7 | export default defineConfig({
8 | plugins: [react()],
9 | test: {
10 | environment: 'jsdom',
11 | coverage: {
12 | provider: 'v8',
13 | include: ['src/**/'],
14 | reporter: ['html'],
15 | },
16 | setupFiles: ['./test/setup.ts', './test/mocks.ts'],
17 | },
18 | resolve: {
19 | alias: {
20 | '@/': r('./src'),
21 | '@': r('./src'),
22 | },
23 | },
24 | })
25 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "formatter": {
7 | "indentStyle": "space",
8 | "indentWidth": 2,
9 | "lineWidth": 80
10 | },
11 | "javascript": {
12 | "formatter": {
13 | "arrowParentheses": "asNeeded",
14 | "jsxQuoteStyle": "double",
15 | "quoteStyle": "single",
16 | "semicolons": "asNeeded",
17 | "trailingCommas": "es5"
18 | }
19 | },
20 | "linter": {
21 | "enabled": true,
22 | "rules": {
23 | "recommended": true,
24 | "a11y": {
25 | "noSvgWithoutTitle": "warn"
26 | },
27 | "complexity": {
28 | "noForEach": "warn"
29 | },
30 | "correctness": {
31 | "noUnusedImports": "error"
32 | }
33 | }
34 | },
35 | "files": {
36 | "ignore": [
37 | "node_modules",
38 | ".turbo",
39 | ".next",
40 | "apps/web/src/api/*.ts",
41 | "packages/ui"
42 | ]
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "plotwist",
3 | "private": true,
4 | "version": "1.0.0",
5 | "scripts": {
6 | "build": "dotenv -- turbo build",
7 | "build:typescript": "turbo build --filter=@plotwist/typescript-config",
8 | "dev": "dotenv -- turbo dev",
9 | "test": "turbo test",
10 | "biome:check": "biome check --write .",
11 | "biome:format": "biome format --write ."
12 | },
13 | "devDependencies": {
14 | "@biomejs/biome": "1.9.4",
15 | "@plotwist/typescript-config": "workspace:*",
16 | "dotenv-cli": "^7.4.2",
17 | "turbo": "2.4.0"
18 | },
19 | "engines": {
20 | "node": ">=23"
21 | },
22 | "packageManager": "pnpm@10.0.0",
23 | "resolutions": {
24 | "react-is": "^19.0.0-beta-26f2496093-20240514"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "declaration": true,
6 | "declarationMap": true,
7 | "esModuleInterop": true,
8 | "incremental": true,
9 | "isolatedModules": true,
10 | "lib": ["es2022", "DOM", "DOM.Iterable"],
11 | "module": "NodeNext",
12 | "moduleDetection": "force",
13 | "moduleResolution": "NodeNext",
14 | "resolveJsonModule": true,
15 | "skipLibCheck": true,
16 | "strict": true,
17 | "strictNullChecks": true,
18 | "target": "es5"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/typescript-config/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Next.js",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
7 | "allowJs": true,
8 | "plugins": [
9 | {
10 | "name": "next"
11 | }
12 | ],
13 | "module": "ESNext",
14 | "moduleResolution": "Bundler",
15 | "jsx": "preserve",
16 | "noEmit": true,
17 | "declaration": false,
18 | "declarationMap": false
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@plotwist/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/typescript-config/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "React Library",
4 | "extends": "./base.json",
5 | "compilerOptions": {
6 | "jsx": "react-jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@plotwist/ui/components",
15 | "utils": "@plotwist/ui/lib/utils",
16 | "ui": "@plotwist/ui/components/ui",
17 | "magicui": "@plotwist/ui/components/magicui"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | }
8 |
9 | export default config
10 |
--------------------------------------------------------------------------------
/packages/ui/src/components/magicui/blur-fade.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | AnimatePresence,
5 | type UseInViewOptions,
6 | type Variants,
7 | motion,
8 | useInView,
9 | } from 'framer-motion'
10 | import { useRef } from 'react'
11 |
12 | type MarginType = UseInViewOptions['margin']
13 |
14 | interface BlurFadeProps {
15 | children: React.ReactNode
16 | className?: string
17 | variant?: {
18 | hidden: { y: number }
19 | visible: { y: number }
20 | }
21 | duration?: number
22 | delay?: number
23 | yOffset?: number
24 | inView?: boolean
25 | inViewMargin?: MarginType
26 | blur?: string
27 | }
28 |
29 | export function BlurFade({
30 | children,
31 | className,
32 | variant,
33 | duration = 0.4,
34 | delay = 0,
35 | yOffset = 6,
36 | inView = false,
37 | inViewMargin = '-50px',
38 | blur = '6px',
39 | }: BlurFadeProps) {
40 | const ref = useRef(null)
41 | const inViewResult = useInView(ref, { once: true, margin: inViewMargin })
42 | const isInView = !inView || inViewResult
43 | const defaultVariants: Variants = {
44 | hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` },
45 | visible: { y: -yOffset, opacity: 1, filter: 'blur(0px)' },
46 | }
47 | const combinedVariants = variant || defaultVariants
48 | return (
49 |
50 |
63 | {children}
64 |
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
4 |
5 | const AspectRatio = AspectRatioPrimitive.Root
6 |
7 | export { AspectRatio }
8 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { type VariantProps, cva } from 'class-variance-authority'
2 | import type * as React from 'react'
3 |
4 | import { cn } from '@plotwist/ui/lib/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/border-beam.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@plotwist/ui/lib/utils'
2 |
3 | interface BorderBeamProps {
4 | className?: string
5 | size?: number
6 | duration?: number
7 | borderWidth?: number
8 | anchor?: number
9 | colorFrom?: string
10 | colorTo?: string
11 | delay?: number
12 | }
13 |
14 | export const BorderBeam = ({
15 | className,
16 | size = 200,
17 | duration = 15,
18 | anchor = 90,
19 | borderWidth = 1.5,
20 | colorFrom = '#ffaa40',
21 | colorTo = '#9c40ff',
22 | delay = 0,
23 | }: BorderBeamProps) => {
24 | return (
25 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
4 | import { CheckIcon } from '@radix-ui/react-icons'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@plotwist/ui/lib/utils'
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
4 |
5 | const Collapsible = CollapsiblePrimitive.Root
6 |
7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
8 |
9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
10 |
11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const HoverCard = HoverCardPrimitive.Root
9 |
10 | const HoverCardTrigger = HoverCardPrimitive.Trigger
11 |
12 | const HoverCardContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
16 |
26 | ))
27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
28 |
29 | export { HoverCard, HoverCardTrigger, HoverCardContent }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@plotwist/ui/lib/utils'
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = 'Input'
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as LabelPrimitive from '@radix-ui/react-label'
4 | import { type VariantProps, cva } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@plotwist/ui/lib/utils'
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as PopoverPrimitive from '@radix-ui/react-popover'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor
13 |
14 | const PopoverContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
18 |
19 |
29 |
30 | ))
31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
32 |
33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
34 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as ProgressPrimitive from '@radix-ui/react-progress'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
27 |
28 | ))
29 | Progress.displayName = ProgressPrimitive.Root.displayName
30 |
31 | export { Progress }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { CheckIcon } from '@radix-ui/react-icons'
4 | import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@plotwist/ui/lib/utils'
8 |
9 | const RadioGroup = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => {
13 | return (
14 |
19 | )
20 | })
21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
22 |
23 | const RadioGroupItem = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => {
27 | return (
28 |
36 |
37 |
38 |
39 |
40 | )
41 | })
42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
43 |
44 | export { RadioGroup, RadioGroupItem }
45 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = 'horizontal', decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@plotwist/ui/lib/utils'
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SliderPrimitive from '@radix-ui/react-slider'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 | const Slider = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
20 |
21 |
22 |
23 | {props.defaultValue?.[1] && (
24 |
25 | )}
26 |
27 | ))
28 | Slider.displayName = SliderPrimitive.Root.displayName
29 |
30 | export { Slider }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useTheme } from 'next-themes'
4 | import { Toaster as Sonner } from 'sonner'
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as SwitchPrimitives from '@radix-ui/react-switch'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@plotwist/ui/lib/utils'
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = 'Textarea'
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@plotwist/ui/components/ui/toast'
11 | import { useToast } from '@plotwist/ui/hooks/use-toast'
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(({ id, title, description, action, ...props }) => (
19 |
20 |
21 | {title && {title}}
22 | {description && {description}}
23 |
24 | {action}
25 |
26 |
27 | ))}
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TogglePrimitive from '@radix-ui/react-toggle'
4 | import { type VariantProps, cva } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@plotwist/ui/lib/utils'
8 |
9 | const toggleVariants = cva(
10 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
11 | {
12 | variants: {
13 | variant: {
14 | default: 'bg-transparent',
15 | outline:
16 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
17 | },
18 | size: {
19 | default: 'h-9 px-3',
20 | sm: 'h-8 px-2',
21 | lg: 'h-10 px-3',
22 | },
23 | },
24 | defaultVariants: {
25 | variant: 'default',
26 | size: 'default',
27 | },
28 | }
29 | )
30 |
31 | const Toggle = React.forwardRef<
32 | React.ElementRef,
33 | React.ComponentPropsWithoutRef &
34 | VariantProps
35 | >(({ className, variant, size, ...props }, ref) => (
36 |
41 | ))
42 |
43 | Toggle.displayName = TogglePrimitive.Root.displayName
44 |
45 | export { Toggle, toggleVariants }
46 |
--------------------------------------------------------------------------------
/packages/ui/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@plotwist/ui/lib/utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/packages/ui/src/hooks/use-media-query.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | export function useMediaQuery(query: string) {
4 | const [value, setValue] = React.useState(false)
5 |
6 | React.useEffect(() => {
7 | function onChange(event: MediaQueryListEvent) {
8 | setValue(event.matches)
9 | }
10 |
11 | const result = matchMedia(query)
12 | result.addEventListener('change', onChange)
13 | setValue(result.matches)
14 |
15 | return () => result.removeEventListener('change', onChange)
16 | }, [query])
17 |
18 | return value
19 | }
20 |
--------------------------------------------------------------------------------
/packages/ui/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@plotwist/typescript-config/react-library.json",
3 | "compilerOptions": {
4 | "baseUrl": ".",
5 | "paths": {
6 | "@plotwist/ui/*": ["./src/*"]
7 | }
8 | },
9 | "include": ["src"],
10 | "exclude": ["node_modules"]
11 | }
12 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@plotwist/typescript-config/base.json"
3 | }
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "tasks": {
4 | "build": {
5 | "dependsOn": ["^build"]
6 | },
7 | "web#build": {
8 | "dependsOn": ["^build"],
9 | "env": ["NEXT_PUBLIC_MEASUREMENT_ID"],
10 | "outputs": [".next/**", "!.next/cache/**"]
11 | },
12 | "lint": {
13 | "dependsOn": ["^lint"]
14 | },
15 | "dev": {
16 | "cache": false,
17 | "persistent": true
18 | },
19 | "test": {}
20 | },
21 | "globalEnv": [
22 | "NEXT_PUBLIC_TMDB_API_KEY",
23 | "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
24 | "STRIPE_SECRET_KEY"
25 | ],
26 | "globalDependencies": ["tsconfig.json", ".env"]
27 | }
28 |
--------------------------------------------------------------------------------