├── .editorconfig ├── .env.example ├── .github ├── ci.yml └── pull_request_template.md ├── .gitignore ├── .npmrc ├── .tool-versions ├── .vscode └── settings.json ├── CONTRIBUTING.MD ├── LICENSE ├── README.md ├── apps └── web │ ├── constants.ts │ ├── languages.ts │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── orval.config.js │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── apple-icon.png │ ├── assets │ │ ├── google.svg │ │ ├── imdb.svg │ │ ├── rotten.png │ │ ├── tmdb-1.svg │ │ ├── tmdb-2.svg │ │ ├── tmdb.svg │ │ └── x.svg │ ├── dictionaries │ │ ├── de-DE.json │ │ ├── en-US.json │ │ ├── es-ES.json │ │ ├── fr-FR.json │ │ ├── it-IT.json │ │ ├── ja-JP.json │ │ └── pt-BR.json │ ├── icon.ico │ ├── images │ │ ├── landing-page.jpg │ │ └── landing-page │ │ │ ├── dark │ │ │ ├── activity.jpg │ │ │ ├── collection.jpg │ │ │ ├── preferences.jpg │ │ │ ├── reviews.jpg │ │ │ └── stats.jpg │ │ │ └── light │ │ │ ├── activity.jpg │ │ │ ├── collection.jpg │ │ │ ├── lists.jpg │ │ │ ├── preferences.jpg │ │ │ ├── reviews.jpg │ │ │ └── stats.jpg │ ├── logo-black.png │ ├── logo-black.svg │ ├── logo-white.png │ └── logo-white.svg │ ├── src │ ├── actions │ │ └── auth │ │ │ ├── logout.ts │ │ │ ├── reset-password.ts │ │ │ ├── sign-in.ts │ │ │ └── sign-up.ts │ ├── api │ │ ├── auth.ts │ │ ├── default.ts │ │ ├── endpoints.schemas.ts │ │ ├── follow.ts │ │ ├── follows.ts │ │ ├── images.ts │ │ ├── imports.ts │ │ ├── like.ts │ │ ├── list-item.ts │ │ ├── list.ts │ │ ├── review-replies.ts │ │ ├── reviews.ts │ │ ├── social-links.ts │ │ ├── user-activities.ts │ │ ├── user-episodes.ts │ │ ├── user-items.ts │ │ ├── user-stats.ts │ │ ├── users.ts │ │ └── webhook.ts │ ├── app │ │ ├── [lang] │ │ │ ├── [username] │ │ │ │ ├── _components │ │ │ │ │ ├── profile-achievements.tsx │ │ │ │ │ ├── social-links-form.tsx │ │ │ │ │ ├── social-links.tsx │ │ │ │ │ ├── user-activities.tsx │ │ │ │ │ ├── user-activity.tsx │ │ │ │ │ ├── user-avatar.tsx │ │ │ │ │ ├── user-banner.tsx │ │ │ │ │ ├── user-dialog.tsx │ │ │ │ │ ├── user-follows.tsx │ │ │ │ │ ├── user-form.tsx │ │ │ │ │ ├── user-items-command.tsx │ │ │ │ │ ├── user-items-list.tsx │ │ │ │ │ ├── user-items.tsx │ │ │ │ │ ├── user-lists.tsx │ │ │ │ │ ├── user-preferences │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── user-preferences.tsx │ │ │ │ │ ├── user-resume-stats.tsx │ │ │ │ │ └── users-tabs.tsx │ │ │ │ ├── _context.tsx │ │ │ │ ├── collection │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── lists │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── reviews │ │ │ │ │ ├── _reviews.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── stats │ │ │ │ │ ├── _best_rated.tsx │ │ │ │ │ ├── _countries │ │ │ │ │ ├── features.json │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── map-chart.tsx │ │ │ │ │ ├── _genres.tsx │ │ │ │ │ ├── _most_watched-series.tsx │ │ │ │ │ ├── _reviews-count.tsx │ │ │ │ │ ├── _status.tsx │ │ │ │ │ ├── _top_actors.tsx │ │ │ │ │ ├── _total_hours.tsx │ │ │ │ │ ├── _watched-movies.tsx │ │ │ │ │ ├── _watched-series.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── container.tsx │ │ │ │ ├── hero.tsx │ │ │ │ └── images.tsx │ │ │ ├── animes │ │ │ │ └── page.tsx │ │ │ ├── docs │ │ │ │ ├── _content │ │ │ │ │ ├── de-DE.mdx │ │ │ │ │ ├── en-US.mdx │ │ │ │ │ ├── es-ES.mdx │ │ │ │ │ ├── fr-FR.mdx │ │ │ │ │ ├── it-IT.mdx │ │ │ │ │ ├── ja-JP.mdx │ │ │ │ │ └── pt-BR.mdx │ │ │ │ ├── _navigation.ts │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── roadmap │ │ │ │ │ └── page.tsx │ │ │ ├── doramas │ │ │ │ └── page.tsx │ │ │ ├── forgot-password │ │ │ │ ├── _components │ │ │ │ │ ├── forgot-password-form.schema.ts │ │ │ │ │ └── forgot-password-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── home │ │ │ │ ├── _components │ │ │ │ │ ├── network-activity.tsx │ │ │ │ │ ├── popular-reviews.tsx │ │ │ │ │ └── user-last-review.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── lists │ │ │ │ ├── [id] │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── list-actions.tsx │ │ │ │ │ │ ├── list-banner.tsx │ │ │ │ │ │ ├── list-items │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── list-item-actions.tsx │ │ │ │ │ │ │ ├── list-item-card.tsx │ │ │ │ │ │ │ ├── list-items-grid.tsx │ │ │ │ │ │ │ ├── list-items-skeleton.tsx │ │ │ │ │ │ │ └── list-items.tsx │ │ │ │ │ │ ├── list-page-results.tsx │ │ │ │ │ │ ├── list-private.tsx │ │ │ │ │ │ └── user-resume │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── user-resume.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── latest-lists.tsx │ │ │ │ │ ├── list-form-schema.tsx │ │ │ │ │ ├── list-form.tsx │ │ │ │ │ ├── lists.tsx │ │ │ │ │ ├── popular-list-card.tsx │ │ │ │ │ └── see-all-lists.tsx │ │ │ │ └── page.tsx │ │ │ ├── movies │ │ │ │ ├── [id] │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── movie-collection-dialog.tsx │ │ │ │ │ │ ├── movie-collection.tsx │ │ │ │ │ │ ├── movie-details.tsx │ │ │ │ │ │ ├── movie-genres.tsx │ │ │ │ │ │ ├── movie-infos.tsx │ │ │ │ │ │ ├── movie-related.tsx │ │ │ │ │ │ └── movie-tabs.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── discover │ │ │ │ │ └── page.tsx │ │ │ │ ├── now-playing │ │ │ │ │ └── page.tsx │ │ │ │ ├── popular │ │ │ │ │ └── page.tsx │ │ │ │ ├── sitemap.xml │ │ │ │ │ └── route.ts │ │ │ │ ├── top-rated │ │ │ │ │ └── page.tsx │ │ │ │ └── upcoming │ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── people │ │ │ │ └── [id] │ │ │ │ │ ├── _biography.tsx │ │ │ │ │ ├── _credits-page.tsx │ │ │ │ │ ├── _infos.tsx │ │ │ │ │ ├── _person_tabs.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ ├── pricing │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ ├── _components │ │ │ │ │ ├── reset-password-form.schema.ts │ │ │ │ │ └── reset-password-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── _sign-in-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── sign-up │ │ │ │ ├── _components │ │ │ │ │ ├── sign-up-form.schema.ts │ │ │ │ │ └── sign-up-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── sitemap.xml │ │ │ │ └── route.ts │ │ │ └── tv-series │ │ │ │ ├── [id] │ │ │ │ ├── _components │ │ │ │ │ ├── tv-serie-details.tsx │ │ │ │ │ ├── tv-serie-genres.tsx │ │ │ │ │ ├── tv-serie-infos.tsx │ │ │ │ │ ├── tv-serie-related.tsx │ │ │ │ │ ├── tv-serie-seasons-overview.tsx │ │ │ │ │ ├── tv-serie-seasons.tsx │ │ │ │ │ ├── tv-serie-tabs.tsx │ │ │ │ │ └── tv-series-progress.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── seasons │ │ │ │ │ └── [seasonNumber] │ │ │ │ │ ├── _components │ │ │ │ │ ├── season-details.tsx │ │ │ │ │ ├── season-episodes.tsx │ │ │ │ │ ├── season-navigation.tsx │ │ │ │ │ └── season-tabs.tsx │ │ │ │ │ ├── episodes │ │ │ │ │ └── [episodeNumber] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── episode-details.tsx │ │ │ │ │ │ ├── episode-navigation.tsx │ │ │ │ │ │ └── episode-tabs.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── airing-today │ │ │ │ └── page.tsx │ │ │ │ ├── discover │ │ │ │ └── page.tsx │ │ │ │ ├── on-the-air │ │ │ │ └── page.tsx │ │ │ │ ├── popular │ │ │ │ └── page.tsx │ │ │ │ ├── sitemap.xml │ │ │ │ └── route.ts │ │ │ │ └── top-rated │ │ │ │ └── page.tsx │ │ ├── api │ │ │ ├── checkout_sessions │ │ │ │ └── route.ts │ │ │ └── proxy │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── lib │ │ │ ├── dal.ts │ │ │ ├── definitions.ts │ │ │ └── session.ts │ │ ├── robots.ts │ │ └── sitemap.ts │ ├── components │ │ ├── animated-link │ │ │ ├── animated-link.tsx │ │ │ └── index.tsx │ │ ├── animes-list │ │ │ ├── anime-list-content.tsx │ │ │ ├── anime-list.tsx │ │ │ └── index.ts │ │ ├── banner │ │ │ ├── banner.tsx │ │ │ └── index.ts │ │ ├── collection-filters │ │ │ ├── collection-filters-schema.ts │ │ │ ├── collection-filters.tsx │ │ │ ├── index.ts │ │ │ └── tabs │ │ │ │ ├── filters │ │ │ │ ├── (fields) │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── media_type.tsx │ │ │ │ │ ├── only_items_without_review.tsx │ │ │ │ │ └── rating.tsx │ │ │ │ ├── filters.tsx │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── sort-by │ │ │ │ ├── index.ts │ │ │ │ └── sort-by.tsx │ │ ├── command-search │ │ │ ├── command-search-group.tsx │ │ │ ├── command-search-icon.tsx │ │ │ ├── command-search-items.tsx │ │ │ ├── command-search.tsx │ │ │ └── index.ts │ │ ├── credits │ │ │ ├── credit-card.tsx │ │ │ ├── credits.tsx │ │ │ └── index.ts │ │ ├── dorama-list │ │ │ ├── dorama-list-content.tsx │ │ │ ├── dorama-list.tsx │ │ │ └── index.ts │ │ ├── follow-button │ │ │ ├── follow-button.tsx │ │ │ └── index.ts │ │ ├── followers │ │ │ ├── followers.tsx │ │ │ └── index.ts │ │ ├── footer │ │ │ ├── footer.tsx │ │ │ └── index.ts │ │ ├── full-review │ │ │ ├── full-review.tsx │ │ │ └── index.ts │ │ ├── gtag │ │ │ ├── gtag.tsx │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── header-account.tsx │ │ │ ├── header-navigation-data.tsx │ │ │ ├── header-navigation-drawer-configs.tsx │ │ │ ├── header-navigation-drawer-item.tsx │ │ │ ├── header-navigation-drawer-user.tsx │ │ │ ├── header-navigation-drawer.tsx │ │ │ ├── header-navigation-menu.tsx │ │ │ ├── header-popular-movie.tsx │ │ │ ├── header-popular-tv-serie.tsx │ │ │ ├── header.tsx │ │ │ └── index.ts │ │ ├── image-picker │ │ │ ├── image-picker-crop.tsx │ │ │ ├── image-picker-item.tsx │ │ │ ├── image-picker-list.tsx │ │ │ ├── image-picker-root.tsx │ │ │ ├── image-picker.ts │ │ │ └── index.ts │ │ ├── images │ │ │ ├── images-masonry.tsx │ │ │ ├── images.tsx │ │ │ └── index.ts │ │ ├── item-hover-card │ │ │ ├── index.ts │ │ │ └── item-hover-card.tsx │ │ ├── item-review │ │ │ └── index.tsx │ │ ├── item-status │ │ │ ├── index.ts │ │ │ └── item-status.tsx │ │ ├── likes │ │ │ ├── index.ts │ │ │ └── likes.tsx │ │ ├── list-card │ │ │ ├── index.ts │ │ │ └── list-card.tsx │ │ ├── list-command │ │ │ ├── index.ts │ │ │ ├── list-command-group.tsx │ │ │ ├── list-command-item.tsx │ │ │ ├── list-command-movies.tsx │ │ │ ├── list-command-tv.tsx │ │ │ └── list-command.tsx │ │ ├── lists │ │ │ ├── index.ts │ │ │ └── lists-dropdown.tsx │ │ ├── logo │ │ │ ├── index.ts │ │ │ └── logo.tsx │ │ ├── movie-list │ │ │ ├── index.ts │ │ │ ├── movie-list.tsx │ │ │ ├── movie-list.types.ts │ │ │ └── use-movie-list-query.tsx │ │ ├── movies-list-filters │ │ │ ├── index.ts │ │ │ ├── movies-list-filters-schema.ts │ │ │ ├── movies-list-filters.tsx │ │ │ ├── movies-list-filters.utils.ts │ │ │ └── tabs │ │ │ │ ├── filters │ │ │ │ ├── (fields) │ │ │ │ │ ├── genres.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── language.tsx │ │ │ │ │ ├── release-date.tsx │ │ │ │ │ ├── vote-average.tsx │ │ │ │ │ └── vote-count.tsx │ │ │ │ ├── filters.tsx │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── sort-by │ │ │ │ ├── index.ts │ │ │ │ └── sort-by.tsx │ │ ├── no-account-tooltip │ │ │ ├── index.ts │ │ │ └── no-account-tooltip.tsx │ │ ├── pattern │ │ │ ├── index.ts │ │ │ └── pattern.tsx │ │ ├── people-list │ │ │ ├── index.ts │ │ │ └── people-list.tsx │ │ ├── person-card │ │ │ ├── index.ts │ │ │ └── person-card.tsx │ │ ├── poster-card │ │ │ ├── index.ts │ │ │ └── poster-card.tsx │ │ ├── poster │ │ │ ├── index.ts │ │ │ └── poster.tsx │ │ ├── pricing │ │ │ ├── index.ts │ │ │ ├── price.tsx │ │ │ └── pricing.tsx │ │ ├── pro-badge │ │ │ ├── index.ts │ │ │ └── pro-badge.tsx │ │ ├── pro-feature-tooltip │ │ │ ├── index.ts │ │ │ └── pro-feature-tooltip.tsx │ │ ├── providers.tsx │ │ ├── reviews │ │ │ ├── index.ts │ │ │ ├── review-form-dialog.tsx │ │ │ ├── review-item │ │ │ │ ├── index.ts │ │ │ │ ├── review-item-actions.tsx │ │ │ │ ├── review-item-edit-actions.tsx │ │ │ │ ├── review-item-skeleton.tsx │ │ │ │ └── review-item.tsx │ │ │ ├── review-reply │ │ │ │ ├── index.ts │ │ │ │ ├── review-reply-actions.tsx │ │ │ │ ├── review-reply-edit-actions.tsx │ │ │ │ ├── review-reply-form.tsx │ │ │ │ └── review-reply.tsx │ │ │ └── reviews.tsx │ │ ├── streaming-services-badge.tsx │ │ ├── tv-series-list-filters │ │ │ ├── index.ts │ │ │ ├── tabs │ │ │ │ ├── filters │ │ │ │ │ ├── (fields) │ │ │ │ │ │ ├── air-date.tsx │ │ │ │ │ │ ├── genres.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── language.tsx │ │ │ │ │ │ ├── vote-average.tsx │ │ │ │ │ │ └── vote-count.tsx │ │ │ │ │ ├── filters.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── sort-by │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sort-by.tsx │ │ │ ├── tv-series-list-filters-schema.ts │ │ │ ├── tv-series-list-filters.tsx │ │ │ └── tv-series-list-filters.utils.ts │ │ ├── tv-series-list │ │ │ ├── index.ts │ │ │ ├── tv-series-list.tsx │ │ │ ├── tv-series-list.types.ts │ │ │ └── use-tv-series-list-query.ts │ │ ├── videos │ │ │ ├── index.ts │ │ │ ├── videos.test.tsx │ │ │ └── videos.tsx │ │ ├── watch-providers.tsx │ │ ├── watch-region.tsx │ │ └── where-to-watch │ │ │ ├── index.tsx │ │ │ └── where-to-watch.tsx │ ├── context │ │ ├── language.tsx │ │ ├── list-mode.tsx │ │ ├── lists.tsx │ │ ├── session.tsx │ │ └── user-preferences.tsx │ ├── env.mjs │ ├── hooks │ │ └── use-media-query │ │ │ ├── index.ts │ │ │ └── use-media-query.ts │ ├── lib │ │ └── utils.ts │ ├── middleware.ts │ ├── services │ │ ├── api.ts │ │ ├── axios-instance.ts │ │ ├── stripe.ts │ │ └── tmdb.ts │ ├── types │ │ ├── languages │ │ │ └── index.ts │ │ ├── media-type.ts │ │ ├── user-item.ts │ │ └── user.ts │ └── utils │ │ ├── array │ │ ├── get-random-items.ts │ │ └── index.ts │ │ ├── currency │ │ ├── format.spec.ts │ │ └── format.ts │ │ ├── date │ │ ├── format-date-to-url.ts │ │ ├── locale.ts │ │ └── time-from-now.ts │ │ ├── dictionaries │ │ ├── get-dictionaries.ts │ │ └── index.ts │ │ ├── list │ │ ├── index.ts │ │ └── list-page-query-key.tsx │ │ ├── number │ │ └── format-number.ts │ │ ├── operating-system │ │ ├── detect-operating-system.ts │ │ └── index.ts │ │ ├── review.ts │ │ ├── seo │ │ ├── get-movie-metadata.ts │ │ ├── get-movies-ids.ts │ │ ├── get-tv-metadata.ts │ │ └── get-tv-series-ids.ts │ │ └── tmdb │ │ ├── department.ts │ │ ├── image.ts │ │ └── job.ts │ ├── tailwind.config.ts │ ├── test │ └── mocks.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── biome.json ├── next-env.d.ts ├── package.json ├── packages ├── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── components.json │ ├── package.json │ ├── postcss.config.mjs │ ├── src │ ├── components │ │ ├── magicui │ │ │ └── blur-fade.tsx │ │ └── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── animated-list.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── border-beam.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── confetti.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── credenza.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── flickering-grid.tsx │ │ │ ├── flip-words.tsx │ │ │ ├── form.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── iphone-15-pro.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── rating.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ ├── globals.css │ ├── hooks │ │ ├── use-media-query.ts │ │ └── use-toast.ts │ └── lib │ │ └── utils.ts │ ├── tailwind.config.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── turbo.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_TMDB_API_KEY= 2 | 3 | NEXT_PUBLIC_MEASUREMENT_ID= 4 | 5 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= 6 | STRIPE_SECRET_KEY= 7 | 8 | -------------------------------------------------------------------------------- /.github/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | 7 | env: 8 | FORCE_COLOR: true 9 | 10 | jobs: 11 | tests: 12 | name: 'Tests' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: ⬇️ Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: 🔧 Install pnpm 19 | uses: pnpm/action-setup@v4 20 | with: 21 | version: 9 22 | 23 | - name: 🔧 Setup Node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: 'pnpm' 28 | 29 | - name: 📦 Install dependencies 30 | run: pnpm install 31 | 32 | - name: 🧪 Run tests 33 | run: pnpm test 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | ## Issue ticket number and link 4 | 5 | ## Checklist before requesting a review 6 | - [ ] I have performed a self-review of my code 7 | - [ ] If it's an essential feature, I've tested it thoroughly. 8 | - [ ] Do we need to implement analytics? 9 | - [ ] Will this be part of a product update? If yes, please write one phrase about this update. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/.npmrc -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 23.8.0 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "patreon", 4 | "plotwist", 5 | "sonner", 6 | "supabase", 7 | "tanstack", 8 | "tmdb", 9 | "unfollow", 10 | "Watchlist" 11 | ], 12 | 13 | "files.eol": "\n", 14 | 15 | "editor.formatOnSave": true, 16 | "[typescript]": { 17 | "editor.defaultFormatter": "biomejs.biome" 18 | }, 19 | "[typescriptreact]": { 20 | "editor.defaultFormatter": "biomejs.biome" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.MD: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/CONTRIBUTING.MD -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot_8](https://github.com/status-451/plotwist/assets/70612836/94637abe-c937-41b3-b855-18b5c983d886) 2 | 3 | # Plotwist 4 | 5 | `[In development]` 6 | Open-source easy management and reviews about movies, series and animes. 7 | 8 | [Discord](https://discord.gg/ZsBJm9Qk) • [Website](https://plotwist.app/en-US) 9 | 10 | # Tech Stack 11 | 12 | - Framework: [Next.js](https://nextjs.org/) 13 | - Styling: [TailwindCSS](https://tailwindcss.com/) 14 | - UI Components: [shadcn](https://ui.shadcn.com/) 15 | - Payments: [Stripe](https://stripe.com/br) 16 | 17 | # Contributing 18 | 19 | If you are interested in contributing, feel free to read our [contributing guide](https://github.com/plotwist-app/plotwist/blob/main/CONTRIBUTING.MD). 20 | 21 | # Star History 22 | 23 | 24 | 30 | 36 | Star History Chart 40 | 41 | -------------------------------------------------------------------------------- /apps/web/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_URL = 2 | process.env.NODE_ENV === 'production' 3 | ? 'https://plotwist.app' 4 | : 'http://localhost:3000' 5 | -------------------------------------------------------------------------------- /apps/web/languages.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from '@/types/languages' 2 | 3 | export type SupportedLanguages = { 4 | label: string 5 | value: Language 6 | country: string 7 | enabled: boolean 8 | hreflang: string 9 | } 10 | 11 | export const SUPPORTED_LANGUAGES: Array = [ 12 | { 13 | label: 'English', 14 | value: 'en-US', 15 | country: 'US', 16 | enabled: true, 17 | hreflang: 'en-us', 18 | }, 19 | { 20 | label: 'Español', 21 | value: 'es-ES', 22 | country: 'ES', 23 | enabled: true, 24 | hreflang: 'es-ES', 25 | }, 26 | { 27 | label: 'Français', 28 | value: 'fr-FR', 29 | country: 'FR', 30 | enabled: true, 31 | hreflang: 'fr-FR', 32 | }, 33 | { 34 | label: 'Deutsch', 35 | value: 'de-DE', 36 | country: 'DE', 37 | enabled: true, 38 | hreflang: 'de-DE', 39 | }, 40 | { 41 | label: 'Italiano', 42 | value: 'it-IT', 43 | country: 'IT', 44 | enabled: true, 45 | hreflang: 'it-IT', 46 | }, 47 | { 48 | label: 'Português', 49 | value: 'pt-BR', 50 | country: 'BR', 51 | enabled: true, 52 | hreflang: 'pt-BR', 53 | }, 54 | { 55 | label: '日本語', 56 | value: 'ja-JP', 57 | country: 'JP', 58 | enabled: true, 59 | hreflang: 'ja-JP', 60 | }, 61 | ] 62 | 63 | export const languages: Language[] = [ 64 | 'en-US', 65 | 'es-ES', 66 | 'fr-FR', 67 | 'de-DE', 68 | 'it-IT', 69 | 'pt-BR', 70 | 'ja-JP', 71 | ] 72 | -------------------------------------------------------------------------------- /apps/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | import createMDX from '@next/mdx' 2 | 3 | const withMDX = createMDX() 4 | 5 | /** @type {import('next').NextConfig} */ 6 | const nextConfig = { 7 | images: { 8 | remotePatterns: [ 9 | { 10 | hostname: 'image.tmdb.org', 11 | }, 12 | ], 13 | unoptimized: true, 14 | }, 15 | pageExtensions: ['mdx', 'ts', 'tsx'], 16 | transpilePackages: ['@plotwist/ui'], 17 | } 18 | 19 | export default withMDX(nextConfig) 20 | -------------------------------------------------------------------------------- /apps/web/orval.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'plotwist-api': { 3 | input: { 4 | target: 'http://localhost:3333/api-docs/json', 5 | }, 6 | output: { 7 | mode: 'tags', 8 | target: 'src/api/endpoints.ts', 9 | client: 'react-query', 10 | override: { 11 | mutator: { 12 | path: './src/services/axios-instance.ts', 13 | name: 'axiosInstance', 14 | }, 15 | query: { 16 | useQuery: true, 17 | useInfinite: false, 18 | useSuspenseQuery: true, 19 | useSuspenseInfiniteQuery: false, 20 | }, 21 | operations: { 22 | getFollowers: { 23 | query: { 24 | useInfinite: true, 25 | }, 26 | }, 27 | getUserActivities: { 28 | query: { 29 | useInfinite: true, 30 | }, 31 | }, 32 | getUserItems: { 33 | query: { 34 | useInfinite: true, 35 | }, 36 | }, 37 | }, 38 | }, 39 | 40 | // mock: true, 41 | }, 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@plotwist/ui/postcss.config' 2 | -------------------------------------------------------------------------------- /apps/web/public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/apple-icon.png -------------------------------------------------------------------------------- /apps/web/public/assets/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/public/assets/rotten.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/assets/rotten.png -------------------------------------------------------------------------------- /apps/web/public/assets/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /apps/web/public/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/icon.ico -------------------------------------------------------------------------------- /apps/web/public/images/landing-page.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/dark/activity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/dark/activity.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/dark/collection.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/dark/collection.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/dark/preferences.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/dark/preferences.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/dark/reviews.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/dark/reviews.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/dark/stats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/dark/stats.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/activity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/activity.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/collection.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/collection.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/lists.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/lists.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/preferences.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/preferences.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/reviews.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/reviews.jpg -------------------------------------------------------------------------------- /apps/web/public/images/landing-page/light/stats.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/images/landing-page/light/stats.jpg -------------------------------------------------------------------------------- /apps/web/public/logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/logo-black.png -------------------------------------------------------------------------------- /apps/web/public/logo-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/plotwist-app/plotwist/c62976592c456c49d6d3519856c0f8777ca71b3e/apps/web/public/logo-white.png -------------------------------------------------------------------------------- /apps/web/src/actions/auth/logout.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import type { Language } from '@plotwist_app/tmdb' 4 | import { cookies } from 'next/headers' 5 | import { redirect } from 'next/navigation' 6 | 7 | export async function logout(language: Language) { 8 | ;(await cookies()).delete('session') 9 | redirect(`/${language}/sign-in`) 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/actions/auth/reset-password.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { patchUserPassword } from '@/api/users' 4 | import { redirect } from 'next/navigation' 5 | 6 | type ResetPassword = { 7 | token: string 8 | password: string 9 | } 10 | 11 | export async function resetPassword({ password, token }: ResetPassword) { 12 | const { status } = await patchUserPassword({ token, password }) 13 | 14 | if (status === 'password_set') { 15 | redirect('/sign-in') 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/src/actions/auth/sign-in.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { postLogin } from '@/api/auth' 4 | import { createSession } from '@/app/lib/session' 5 | import { redirect } from 'next/navigation' 6 | 7 | type SignInInput = { 8 | login: string 9 | password: string 10 | redirectTo?: string 11 | } 12 | 13 | export async function signIn({ login, password, redirectTo }: SignInInput) { 14 | const { token, status } = await postLogin({ login, password }) 15 | 16 | if (token) { 17 | await createSession({ token }) 18 | 19 | if (redirectTo) { 20 | redirect(redirectTo) 21 | } 22 | } 23 | 24 | return { status } 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/actions/auth/sign-up.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import type { PostUsersCreateBody } from '@/api/endpoints.schemas' 4 | import { postUsersCreate } from '@/api/users' 5 | import { api } from '@/services/api' 6 | import type { Language } from '@plotwist_app/tmdb' 7 | import { redirect } from 'next/navigation' 8 | import { signIn } from './sign-in' 9 | 10 | type SignUpParams = PostUsersCreateBody & { 11 | redirectToCheckout?: boolean 12 | language: Language 13 | } 14 | 15 | export async function signUp({ 16 | email, 17 | password, 18 | username, 19 | language, 20 | redirectToCheckout, 21 | }: SignUpParams) { 22 | const { user } = await postUsersCreate({ email, password, username }) 23 | if (!user) return 24 | 25 | await signIn({ 26 | login: email, 27 | password, 28 | redirectTo: redirectToCheckout ? undefined : `/${language}/${username}`, 29 | }) 30 | 31 | if (redirectToCheckout) { 32 | const { data } = await api.post( 33 | `/checkout_sessions?locale=${language.split('-')[0]}&email=${email}&username=${username}` 34 | ) 35 | 36 | if (data.url) { 37 | redirect(data.url) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/_components/profile-achievements.tsx: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/utils/dictionaries' 2 | import * as icons from 'lucide-react' 3 | import { v4 } from 'uuid' 4 | 5 | const RandomIcon = () => { 6 | const iconNames = Object.keys(icons) 7 | const randomIconName = iconNames[Math.floor(Math.random() * iconNames.length)] 8 | const IconComponent = icons[ 9 | randomIconName as keyof typeof icons 10 | ] as icons.LucideIcon 11 | 12 | return 13 | } 14 | 15 | type ProfileAchievementsProps = { 16 | dictionary: Dictionary 17 | } 18 | 19 | export const ProfileAchievements = ({ 20 | dictionary, 21 | }: ProfileAchievementsProps) => { 22 | return ( 23 |
24 |
25 | 🚧 {dictionary.profile.work_in_progress} 26 |
27 | 28 |

{dictionary.profile.achievements}

29 | 30 |
31 | {Array.from({ length: 30 }).map((_, index) => { 32 | return ( 33 |
37 | 38 |
39 | ) 40 | })} 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/_components/user-items.tsx: -------------------------------------------------------------------------------- 1 | import type { CollectionFiltersFormValues } from '@/components/collection-filters/collection-filters-schema' 2 | import { Skeleton } from '@plotwist/ui/components/ui/skeleton' 3 | import { Suspense } from 'react' 4 | import { v4 } from 'uuid' 5 | import { UserItemsList } from './user-items-list' 6 | export type UserItemsProps = { 7 | filters: CollectionFiltersFormValues 8 | } 9 | 10 | export function UserItems({ filters }: UserItemsProps) { 11 | return ( 12 |
13 | ( 15 | 16 | ))} 17 | > 18 | 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/_components/user-preferences/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user-preferences' 2 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/_context.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type React from 'react' 4 | import { createContext, useContext } from 'react' 5 | 6 | type LayoutContextProps = { 7 | userId: string 8 | avatarUrl: string | null 9 | username: string 10 | } 11 | 12 | const LayoutContext = createContext(undefined) 13 | 14 | export const LayoutProvider = ({ 15 | children, 16 | userId, 17 | avatarUrl, 18 | username, 19 | }: { 20 | children: React.ReactNode 21 | } & LayoutContextProps) => { 22 | return ( 23 | 24 | {children} 25 | 26 | ) 27 | } 28 | 29 | export const useLayoutContext = () => { 30 | const context = useContext(LayoutContext) 31 | if (!context) { 32 | throw new Error( 33 | 'useLayoutContext deve ser usado dentro de um LayoutProvider' 34 | ) 35 | } 36 | return context 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/lists/page.tsx: -------------------------------------------------------------------------------- 1 | import { ListCardSkeleton } from '@/components/list-card' 2 | import { Suspense } from 'react' 3 | import { v4 } from 'uuid' 4 | import { UserLists } from '../_components/user-lists' 5 | 6 | export default async function ListsPage() { 7 | return ( 8 | 11 | {Array.from({ length: 3 }).map(_ => ( 12 | 13 | ))} 14 | 15 | } 16 | > 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/reviews/_reviews.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useGetDetailedReviewsSuspense } from '@/api/reviews' 4 | import { FullReview } from '@/components/full-review' 5 | import { useLanguage } from '@/context/language' 6 | import { EmptyReview } from '../../home/_components/user-last-review' 7 | import { useLayoutContext } from '../_context' 8 | 9 | export const Reviews = () => { 10 | const { language } = useLanguage() 11 | const { userId } = useLayoutContext() 12 | const { data } = useGetDetailedReviewsSuspense({ 13 | language, 14 | userId, 15 | }) 16 | 17 | if (data.reviews.length === 0) { 18 | return 19 | } 20 | 21 | return ( 22 |
23 | {data.reviews.map(review => ( 24 | 25 | ))} 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/reviews/page.tsx: -------------------------------------------------------------------------------- 1 | import { FullReviewSkeleton } from '@/components/full-review' 2 | import { Suspense } from 'react' 3 | import { Reviews } from './_reviews' 4 | 5 | export default async function ReviewsPage() { 6 | return ( 7 | }> 8 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/[username]/stats/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import { BestRated, BestRatedSkeleton } from './_best_rated' 3 | import { Countries, CountriesSkeleton } from './_countries' 4 | import { Genres, GenresSkeleton } from './_genres' 5 | import { 6 | MostWatchedSeries, 7 | MostWatchedSeriesSkeleton, 8 | } from './_most_watched-series' 9 | import { ReviewsCount, ReviewsCountSkeleton } from './_reviews-count' 10 | import { Status, StatusSkeleton } from './_status' 11 | import { TopActors, TopActorsSkeleton } from './_top_actors' 12 | import { TotalHours, TotalHoursSkeleton } from './_total_hours' 13 | 14 | export default function StatsPage() { 15 | return ( 16 |
17 | }> 18 | 19 | 20 | 21 | }> 22 | 23 | 24 | 25 | }> 26 | 27 | 28 | 29 | }> 30 | 31 | 32 | 33 | }> 34 | 35 | 36 | 37 | }> 38 | 39 | 40 | 41 | }> 42 | 43 | 44 | 45 | }> 46 | 47 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/_components/container.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | 3 | export const Container = (props: PropsWithChildren) => { 4 | return ( 5 |
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/_components/hero.tsx: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/utils/dictionaries' 2 | import { Badge } from '@plotwist/ui/components/ui/badge' 3 | import { Button } from '@plotwist/ui/components/ui/button' 4 | import { Link } from 'next-view-transitions' 5 | 6 | type HeroProps = { 7 | dictionary: Dictionary 8 | } 9 | 10 | export async function Hero({ dictionary }: HeroProps) { 11 | return ( 12 |
13 | 14 | 15 | {dictionary.community_badge} 16 | 17 | 18 | 19 |

20 | {dictionary.perfect_place_for_watching} {dictionary.everything} 21 |

22 | 23 |

24 | {dictionary.manage_rate_discover} 25 |

26 | 27 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/animes/page.tsx: -------------------------------------------------------------------------------- 1 | import { AnimeList } from '@/components/animes-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 { lang } = await props.params 9 | const { animes_page } = await getDictionary(lang) 10 | 11 | const title = animes_page.title 12 | const description = animes_page.description 13 | 14 | return { 15 | title, 16 | description, 17 | openGraph: { 18 | title, 19 | description, 20 | siteName: 'Plotwist', 21 | }, 22 | twitter: { 23 | title, 24 | description, 25 | }, 26 | } 27 | } 28 | 29 | export default async function DiscoverMoviesPage(props: PageProps) { 30 | const { lang } = await props.params 31 | const dictionary = await getDictionary(lang) 32 | 33 | return ( 34 | 35 |
36 |
37 |

{dictionary.animes_page.title}

38 |

39 | {dictionary.animes_page.description} 40 |

41 |
42 |
43 | 44 | 45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/de-DE.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** ist eine umfassende Plattform, um Filme, Serien, Animes, Dramen und mehr zu verwalten und zu bewerten – alles an einem Ort, anders als Alternativen wie Letterboxd und MyAnimeList. 2 | 3 | #### **Kostenloser Plan** 4 | - Unbegrenzte Bewertungen und Listen. 5 | - Tools zur Verwaltung von angesehenen, laufenden und geplanten Inhalten. 6 | - Kollaborative Listen und Empfehlungen für Freunde. 7 | 8 | #### **Pro-Plan** 9 | - Detaillierte Statistiken (angesehene Stunden, Genres, Schauspieler usw.). 10 | - Personalisierte Empfehlungen. 11 | - Abzeichen und Erfolge. 12 | - Frühzeitiger Zugriff auf neue Funktionen. 13 | 14 | #### **Ziel** 15 | Aufbau einer lebendigen Community und eines zentralen Anlaufpunkts für Liebhaber audiovisueller Unterhaltung. 16 | 17 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/en-US.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** is a complete platform for managing and reviewing movies, series, anime, dramas, and more, all in one place, unlike alternatives like Letterboxd and MyAnimeList. 2 | 3 | #### **Free Plan** 4 | - Unlimited reviews and lists. 5 | - Tools to manage watched, ongoing, and planned content. 6 | - Collaborative lists and recommendations for friends. 7 | 8 | #### **Pro Plan** 9 | - Detailed statistics (hours watched, genres, actors, etc.). 10 | - Personalized recommendations. 11 | - Badges and achievements. 12 | - Early access to new features. 13 | 14 | #### **Objective** 15 | Build a vibrant community and become the ultimate destination for audiovisual entertainment enthusiasts. -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/es-ES.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** es una plataforma completa para gestionar y reseñar películas, series, animes, dramas y más, todo en un solo lugar, a diferencia de alternativas como Letterboxd y MyAnimeList. 2 | 3 | #### **Plan Gratis** 4 | - Reseñas y listas ilimitadas. 5 | - Herramientas para gestionar contenido visto, en progreso y planificado. 6 | - Listas colaborativas y recomendaciones para amigos. 7 | 8 | #### **Plan Pro** 9 | - Estadísticas detalladas (horas vistas, géneros, actores, etc.). 10 | - Recomendaciones personalizadas. 11 | - Insignias y logros. 12 | - Acceso anticipado a nuevas funciones. 13 | 14 | #### **Objetivo** 15 | Crear una comunidad vibrante y convertirse en el destino definitivo para los amantes del entretenimiento audiovisual. -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/fr-FR.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** est une plateforme complète pour gérer et évaluer des films, séries, animes, dramas et plus encore, tout en un seul endroit, contrairement à des alternatives comme Letterboxd et MyAnimeList. 2 | 3 | #### **Plan Gratuit** 4 | - Avis et listes illimités. 5 | - Outils pour gérer le contenu visionné, en cours ou prévu. 6 | - Listes collaboratives et recommandations pour des amis. 7 | 8 | #### **Plan Pro** 9 | - Statistiques détaillées (heures visionnées, genres, acteurs, etc.). 10 | - Recommandations personnalisées. 11 | - Badges et succès. 12 | - Accès anticipé aux nouvelles fonctionnalités. 13 | 14 | #### **Objectif** 15 | Créer une communauté dynamique et devenir la destination ultime pour les passionnés de divertissement audiovisuel. 16 | 17 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/it-IT.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** è una piattaforma completa per gestire e recensire film, serie TV, anime, drama e altro, tutto in un unico posto, diversamente da alternative come Letterboxd e MyAnimeList. 2 | 3 | #### **Piano Gratuito** 4 | - Recensioni e liste illimitate. 5 | - Strumenti per gestire contenuti già visti, in corso o pianificati. 6 | - Liste collaborative e raccomandazioni per amici. 7 | 8 | #### **Piano Pro** 9 | - Statistiche dettagliate (ore guardate, generi, attori, ecc.). 10 | - Raccomandazioni personalizzate. 11 | - Distintivi e traguardi. 12 | - Accesso anticipato a nuove funzionalità. 13 | 14 | #### **Obiettivo** 15 | Creare una comunità vivace e diventare la destinazione definitiva per gli appassionati di intrattenimento audiovisivo. 16 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/ja-JP.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** は、映画、シリーズ、アニメ、ドラマなどを管理しレビューするための完全なプラットフォームで、Letterboxd や MyAnimeList のような代替案とは異なり、すべてを1か所で行えます。 2 | 3 | #### **無料プラン** 4 | - 無制限のレビューとリスト。 5 | - 視聴済み、視聴中、視聴予定のコンテンツを管理するツール。 6 | - 共同リストおよび友達へのおすすめ機能。 7 | 8 | #### **プロプラン** 9 | - 詳細な統計 (視聴時間、ジャンル、出演俳優など)。 10 | - 個人履歴に基づくおすすめ。 11 | - バッジと成果。 12 | - 新機能への優先アクセス。 13 | 14 | #### **目的** 15 | 活気あるコミュニティを構築し、視聴型エンターテインメント愛好者のための究極の目的地になること。 16 | 17 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_content/pt-BR.mdx: -------------------------------------------------------------------------------- 1 | **Plotwist** é uma plataforma completa para gerenciar e avaliar filmes, séries, animes, doramas e mais, integrando tudo em um só lugar, diferentemente de alternativas como Letterboxd e MyAnimeList. 2 | 3 | ### **Plano Grátis** 4 | - Avaliações e listas ilimitadas. 5 | - Ferramentas para gerenciar conteúdos assistidos, em andamento ou desejados. 6 | - Listas colaborativas e recomendações para amigos. 7 | 8 | ### **Plano Pro** 9 | - Estatísticas detalhadas (horas assistidas, gêneros, atores, etc.). 10 | - Recomendações personalizadas. 11 | - Distintivos e conquistas. 12 | - Acesso antecipado a novos recursos. 13 | 14 | ### **Objetivo** 15 | Construir uma comunidade vibrante e ser o destino definitivo para fãs de entretenimento audiovisual. -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/_navigation.ts: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/utils/dictionaries' 2 | 3 | type NavigationItem = { 4 | name: string 5 | href: string 6 | isNew?: boolean 7 | isDisabled?: boolean 8 | // isUpdated?: boolean 9 | } 10 | 11 | type NavigationGroup = { 12 | name: string 13 | children: NavigationItem[] 14 | } 15 | 16 | export const buildNavigation = (dictionary: Dictionary): NavigationGroup[] => [ 17 | { 18 | name: 'Plotwist', 19 | children: [ 20 | { 21 | name: dictionary.about, 22 | href: '/docs', 23 | isNew: true, 24 | }, 25 | // { 26 | // name: 'Membership', 27 | // href: '/membership', 28 | // }, 29 | // { 30 | // name: 'Usage', 31 | // href: '/membership', 32 | // }, 33 | { 34 | name: dictionary.roadmap, 35 | href: '/about/roadmap', 36 | isDisabled: true, 37 | }, 38 | ], 39 | }, 40 | // { 41 | // name: 'Importing data', 42 | // children: [ 43 | // { 44 | // name: 'Letterboxd', 45 | // href: '/etterboxd', 46 | // isDisabled: true, 47 | // }, 48 | // { 49 | // name: 'MyAnimeList', 50 | // href: '/my-anime-list', 51 | // isDisabled: true, 52 | // }, 53 | // ], 54 | // }, 55 | ] 56 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import type { PageProps } from '@/types/languages' 4 | import { notFound } from 'next/navigation' 5 | 6 | export default async function Page(props: PageProps) { 7 | const params = await props.params 8 | try { 9 | const Content = (await import(`./_content/${params.lang}.mdx`)).default 10 | return 11 | } catch (error) { 12 | notFound() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/docs/roadmap/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
oi
3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/doramas/page.tsx: -------------------------------------------------------------------------------- 1 | import { DoramaList } from '@/components/dorama-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 { lang } = await props.params 9 | const { doramas, doramas_description } = await getDictionary(lang) 10 | 11 | const title = doramas 12 | const description = doramas_description 13 | 14 | return { 15 | title, 16 | description, 17 | openGraph: { 18 | title, 19 | description, 20 | siteName: 'Plotwist', 21 | }, 22 | twitter: { 23 | title, 24 | description, 25 | }, 26 | } 27 | } 28 | 29 | export default async function (props: PageProps) { 30 | const { lang } = await props.params 31 | const dictionary = await getDictionary(lang) 32 | 33 | return ( 34 | 35 |
36 |
37 |

{dictionary.doramas}

38 | 39 |

40 | {dictionary.doramas_description} 41 |

42 |
43 |
44 | 45 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /apps/web/src/app/[lang]/forgot-password/_components/forgot-password-form.schema.ts: -------------------------------------------------------------------------------- 1 | import type { Dictionary } from '@/utils/dictionaries' 2 | import { z } from 'zod' 3 | 4 | export const forgotPasswordFormSchema = (dictionary: Dictionary) => 5 | z.object({ 6 | login: z.string().min(1, dictionary.login_required), 7 | }) 8 | 9 | export type ForgotPasswordFormValues = z.infer< 10 | ReturnType 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 |
    37 | 38 |
    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 |
    18 |
    25 |
    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 | {name} 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 | 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 |
    logout(language)} className="w-full"> 43 | 50 |
    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 |
    16 |
    17 | 18 | 19 |
    20 | 21 |
    22 | {isDesktop && } 23 | 24 |
    25 |
    26 | 27 |
    28 | 29 | 30 |
    31 | {!isDesktop && } 32 |
    33 |
    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 | Logo 26 | 27 | Logo 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 |
    46 |
    47 |
    {index}
    48 |
    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 | {alt} 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 |