├── .prettierignore ├── pnpm-workspace.yaml ├── README.md ├── apps ├── stories │ ├── postcss.config.cjs │ ├── tailwind.config.cjs │ ├── .storybook │ │ ├── preview-head.html │ │ ├── stubs │ │ │ └── next-image.tsx │ │ ├── main.ts │ │ └── preview.tsx │ ├── tsconfig.json │ ├── app │ │ ├── search │ │ │ ├── layout.stories.tsx │ │ │ └── components │ │ │ │ ├── sidebar.stories.tsx │ │ │ │ └── pagination.stories.tsx │ │ ├── page.stories.tsx │ │ └── layout.stories.tsx │ ├── components │ │ ├── main-nav.stories.tsx │ │ ├── footer.stories.tsx │ │ ├── mode-toggle.stories.tsx │ │ ├── poster-image.stories.tsx │ │ ├── outside-btn.stories.tsx │ │ ├── search.stories.tsx │ │ └── site-header.stories.tsx │ ├── shadcn-ui │ │ ├── avatar.stories.tsx │ │ ├── switch.stories.tsx │ │ ├── label.stories.tsx │ │ ├── separator.stories.tsx │ │ ├── badge.stories.tsx │ │ ├── button.stories.tsx │ │ ├── accordion.stories.tsx │ │ ├── form.stories.tsx │ │ ├── card.stories.tsx │ │ ├── input.stories.tsx │ │ └── dropdown-menu.stories.tsx │ ├── package.json │ ├── CHANGELOG.md │ └── .gitignore └── web │ ├── public │ ├── favicon.ico │ ├── vercel.svg │ └── next.svg │ ├── components │ ├── theme-provider.tsx │ ├── site-footer.tsx │ ├── main-nav.tsx │ ├── outside-btn.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── badge.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── accordion.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── toast.tsx │ │ ├── command.tsx │ │ └── dropdown-menu.tsx │ ├── site-header.tsx │ ├── toaster.tsx │ ├── poster-image.tsx │ ├── mode-toggle.tsx │ └── search.tsx │ ├── postcss.config.cjs │ ├── config │ ├── tokens.ts │ └── site.ts │ ├── lib │ ├── fonts.ts │ ├── use-debounce.ts │ ├── use-preferred-language.ts │ ├── utils.ts │ ├── use-localstorage.ts │ └── use-toast.ts │ ├── next.config.mjs │ ├── tsconfig.json │ ├── components.json │ ├── .gitignore │ ├── app │ ├── search │ │ ├── layout.tsx │ │ ├── components │ │ │ ├── sidebar.tsx │ │ │ └── pagination.tsx │ │ └── [type] │ │ │ └── page.tsx │ ├── page.tsx │ ├── layout.tsx │ └── movie │ │ └── [id] │ │ └── page.tsx │ ├── README.md │ ├── package.json │ ├── styles │ └── globals.css │ ├── tailwind.config.cjs │ └── CHANGELOG.md ├── packages ├── tmdb-api │ ├── src │ │ ├── types │ │ │ ├── account │ │ │ │ ├── lists.ts │ │ │ │ ├── rated-tv.ts │ │ │ │ ├── add-favorite.ts │ │ │ │ ├── favorite-tv.ts │ │ │ │ ├── rated-movies.ts │ │ │ │ ├── watchlist-tv.ts │ │ │ │ ├── add-watchlist.ts │ │ │ │ ├── favorite-movies.ts │ │ │ │ ├── rated-tv-episodes.ts │ │ │ │ ├── watchlist-movies.ts │ │ │ │ ├── post-params.ts │ │ │ │ ├── get-params.ts │ │ │ │ ├── details.ts │ │ │ │ └── mod.ts │ │ │ ├── search │ │ │ │ ├── tv-params.ts │ │ │ │ ├── movie-params.ts │ │ │ │ ├── mulit-params.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── movie.ts │ │ │ │ ├── tv.ts │ │ │ │ ├── mulit.ts │ │ │ │ └── mod.ts │ │ │ ├── shared.ts │ │ │ ├── movie │ │ │ │ ├── details-params.ts │ │ │ │ ├── mod.ts │ │ │ │ └── details.ts │ │ │ ├── tv │ │ │ │ └── details.ts │ │ │ ├── mod.ts │ │ │ └── utils.ts │ │ ├── mod.ts │ │ └── endpoints.ts │ ├── README.md │ ├── tsconfig.json │ ├── .gitignore │ ├── vitest.config.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── tmdb.test.ts.snap │ │ └── tmdb.test.ts │ ├── tsup.config.ts │ ├── LICENSE │ ├── package.json │ └── CHANGELOG.md ├── tsconfig │ ├── package.json │ ├── CHANGELOG.md │ └── default.tsconfig.json ├── tmdb-request │ ├── tsconfig.json │ ├── .gitignore │ ├── vitest.config.ts │ ├── tsup.config.ts │ ├── src │ │ ├── split-obj.ts │ │ ├── merge-deep.ts │ │ ├── parse.ts │ │ └── defaults.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── parser.test.ts.snap │ │ ├── parser.test.ts │ │ └── request.test.ts │ ├── LICENSE │ ├── package.json │ └── CHANGELOG.md └── is-plain-object │ ├── tsconfig.json │ ├── vitest.config.ts │ ├── tsup.config.ts │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.json │ └── src │ └── mod.ts ├── .eslintrc.json ├── .changeset ├── config.json └── README.md ├── .github ├── dependabot.yml ├── changeset-version.cjs └── workflows │ ├── test.yml │ ├── build.yml │ └── release.yml ├── turbo.json ├── .prettierrc.cjs ├── LICENSE ├── package.json └── .gitignore /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | build 3 | dist 4 | node_modules 5 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Movisea 2 | 3 | Front-end implementation of The Movie Database (TMDB). 4 | -------------------------------------------------------------------------------- /apps/stories/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("movisea-web/postcss.config.cjs"); 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/lists.ts: -------------------------------------------------------------------------------- 1 | export type AccountLists = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mogeko/movisea/master/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/rated-tv.ts: -------------------------------------------------------------------------------- 1 | export type AccountRatedTV = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/tv-params.ts: -------------------------------------------------------------------------------- 1 | export type SearchTVParams = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /apps/web/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export { ThemeProvider } from "next-themes"; 4 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/add-favorite.ts: -------------------------------------------------------------------------------- 1 | export type AccountAddFavorite = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/favorite-tv.ts: -------------------------------------------------------------------------------- 1 | export type AccountFavoriteTV = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/rated-movies.ts: -------------------------------------------------------------------------------- 1 | export type AccountRatedMovies = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/watchlist-tv.ts: -------------------------------------------------------------------------------- 1 | export type AccountWatchlistTV = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/movie-params.ts: -------------------------------------------------------------------------------- 1 | export type SearchMovieParams = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/mulit-params.ts: -------------------------------------------------------------------------------- 1 | export type SearchMulitParams = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/add-watchlist.ts: -------------------------------------------------------------------------------- 1 | export type AccountAddWatchlist = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/favorite-movies.ts: -------------------------------------------------------------------------------- 1 | export type AccountFavoriteMovies = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/rated-tv-episodes.ts: -------------------------------------------------------------------------------- 1 | export type AccountrRatedTVEpisodes = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/watchlist-movies.ts: -------------------------------------------------------------------------------- 1 | export type AccountWatchlistMovies = any; // TODO: Implement 2 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/config/tokens.ts: -------------------------------------------------------------------------------- 1 | export const tokens = { 2 | tmdb: process.env.TMDB_TOKEN, 3 | }; 4 | 5 | export type Tokens = typeof tokens; 6 | -------------------------------------------------------------------------------- /apps/web/components/site-footer.tsx: -------------------------------------------------------------------------------- 1 | export const SiteFooter: React.FC = () => { 2 | return ; 3 | }; 4 | -------------------------------------------------------------------------------- /apps/web/lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | 3 | export const sans = Inter({ 4 | subsets: ["latin"], 5 | variable: "--font-sans", 6 | }); 7 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/post-params.ts: -------------------------------------------------------------------------------- 1 | export type AccountPostParams = { 2 | id: number; 3 | session_id?: string; 4 | body: Record; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/shared.ts: -------------------------------------------------------------------------------- 1 | export type Language = string; 2 | 3 | export type Coutry = string; 4 | export type ISO_639_1 = string; 5 | export type ISO_3166_1 = string; 6 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/utils.ts: -------------------------------------------------------------------------------- 1 | export type SearchResult = { 2 | page: number; 3 | results: Array; 4 | total_pages: number; 5 | total_results: number; 6 | }; 7 | -------------------------------------------------------------------------------- /apps/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | imageSizes: [94, 300, 640, 1280], 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /apps/web/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | name: "Movisea", 3 | description: "Front-end implementation of The Movie Database (TMDB)", 4 | }; 5 | 6 | export type SiteConfig = typeof siteConfig; 7 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/movie/details-params.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "@/types/shared"; 2 | 3 | export type MovieDetailsParams = { 4 | append_to_response?: string; 5 | language?: Language; 6 | id: number; 7 | }; 8 | -------------------------------------------------------------------------------- /apps/stories/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | ...require("movisea-web/tailwind.config.cjs"), 4 | content: ["../web/{app,components}/**/*.{ts,tsx}", "./**/*.{ts,tsx}"], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/tv/details.ts: -------------------------------------------------------------------------------- 1 | import { Coutry } from "@/types/shared"; 2 | 3 | export type TVDifferent = { 4 | media_type: "tv"; 5 | name: string; 6 | original_name: string; 7 | first_air_date: string; 8 | origin_country: Coutry[]; 9 | }; 10 | -------------------------------------------------------------------------------- /apps/stories/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/get-params.ts: -------------------------------------------------------------------------------- 1 | import { Language } from "@/types/shared"; 2 | 3 | export type AccountGetParams = { 4 | id: number; 5 | language?: Language; 6 | page?: number; 7 | sort_by?: "created_at.asc" | "created_at.desc"; 8 | session_id?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/movie.ts: -------------------------------------------------------------------------------- 1 | import type { MovieAndTVShared, MovieDifferent } from "@/types/movie/details"; 2 | import type { SearchResult } from "@/types/search/utils"; 3 | 4 | export type SearchMovieResult = SearchResult< 5 | Omit & MovieAndTVShared 6 | >; 7 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.2", 4 | "description": "Shared tsconfig.json for typescript projects", 5 | "private": true, 6 | "scripts": { 7 | "lint": "prettier --check . --cache", 8 | "fmt": "prettier --write . --cache" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "ignorePatterns": ["node_modules/", ".next/", "dist/", "public/"], 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "plugin:prettier/recommended", 7 | "plugin:storybook/recommended", 8 | "turbo" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /packages/tmdb-api/README.md: -------------------------------------------------------------------------------- 1 | # tmdb-api.js 2 | 3 | > **Warning** Not ready for production. 4 | 5 | A HTTP client for the [The Movie Database API](https://developer.themoviedb.org/reference/intro/getting-started). 6 | 7 | ## License 8 | 9 | The code in this project is released under the [MIT License](./LICENSE). 10 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/tv.ts: -------------------------------------------------------------------------------- 1 | import type { MovieAndTVShared } from "@/types/movie/details"; 2 | import type { SearchResult } from "@/types/search/utils"; 3 | import type { TVDifferent } from "@/types/tv/details"; 4 | 5 | export type SearchTVResult = SearchResult< 6 | Omit & MovieAndTVShared 7 | >; 8 | -------------------------------------------------------------------------------- /packages/tmdb-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/default.tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@/*": ["src/*"] }, 6 | "baseUrl": "." 7 | }, 8 | "include": ["src", "test", "*.config.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/tmdb-request/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/default.tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@/*": ["src/*"] }, 6 | "baseUrl": "." 7 | }, 8 | "include": ["src", "test", "*.config.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/stories/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "movisea-web/tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@/*": ["../web/*"] }, 6 | "baseUrl": "." 7 | }, 8 | "include": [".storybook/**/*", "**/*.ts", "**/*.tsx"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/is-plain-object/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/default.tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@/*": ["src/*"] }, 6 | "baseUrl": "." 7 | }, 8 | "include": ["src", "test", "*.config.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/stories/app/search/layout.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import SearchLayout from "@/app/search/layout"; 4 | 5 | export default { 6 | title: "APP/Search/Layout", 7 | component: SearchLayout, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Default: Story = {}; 12 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/mod.ts: -------------------------------------------------------------------------------- 1 | import type { AccountInterface } from "@/types/account/mod"; 2 | import type { MovieInterface } from "@/types/movie/mod"; 3 | import type { SearchInterface } from "@/types/search/mod"; 4 | 5 | export type RestInterface = AccountInterface & MovieInterface & SearchInterface; 6 | 7 | export type { Recur } from "@/types/utils"; 8 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/movie/mod.ts: -------------------------------------------------------------------------------- 1 | import type { MovieDetailsResult } from "@/types/movie/details"; 2 | import type { MovieDetailsParams } from "@/types/movie/details-params"; 3 | import type { Assoc } from "@/types/utils"; 4 | 5 | export type MovieInterface = Assoc< 6 | ["movie", "details"], 7 | (params: MovieDetailsParams) => MovieDetailsResult 8 | >; 9 | -------------------------------------------------------------------------------- /apps/stories/app/search/components/sidebar.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Sidebar } from "@/app/search/components/sidebar"; 4 | 5 | export default { 6 | title: "APP/Search/Components/Sidebar", 7 | component: Sidebar, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Default: Story = {}; 12 | -------------------------------------------------------------------------------- /packages/tmdb-api/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "@tsconfig/next/tsconfig.json", 4 | "compilerOptions": { 5 | "paths": { "@/*": ["./*"] }, 6 | "baseUrl": ".", 7 | "moduleResolution": "node" 8 | }, 9 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 10 | "exclude": ["../../node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/tmdb-request/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tailwind": { 6 | "config": "tailwind.config.js", 7 | "css": "styles/globals.css", 8 | "baseColor": "zinc", 9 | "cssVariables": true 10 | }, 11 | "aliases": { 12 | "components": "@/components", 13 | "utils": "@/lib/utils" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/mulit.ts: -------------------------------------------------------------------------------- 1 | import type { MovieAndTVShared, MovieDifferent } from "@/types/movie/details"; 2 | import type { SearchResult } from "@/types/search/utils"; 3 | import type { TVDifferent } from "@/types/tv/details"; 4 | import type { XOR } from "@/types/utils"; 5 | 6 | export type SearchMulitResult = SearchResult< 7 | XOR & MovieAndTVShared 8 | >; 9 | -------------------------------------------------------------------------------- /apps/stories/.storybook/stubs/next-image.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as NextImage from "next/image"; 3 | 4 | const OriginalNextImage = NextImage.default; 5 | 6 | Object.defineProperty(NextImage, "default", { 7 | configurable: true, 8 | value: (props: Props) => OriginalNextImage({ ...props, unoptimized: true }), 9 | }); 10 | 11 | type Props = React.ComponentProps; 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "mogeko/movisea" }], 4 | "commit": false, 5 | "fixed": [["movisea-web", "movisea-stories"]], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "master", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /packages/tmdb-api/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | define: { "import.meta.vitest": "undefined" }, 6 | test: { 7 | includeSource: ["src/**/*.{js,ts}"], 8 | coverage: { 9 | reporter: ["text", "json", "html"], 10 | }, 11 | }, 12 | plugins: [tsconfigPaths()], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/is-plain-object/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | define: { "import.meta.vitest": "undefined" }, 6 | test: { 7 | includeSource: ["src/**/*.{js,ts}"], 8 | coverage: { 9 | reporter: ["text", "json", "html"], 10 | }, 11 | }, 12 | plugins: [tsconfigPaths()], 13 | }); 14 | -------------------------------------------------------------------------------- /packages/tmdb-request/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from "vite-tsconfig-paths"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | define: { "import.meta.vitest": "undefined" }, 6 | test: { 7 | includeSource: ["src/**/*.{js,ts}"], 8 | coverage: { 9 | reporter: ["text", "json", "html"], 10 | }, 11 | }, 12 | plugins: [tsconfigPaths()], 13 | }); 14 | -------------------------------------------------------------------------------- /apps/stories/components/main-nav.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { MainNav } from "@/components/main-nav"; 4 | 5 | export default { 6 | title: "Components/MainNav", 7 | component: MainNav, 8 | parameters: { 9 | controls: { hideNoControlsWarning: true }, 10 | }, 11 | } as Meta; 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = {}; 15 | -------------------------------------------------------------------------------- /apps/stories/components/footer.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { SiteFooter } from "@/components/site-footer"; 4 | 5 | export default { 6 | title: "Components/Footer", 7 | component: SiteFooter, 8 | parameters: { 9 | controls: { hideNoControlsWarning: true }, 10 | }, 11 | } as Meta; 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = {}; 15 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/details.ts: -------------------------------------------------------------------------------- 1 | import { ISO_639_1, ISO_3166_1 } from "@/types/shared"; 2 | 3 | export type AccountDetails = { 4 | avatar: { 5 | gravatar: { 6 | hash: string; 7 | }; 8 | tmdb: { 9 | avatar_path: null; 10 | }; 11 | }; 12 | id: number; 13 | iso_639_1: ISO_639_1; 14 | iso_3166_1: ISO_3166_1; 15 | name: string; 16 | include_adult: boolean; 17 | username: string; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/stories/components/mode-toggle.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { ModeToggle } from "@/components/mode-toggle"; 4 | 5 | export default { 6 | title: "Components/ModeToggle", 7 | component: ModeToggle, 8 | parameters: { 9 | controls: { hideNoControlsWarning: true }, 10 | }, 11 | } as Meta; 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = {}; 15 | -------------------------------------------------------------------------------- /packages/tmdb-api/test/__snapshots__/tmdb.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Re-exported form tmdb-request > should parse 1`] = ` 4 | { 5 | "baseUrl": "https://api.themoviedb.org/3", 6 | "body": null, 7 | "headers": { 8 | "accept": "application/json", 9 | "authorization": "Bearer ONLY_FOR_TESTING", 10 | }, 11 | "method": "GET", 12 | "url": "/foo/baz", 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for GitHub Actions 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | # Enable version updates for PNPM 12 | - package-ecosystem: "npm" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /apps/stories/components/poster-image.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { PosterImage } from "@/components/poster-image"; 4 | 5 | export default { 6 | title: "Components/PosterImage", 7 | component: PosterImage, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Default: Story = { 12 | args: { 13 | src: "/fWNIrfvDXORrbfSVy3OX7ndgCLw.jpg", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /apps/stories/components/outside-btn.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { OutsideButton } from "@/components/outside-btn"; 4 | 5 | export default { 6 | title: "Components/OutsideButton", 7 | component: OutsideButton, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Default: Story = { 12 | args: { 13 | children: "RARBG", 14 | href: "https://rarbg.to", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web/lib/use-debounce.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | export function useDebounce(value: T, delay: number): T { 6 | const [debouncedValue, setDebouncedValue] = useState(value); 7 | 8 | useEffect(() => { 9 | const timeout = setTimeout(() => { 10 | setDebouncedValue(value); 11 | }, delay); 12 | 13 | return () => clearTimeout(timeout); 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | -------------------------------------------------------------------------------- /packages/tmdb-api/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "tsup"; 3 | 4 | export default defineConfig({ 5 | entryPoints: { 6 | "tmdb-api": path.resolve(__dirname, "src/mod.ts"), 7 | }, 8 | format: ["esm"], 9 | clean: true, 10 | dts: true, 11 | 12 | // To delete the in-source testing (comes from vitest) 13 | // See: https://github.com/egoist/tsup/issues/625#issuecomment-1608591913 14 | define: { "import.meta.vitest": "false" }, 15 | treeshake: true, 16 | }); 17 | -------------------------------------------------------------------------------- /packages/is-plain-object/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "tsup"; 3 | 4 | export default defineConfig({ 5 | entryPoints: { 6 | "is-plain-object": path.resolve(__dirname, "src/mod.ts"), 7 | }, 8 | format: ["esm"], 9 | clean: true, 10 | dts: true, 11 | 12 | // To delete the in-source testing (comes from vitest) 13 | // See: https://github.com/egoist/tsup/issues/625#issuecomment-1608591913 14 | define: { "import.meta.vitest": "false" }, 15 | treeshake: true, 16 | }); 17 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /apps/web/.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 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/avatar.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 | 5 | export default { 6 | title: "Shadcn-ui/Avatar", 7 | component: Avatar, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Mogeko: Story = { 12 | render: (args) => ( 13 | 14 | 15 | Mo 16 | 17 | ), 18 | }; 19 | -------------------------------------------------------------------------------- /apps/stories/components/search.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { useForm } from "react-hook-form"; 3 | 4 | import { Form } from "@/components/ui/form"; 5 | import { Search } from "@/components/search"; 6 | 7 | export default { 8 | title: "Components/Search", 9 | component: Search, 10 | decorators: [ 11 | (Story) => ( 12 |
13 | 14 | 15 | ), 16 | ], 17 | } as Meta; 18 | type Story = StoryObj; 19 | 20 | export const Default: Story = {}; 21 | -------------------------------------------------------------------------------- /apps/stories/app/page.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import Home from "@/app/page"; 4 | 5 | export default { 6 | title: "App/Home", 7 | component: Home, 8 | decorators: [ 9 | (Story: React.FC) => ( 10 |
11 |
12 | 13 |
14 |
15 | ), 16 | ], 17 | } as Meta; 18 | type Story = StoryObj; 19 | 20 | export const Default: Story = { 21 | render: () => , 22 | }; 23 | -------------------------------------------------------------------------------- /apps/stories/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../**/*.stories.@(js|jsx|ts|tsx|mdx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-interactions", 9 | "@storybook/addon-styling", 10 | ], 11 | staticDirs: ["../../web/public"], 12 | framework: { 13 | name: "@storybook/nextjs", 14 | options: {}, 15 | }, 16 | docs: { 17 | autodocs: true, 18 | }, 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /apps/web/components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FaCircle } from "react-icons/fa"; 3 | 4 | import { siteConfig } from "@/config/site"; 5 | 6 | export const MainNav: React.FC = () => { 7 | return ( 8 |
9 | 10 | 11 | 12 | {siteConfig.name} 13 | 14 | 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/lib/use-preferred-language.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSyncExternalStore } from "react"; 4 | 5 | const subscribe = (cb: () => void) => { 6 | window.addEventListener("languagechange", cb); 7 | return () => window.removeEventListener("languagechange", cb); 8 | }; 9 | 10 | const snapshot = () => navigator.language; 11 | 12 | const serverSnapshot = () => { 13 | throw Error("usePreferredLanguage is a client-only hook"); 14 | }; 15 | 16 | export function usePreferredLanguage() { 17 | return useSyncExternalStore(subscribe, snapshot, serverSnapshot); 18 | } 19 | -------------------------------------------------------------------------------- /packages/tmdb-request/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "tsup"; 3 | 4 | export default defineConfig({ 5 | entryPoints: { 6 | "tmdb-request": path.resolve(__dirname, "src/mod.ts"), 7 | "merge-deep": path.resolve(__dirname, "src/merge-deep.ts"), 8 | }, 9 | format: ["esm"], 10 | clean: true, 11 | dts: true, 12 | 13 | // To delete the in-source testing (comes from vitest) 14 | // See: https://github.com/egoist/tsup/issues/625#issuecomment-1608591913 15 | define: { "import.meta.vitest": "false" }, 16 | treeshake: true, 17 | }); 18 | -------------------------------------------------------------------------------- /apps/web/components/outside-btn.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { LuArrowUpRight } from "react-icons/lu"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export const OutsideButton: React.FC< 7 | Pick, "size"> & 8 | React.ComponentProps 9 | > = ({ children, size, target = "_blank", ...props }) => { 10 | return ( 11 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /apps/web/app/search/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Sidebar } from "@/app/search/components/sidebar"; 2 | 3 | const SearchLayout: React.FC<{ 4 | children: React.ReactNode; 5 | }> = ({ children }) => { 6 | return ( 7 |
8 | 11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default SearchLayout; 17 | -------------------------------------------------------------------------------- /apps/web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/tsconfig/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # tsconfig 2 | 3 | ## 0.0.2 4 | 5 | ### Patch Changes 6 | 7 | - [#26](https://github.com/mogeko/movisea/pull/26) [`5bf2d78`](https://github.com/mogeko/movisea/commit/5bf2d78d81b4d64ba066ce696b1ee6b1abf5a6ce) Thanks [@mogeko](https://github.com/mogeko)! - Rename `tmdb.tsconfig.json` to `default.tsconfig.json` 8 | 9 | ## 0.0.1 10 | 11 | ### Patch Changes 12 | 13 | - [#22](https://github.com/mogeko/movisea/pull/22) [`8bb0e14`](https://github.com/mogeko/movisea/commit/8bb0e1454ec8f73d002b1c0d0c9712d6c8f05bde) Thanks [@mogeko](https://github.com/mogeko)! - Setup shared `tsconfig.json` for `tmdb-*` packages 14 | -------------------------------------------------------------------------------- /apps/stories/components/site-header.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { SiteHeader } from "@/components/site-header"; 4 | 5 | export default { 6 | title: "Components/SiteHeader", 7 | component: SiteHeader, 8 | parameters: { 9 | controls: { hideNoControlsWarning: true }, 10 | }, 11 | } as Meta; 12 | type Story = StoryObj; 13 | 14 | export const Default: Story = {}; 15 | 16 | export const WithSearchButton: Story = { 17 | parameters: { 18 | nextjs: { 19 | navigation: { 20 | pathname: "/movie/569094", 21 | }, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/switch.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Label } from "@/components/ui/label"; 4 | import { Switch } from "@/components/ui/switch"; 5 | 6 | export default { 7 | title: "Shadcn-ui/Switch", 8 | component: Switch, 9 | } as Meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = {}; 13 | 14 | export const Demo1: Story = { 15 | render: (args) => ( 16 |
17 | 18 | 19 |
20 | ), 21 | }; 22 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/label.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | 6 | export default { 7 | title: "Shadcn-ui/Label", 8 | component: Label, 9 | } as Meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = { 13 | render: ({ htmlFor, ...props }) => ( 14 |
15 |
18 | ), 19 | args: { 20 | children: "Your email address:", 21 | htmlFor: "email", 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site"; 2 | import { Search } from "@/components/search"; 3 | 4 | const Home: React.FC = () => { 5 | return ( 6 |
7 |
8 |
9 | {siteConfig.name} 10 | . 11 |
12 |
13 | 14 |
15 |
16 |
17 | ); 18 | }; 19 | 20 | export default Home; 21 | -------------------------------------------------------------------------------- /apps/stories/app/layout.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import RootLayout from "@/app/layout"; 4 | 5 | export default { 6 | title: "App/Layout", 7 | component: RootLayout, 8 | args: { 9 | children: ( 10 |
11 | page area 12 |
13 | ), 14 | }, 15 | } as Meta; 16 | type Story = StoryObj; 17 | 18 | export const Default: Story = {}; 19 | 20 | export const WithSearchButton: Story = { 21 | parameters: { 22 | nextjs: { 23 | navigation: { 24 | pathname: "/movie/569094", 25 | }, 26 | }, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/tmdb-request/src/split-obj.ts: -------------------------------------------------------------------------------- 1 | export function splitObj(obj: Record, keys: string[]) { 2 | const rObj = Object.assign({}, obj); 3 | const lObj = Object.keys(obj).reduce((acc, k) => { 4 | if (keys.includes(k)) (acc[k] = obj[k]), delete rObj[k]; 5 | return acc; 6 | }, {} as Record); 7 | 8 | return [lObj, rObj] as [L, R]; 9 | } 10 | 11 | if (import.meta.vitest) { 12 | const { describe, it, expect } = await import("vitest"); 13 | 14 | describe("splitObj", () => { 15 | it("should split object", () => { 16 | expect( 17 | splitObj({ foo: "bar", bar: "baz", baz: "foo" }, ["foo", "bar"]) 18 | ).toEqual([{ foo: "bar", bar: "baz" }, { baz: "foo" }]); 19 | }); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "dev": { "dependsOn": ["^build"], "cache": false, "persistent": true }, 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"], 8 | "dotEnv": [".env.local"] 9 | }, 10 | "start": { "dependsOn": ["build"], "cache": false, "persistent": true }, 11 | "test": { "dependsOn": ["^build"], "outputs": [] }, 12 | "cov": { "dependsOn": ["^build"], "outputs": [] }, 13 | "type-check": { "outputs": [] }, 14 | "lint": { "outputs": [] }, 15 | "fmt": { "cache": false, "outputs": [] } 16 | }, 17 | "globalDependencies": [".prettierrc.cjs", ".eslintrc.json"], 18 | "globalEnv": ["NODE_ENV"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/search/mod.ts: -------------------------------------------------------------------------------- 1 | import type { SearchMovieResult } from "@/types/search/movie"; 2 | import type { SearchMovieParams } from "@/types/search/movie-params"; 3 | import type { SearchMulitResult } from "@/types/search/mulit"; 4 | import type { SearchMulitParams } from "@/types/search/mulit-params"; 5 | import type { SearchTVResult } from "@/types/search/tv"; 6 | import type { SearchTVParams } from "@/types/search/tv-params"; 7 | import type { Assoc } from "@/types/utils"; 8 | 9 | export type SearchInterface = Assoc< 10 | ["search", "multi"], 11 | (params: SearchMulitParams) => SearchMulitResult 12 | > & 13 | Assoc<["search", "movie"], (params: SearchMovieParams) => SearchMovieResult> & 14 | Assoc<["search", "tv"], (params: SearchTVParams) => SearchTVResult>; 15 | -------------------------------------------------------------------------------- /packages/tmdb-request/test/__snapshots__/parser.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`parser > should parse endpoint 1`] = ` 4 | { 5 | "baseUrl": "https://api.themoviedb.org/3", 6 | "body": "{\\"foo\\":\\"bar\\"}", 7 | "headers": { 8 | "accept": "application/json", 9 | "authorization": "Bearer xxx", 10 | "content-type": "application/json", 11 | }, 12 | "method": "POST", 13 | "url": "/foo/baz", 14 | } 15 | `; 16 | 17 | exports[`parser > should parse route 1`] = ` 18 | { 19 | "baseUrl": "https://api.themoviedb.org/3", 20 | "body": null, 21 | "headers": { 22 | "accept": "application/json", 23 | "authorization": "Bearer xxx", 24 | }, 25 | "method": "GET", 26 | "url": "/foo/baz", 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /.github/changeset-version.cjs: -------------------------------------------------------------------------------- 1 | // ORIGINALLY FROM CLOUDFLARE WRANGLER: 2 | // https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js 3 | 4 | const { execSync } = require("node:child_process"); 5 | 6 | // This script is used by the `release.yml` workflow to update the version of the packages being released. 7 | // The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file. 8 | // So we also run `pnpm install`, which does this update. 9 | // This is a workaround until this is handled automatically by `changeset version`. 10 | // See https://github.com/changesets/changesets/issues/421. 11 | execSync("pnpm changeset version"); 12 | 13 | // Run `pnpm install` to update the pnpm-lock.yaml file. 14 | execSync("pnpm install --lockfile-only"); 15 | -------------------------------------------------------------------------------- /packages/tsconfig/default.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | 5 | "compilerOptions": { 6 | "target": "ES2020", 7 | "useDefineForClassFields": true, 8 | "module": "ESNext", 9 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 10 | "types": ["vitest/importMeta"], 11 | "skipLibCheck": true, 12 | 13 | /* Bundler mode */ 14 | "moduleResolution": "bundler", 15 | "allowImportingTsExtensions": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/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 | -------------------------------------------------------------------------------- /apps/web/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function tap(fn: (x: T) => void) { 9 | return (x: T) => (fn(x), x); 10 | } 11 | 12 | export function range(to: number): number[]; 13 | export function range(from: number, to: number): number[]; 14 | export function range(from: number, to?: number) { 15 | return Array.from({ length: to ? to - from : from }, (_, i) => i + from); 16 | } 17 | 18 | // XOR<{foo: string;}, {bar: number}> 19 | // { foo: "test" } // OK 20 | // { bar: 1 } // OK 21 | // { foo: "test", bar: 1 } // Error 22 | export type XOR = T | U extends object 23 | ? (Without & U) | (Without & T) 24 | : T | U; 25 | 26 | type Without = { [P in Exclude]?: never }; 27 | -------------------------------------------------------------------------------- /apps/web/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import { MainNav } from "@/components/main-nav"; 2 | import { ModeToggle } from "@/components/mode-toggle"; 3 | import { SearchInHeader } from "@/components/search"; 4 | 5 | export const SiteHeader: React.FC = () => { 6 | return ( 7 |
8 |
9 | 10 |
11 |
12 | 13 |
14 | 17 |
18 |
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * ```ts 4 | * type FooOrBar = XOR<{foo: string;}, {bar: number}> 5 | * const a: FooOrBar = { foo: "test" } // OK 6 | * const b: FooOrBar = { bar: 1 } // OK 7 | * const c: FooOrBar = { foo: "test", bar: 1 }// Error 8 | * ``` 9 | */ 10 | export type XOR = T | U extends object 11 | ? (Without & U) | (Without & T) 12 | : T | U; 13 | 14 | type Without = { [P in Exclude]?: never }; 15 | 16 | /** 17 | * @example 18 | * ```ts 19 | * type X = Assoc<["a", "b", "c"], number> // => {a: {b: {c: number } } } 20 | * ``` 21 | */ 22 | export type Assoc

= P extends [ 23 | infer F extends string, 24 | ...infer R 25 | ] 26 | ? { [K in F]: Assoc } 27 | : D; 28 | 29 | export type Recur = { 30 | [T in keyof R]: { [S in keyof R[T]]: R[T][S] }; 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [master] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | name: Run tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up pnpm 16 | uses: pnpm/action-setup@v2.2.4 17 | with: 18 | version: 8 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | cache: "pnpm" 24 | - name: Cache Turborepo Tasks 25 | uses: actions/cache@v3 26 | with: 27 | path: ${{ github.workspace }}/node_modules/.cache/turbo 28 | key: ${{ runner.os }}-turbo-test-${{ hashFiles('**/pnpm-lock.yaml') }} 29 | restore-keys: ${{ runner.os }}-turbo- 30 | - run: pnpm install -rw 31 | - run: pnpm run lint 32 | - run: pnpm run cov 33 | -------------------------------------------------------------------------------- /apps/web/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "@/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 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches-ignore: [master] 6 | pull_request: 7 | branches: [master] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | name: Build Next.js App 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up pnpm 17 | uses: pnpm/action-setup@v2.2.4 18 | with: 19 | version: 8 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 20 24 | cache: "pnpm" 25 | - name: Cache Turborepo Tasks 26 | uses: actions/cache@v3 27 | with: 28 | path: ${{ github.workspace }}/node_modules/.cache/turbo 29 | key: ${{ runner.os }}-turbo-build-${{ hashFiles('**/pnpm-lock.yaml') }} 30 | restore-keys: ${{ runner.os }}-turbo- 31 | - run: pnpm install -rw 32 | - run: pnpm run build 33 | -------------------------------------------------------------------------------- /apps/web/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/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 | -------------------------------------------------------------------------------- /apps/web/components/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/lib/use-toast"; 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |

22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 | 30 | ); 31 | })} 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import('prettier').Config} */ 4 | /** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ 5 | module.exports = { 6 | importOrder: [ 7 | "^(react/(.*)$)|^(react$)", 8 | "^(next/(.*)$)|^(next$)", 9 | "", 10 | "", 11 | "^types$", 12 | "^@/types/(.*)$", 13 | "^@/config/(.*)$", 14 | "^@/lib/(.*)$", 15 | "^@/hooks/(.*)$", 16 | "^@/components/ui/(.*)$", 17 | "^@/components/(.*)$", 18 | "^@/styles/(.*)$", 19 | "^@/app/(.*)$", 20 | "", 21 | "^[./]", 22 | ], 23 | importOrderSortSpecifiers: true, 24 | importOrderBuiltinModulesToTop: true, 25 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 26 | importOrderMergeDuplicateImports: true, 27 | importOrderCombineTypeAndValueImports: true, 28 | plugins: [ 29 | "prettier-plugin-tailwindcss", 30 | "@ianvs/prettier-plugin-sort-imports", 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /packages/is-plain-object/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # is-plain-object 2 | 3 | ## 0.0.3 4 | 5 | ### Patch Changes 6 | 7 | - [#41](https://github.com/mogeko/movisea/pull/41) [`c7304c1`](https://github.com/mogeko/movisea/commit/c7304c10629a443c00465c41e1d32ca1c4de9774) Thanks [@mogeko](https://github.com/mogeko)! - Upgrade dependencies 8 | 9 | - bump `@types/node` from `20.2.5` to `20.3.3` ([#37](https://github.com/mogeko/movisea/pull/37)) 10 | 11 | ## 0.0.2 12 | 13 | ### Patch Changes 14 | 15 | - [#28](https://github.com/mogeko/movisea/pull/28) [`f02efa6`](https://github.com/mogeko/movisea/commit/f02efa69403ef02284b49ff0e0e7b050a9b4c99c) Thanks [@mogeko](https://github.com/mogeko)! - Rename to `@mogeko/is-plain-object` 16 | 17 | ## 0.0.1 18 | 19 | ### Patch Changes 20 | 21 | - [#26](https://github.com/mogeko/movisea/pull/26) [`dcb257f`](https://github.com/mogeko/movisea/commit/dcb257fb5deec590631f3874c1e319d15b8345e1) Thanks [@mogeko](https://github.com/mogeko)! - Implement our own `is-plain-object` 22 | -------------------------------------------------------------------------------- /apps/stories/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import "../../web/styles/globals.css"; 2 | import "../.storybook/stubs/next-image"; 3 | 4 | import * as React from "react"; 5 | import { withThemeByClassName } from "@storybook/addon-styling"; 6 | import type { Decorator, Parameters } from "@storybook/react"; 7 | 8 | import { Toaster } from "@/components/toaster"; 9 | 10 | export const parameters: Parameters = { 11 | actions: { argTypesRegex: "^on[A-Z].*" }, 12 | controls: { 13 | matchers: { 14 | color: /(background|color)$/i, 15 | date: /Date$/, 16 | }, 17 | }, 18 | backgrounds: { disable: true }, 19 | nextjs: { appDirectory: true }, 20 | }; 21 | 22 | export const decorators: Array = [ 23 | (Story) => ( 24 |
25 | 26 | 27 |
28 | ), 29 | withThemeByClassName({ 30 | themes: { 31 | light: "light", 32 | dark: "dark", 33 | }, 34 | defaultTheme: "light", 35 | }), 36 | ]; 37 | -------------------------------------------------------------------------------- /apps/web/components/poster-image.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image, { type ImageLoader } from "next/image"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | export const PosterImage: React.FC< 8 | Omit, "loader"> 9 | > = ({ width = 300, loading = "lazy", alt, className, ...props }) => { 10 | return ( 11 | {alt} 23 | ); 24 | }; 25 | 26 | const posterLoader: ImageLoader = ({ src, width }) => { 27 | if (width > 1000) { 28 | return `https://image.tmdb.org/t/p/w1280${src}`; 29 | } else { 30 | const height = width * 1.5; 31 | return `https://image.tmdb.org/t/p/w${width}_and_h${height}_bestv2${src}`; 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/separator.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { Separator } from "@/components/ui/separator"; 5 | 6 | export default { 7 | title: "Shadcn-ui/Separator", 8 | component: Separator, 9 | } as Meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = {}; 13 | 14 | export const Demo1: Story = { 15 | render: ({ className, ...props }) => ( 16 |
17 |
18 |

Radix Primitives

19 |

20 | An open-source UI component library. 21 |

22 |
23 | 24 |
25 |
Blog
26 | 27 |
Docs
28 | 29 |
Source
30 |
31 |
32 | ), 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zheng Junyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/badge.stories.tsx: -------------------------------------------------------------------------------- 1 | import { default as NextLink } from "next/link"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | 4 | import { Badge, badgeVariants } from "@/components/ui/badge"; 5 | 6 | export default { 7 | title: "Shadcn-ui/Badge", 8 | component: Badge, 9 | } as Meta; 10 | type Story = StoryObj; 11 | 12 | export const Default: Story = { 13 | args: { 14 | children: "Badge", 15 | }, 16 | }; 17 | 18 | export const Secondary: Story = { 19 | args: { 20 | children: "Badge", 21 | variant: "secondary", 22 | }, 23 | }; 24 | 25 | export const Destructive: Story = { 26 | args: { 27 | children: "Badge", 28 | variant: "destructive", 29 | }, 30 | }; 31 | 32 | export const Outline: Story = { 33 | args: { 34 | children: "Badge", 35 | variant: "outline", 36 | }, 37 | }; 38 | 39 | export const Link: Story = { 40 | render: (args) => ( 41 | 42 | {args.children} 43 | 44 | ), 45 | args: { 46 | children: "Link", 47 | variant: "outline", 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /packages/tmdb-api/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zheng Junyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/tmdb-request/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zheng Junyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/is-plain-object/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zheng Junyi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/mod.ts: -------------------------------------------------------------------------------- 1 | import { ENDPOINTS } from "@/endpoints"; 2 | import { parser, request, type Options } from "@mogeko/tmdb-request"; 3 | import { mergeDeep } from "@mogeko/tmdb-request/merge-deep"; 4 | 5 | import type { Recur, RestInterface } from "@/types/mod"; 6 | 7 | export class TMDB { 8 | private _defaultOpts: Options; 9 | 10 | constructor({ auth }: { auth: `Bearer ${string}` }) { 11 | this._defaultOpts = { headers: { authorization: auth } }; 12 | } 13 | 14 | public rest: Recur = Object.fromEntries( 15 | Object.entries(ENDPOINTS).map(([topLevelKey, subs]) => [ 16 | topLevelKey, 17 | Object.fromEntries( 18 | Object.entries(subs).map(([subLevelKey, [route, opts]]) => [ 19 | subLevelKey, 20 | (params: any) => this.request(route, mergeDeep(opts, params)), 21 | ]) 22 | ), 23 | ]) 24 | ) as any; 25 | 26 | public request(route: string, opts: Options = {}) { 27 | return request(route, mergeDeep(this._defaultOpts, opts)); 28 | } 29 | 30 | public parser(route: string, opts: Options = {}) { 31 | return parser(route, mergeDeep(this._defaultOpts, opts)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/stories/app/search/components/pagination.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { Pagination } from "@/app/search/components/pagination"; 4 | 5 | export default { 6 | title: "APP/Search/Components/Pagination", 7 | component: Pagination, 8 | } as Meta; 9 | type Story = StoryObj; 10 | 11 | export const Default: Story = { 12 | args: { 13 | totalPages: 5, 14 | }, 15 | }; 16 | 17 | export const InPage3: Story = { 18 | args: { 19 | totalPages: 9, 20 | }, 21 | parameters: { 22 | nextjs: { 23 | navigation: { query: { page: "3" } }, 24 | }, 25 | }, 26 | }; 27 | 28 | export const MoreThan9Pages: Story = { 29 | args: { 30 | totalPages: 10, 31 | }, 32 | }; 33 | 34 | export const InPage10With20Pages: Story = { 35 | args: { 36 | totalPages: 20, 37 | }, 38 | parameters: { 39 | nextjs: { 40 | navigation: { query: { page: "10" } }, 41 | }, 42 | }, 43 | }; 44 | 45 | export const InPage20With20Pages: Story = { 46 | args: { 47 | totalPages: 20, 48 | }, 49 | parameters: { 50 | nextjs: { 51 | navigation: { query: { page: "20" } }, 52 | }, 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /apps/stories/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movisea-stories", 3 | "version": "0.0.13", 4 | "private": true, 5 | "scripts": { 6 | "dev": "storybook dev -p 6006", 7 | "build": "storybook build -o dist", 8 | "type-check": "tsc --noEmit", 9 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 10 | "fmt": "eslint . --ext .js,.jsx,.ts,.tsx --fix" 11 | }, 12 | "dependencies": { 13 | "movisea-web": "workspace:*", 14 | "react": "18.2.0", 15 | "react-dom": "18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@storybook/addon-essentials": "^7.0.26", 19 | "@storybook/addon-interactions": "^7.0.24", 20 | "@storybook/addon-links": "^7.0.26", 21 | "@storybook/addon-styling": "^1.3.4", 22 | "@storybook/blocks": "^7.0.24", 23 | "@storybook/nextjs": "^7.0.24", 24 | "@storybook/react": "^7.0.27", 25 | "@storybook/testing-library": "^0.2.0", 26 | "@types/node": "20.4.2", 27 | "@types/react": "18.2.8", 28 | "@types/react-dom": "18.2.4", 29 | "autoprefixer": "10.4.14", 30 | "postcss": "8.4.24", 31 | "storybook": "^7.0.27", 32 | "tailwindcss": "3.3.2", 33 | "tailwindcss-animate": "^1.0.6", 34 | "typescript": "5.1.6" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | relsease: 15 | name: Create PR or Publish to NPM 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up pnpm 20 | uses: pnpm/action-setup@v2.2.4 21 | with: 22 | version: 8 23 | - name: Set up Node.js 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: 20 27 | cache: "pnpm" 28 | - run: pnpm install -rw 29 | - run: pnpm run build 30 | - name: Create Version PR or Publish to NPM 31 | id: changesets 32 | uses: changesets/action@v1.4.5 33 | with: 34 | commit: "chore(release): version packages" 35 | title: "chore(release): version packages" 36 | version: node .github/changeset-version.cjs 37 | publish: pnpm changeset publish 38 | env: 39 | NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NODE_ENV: "production" 42 | -------------------------------------------------------------------------------- /apps/web/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center border rounded-full 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 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", 13 | secondary: 14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", 15 | destructive: 16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", 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 | -------------------------------------------------------------------------------- /apps/web/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/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 | -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | 3 | import type { Metadata } from "next"; 4 | 5 | import { siteConfig } from "@/config/site"; 6 | import { sans } from "@/lib/fonts"; 7 | import { cn } from "@/lib/utils"; 8 | import { SiteFooter } from "@/components/site-footer"; 9 | import { SiteHeader } from "@/components/site-header"; 10 | import { ThemeProvider } from "@/components/theme-provider"; 11 | import { Toaster } from "@/components/toaster"; 12 | 13 | export const metadata: Metadata = { 14 | title: siteConfig.name, 15 | description: siteConfig.description, 16 | }; 17 | 18 | const RootLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => { 19 | return ( 20 | 21 | 22 | 28 | 29 |
30 | 31 |
{children}
32 | 33 |
34 |
35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default RootLayout; 42 | -------------------------------------------------------------------------------- /apps/web/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/is-plain-object/README.md: -------------------------------------------------------------------------------- 1 | # is-plain-object 2 | 3 | > Returns true if an object was created by the `Object` constructor, or Object.create(null). 4 | 5 | This project originated from [is-plain-object](https://github.com/jonschlinkert/is-plain-object) (released under [MIT license](https://github.com/jonschlinkert/is-plain-object/blob/master/LICENSE)). I reimplemented it by TypeScript. 6 | 7 | ## Usage 8 | 9 | With ES modules: 10 | 11 | ```ts 12 | import { isPlainObject } from "is-plain-object"; 13 | ``` 14 | 15 | **true** when created by the `Object` constructor, or `Object.create(null)`. 16 | 17 | ```ts 18 | isPlainObject(Object.create({})); // => true 19 | isPlainObject(Object.create(Object.prototype)); // => true 20 | isPlainObject({ foo: "bar" }); // => true 21 | isPlainObject({}); // => true 22 | isPlainObject(Object.create(null)); // => true 23 | ``` 24 | 25 | **false** when not created by the `Object` constructor. 26 | 27 | ```ts 28 | isPlainObject(["foo", "bar"]); // => false 29 | isPlainObject([]); // => false 30 | isPlainObject(new Foo()); // => false 31 | ``` 32 | 33 | ## License 34 | 35 | The [original project](https://github.com/jonschlinkert/is-plain-object) is released under the [MIT License](https://github.com/jonschlinkert/is-plain-object/blob/master/LICENSE). 36 | 37 | The code in this project is released under the [MIT License](./LICENSE). 38 | -------------------------------------------------------------------------------- /apps/web/lib/use-localstorage.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import type { Dispatch, SetStateAction } from "react"; 5 | 6 | export function useLocalStorage(storageKey: string, init = "") { 7 | const [value, setValue] = useState(init); 8 | 9 | // In order to ensure that `window.*` code runs only in the client 10 | // See: https://github.com/vercel/next.js/discussions/19911 11 | useEffect(() => { 12 | setValue(window.localStorage.getItem(storageKey) || init); 13 | }, [storageKey, init]); 14 | 15 | const setItem: Dispatch> = (newValue) => { 16 | if (typeof newValue === "function") { 17 | return setItem(newValue(value)); 18 | } else { 19 | setValue(newValue); 20 | 21 | if (newValue === init) { 22 | window.localStorage.removeItem(storageKey); 23 | } else { 24 | window.localStorage.setItem(storageKey, newValue); 25 | } 26 | } 27 | }; 28 | 29 | const handleStorage = useCallback( 30 | (e: StorageEvent) => { 31 | if (e.key !== storageKey) return; 32 | if (e.newValue !== value) { 33 | setValue(e.newValue || init); 34 | } 35 | }, 36 | [value, init, storageKey] 37 | ); 38 | 39 | useEffect(() => { 40 | window.addEventListener("storage", handleStorage); 41 | return () => window.removeEventListener("storage", handleStorage); 42 | }, [handleStorage]); 43 | 44 | return [value, setItem] as const; 45 | } 46 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/movie/details.ts: -------------------------------------------------------------------------------- 1 | import { Coutry, ISO_639_1, ISO_3166_1, Language } from "@/types/shared"; 2 | 3 | export type MovieDifferent = { 4 | media_type: "movie"; 5 | title: string; 6 | original_title: string; 7 | release_date: string; 8 | }; 9 | 10 | export type MovieDetailsResult = { 11 | belongs_to_collection: null; 12 | budget: number; 13 | genres: Array<{ 14 | id: number; 15 | name: string; 16 | }>; 17 | homepage: string; 18 | imdb_id: `tt${number}`; 19 | production_companies: Array<{ 20 | id: number; 21 | logo_path: string; 22 | name: string; 23 | origin_country: Coutry; 24 | }>; 25 | production_countries: Array<{ 26 | iso_3166_1: ISO_3166_1; 27 | name: string; 28 | }>; 29 | revenue: number; 30 | runtime: number; 31 | spoken_languages: Array<{ 32 | english_name: string; 33 | iso_639_1: ISO_639_1; 34 | name: string; 35 | }>; 36 | status: 37 | | "Rumored" 38 | | "Planned" 39 | | "In Production" 40 | | "Post Production" 41 | | "Released" 42 | | "Canceled"; 43 | tagline: string; 44 | video: boolean; 45 | } & Omit & 46 | MovieAndTVShared; 47 | 48 | export type MovieAndTVShared = { 49 | ault: boolean; 50 | backdrop_path: string; 51 | id: number; 52 | original_language: Language; 53 | overview: string; 54 | poster_path: string; 55 | genre_ids: number[]; 56 | popularity: number; 57 | vote_average: number; 58 | vote_count: number; 59 | }; 60 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movisea-web", 3 | "version": "0.0.13", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "type-check": "tsc --noEmit", 10 | "lint": "next lint", 11 | "fmt": "next lint --fix" 12 | }, 13 | "dependencies": { 14 | "@hookform/resolvers": "^3.1.1", 15 | "@mogeko/tmdb-request": "workspace:^", 16 | "@radix-ui/react-accordion": "^1.1.2", 17 | "@radix-ui/react-avatar": "^1.0.3", 18 | "@radix-ui/react-dialog": "^1.0.4", 19 | "@radix-ui/react-dropdown-menu": "^2.0.5", 20 | "@radix-ui/react-label": "^2.0.2", 21 | "@radix-ui/react-separator": "^1.0.3", 22 | "@radix-ui/react-slot": "^1.0.2", 23 | "@radix-ui/react-switch": "^1.0.3", 24 | "@radix-ui/react-toast": "^1.1.4", 25 | "class-variance-authority": "^0.6.1", 26 | "clsx": "^2.0.0", 27 | "cmdk": "^0.2.0", 28 | "next": "13.4.9", 29 | "next-themes": "^0.2.1", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-hook-form": "^7.45.1", 33 | "react-icons": "^4.10.1", 34 | "tailwind-merge": "^1.14.0", 35 | "zod": "^3.21.4" 36 | }, 37 | "devDependencies": { 38 | "@tsconfig/next": "^2.0.0", 39 | "@types/node": "20.4.2", 40 | "@types/react": "18.2.8", 41 | "@types/react-dom": "18.2.4", 42 | "autoprefixer": "10.4.14", 43 | "postcss": "8.4.24", 44 | "tailwindcss": "3.3.2", 45 | "tailwindcss-animate": "^1.0.6", 46 | "typescript": "5.1.6" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movisea", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Front-end implementation of The Movie Database (TMDB)", 6 | "author": { 7 | "name": "Zheng Junyi", 8 | "email": "zhengjunyi@live.com" 9 | }, 10 | "repository": "github:mogeko/movisea", 11 | "homepage": "https://github.com/mogeko/movisea#readme", 12 | "bugs": { 13 | "url": "https://github.com/mogeko/movisea/issues", 14 | "email": "zhengjunyi@live.com" 15 | }, 16 | "license": "MIT", 17 | "scripts": { 18 | "dev": "turbo run --filter=movisea-web dev", 19 | "storybook": "turbo run --filter=movisea-stories dev", 20 | "build": "turbo run build", 21 | "start": "turbo run start", 22 | "test": "turbo run test", 23 | "cov": "turbo run cov", 24 | "type-check": "turbo run type-check", 25 | "lint": "turbo run lint", 26 | "fmt": "turbo run fmt" 27 | }, 28 | "workspaces": [ 29 | "apps/*", 30 | "packages/*" 31 | ], 32 | "devDependencies": { 33 | "@changesets/changelog-github": "^0.4.8", 34 | "@changesets/cli": "^2.26.2", 35 | "@ianvs/prettier-plugin-sort-imports": "^4.1.0", 36 | "eslint": "8.45.0", 37 | "eslint-config-next": "13.4.4", 38 | "eslint-config-prettier": "^8.8.0", 39 | "eslint-config-turbo": "^1.10.8", 40 | "eslint-plugin-prettier": "^4.2.1", 41 | "eslint-plugin-storybook": "^0.6.12", 42 | "prettier": "^2.8.8", 43 | "prettier-plugin-tailwindcss": "^0.3.0", 44 | "turbo": "^1.10.8", 45 | "typescript": "^5.1.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/button.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { LuLoader2, LuMail } from "react-icons/lu"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export default { 7 | title: "Shadcn-ui/Button", 8 | component: Button, 9 | } as Meta; 10 | type Story = StoryObj; 11 | 12 | export const Primary: Story = { 13 | args: { 14 | children: "Primary", 15 | }, 16 | }; 17 | 18 | export const Secondary: Story = { 19 | args: { 20 | children: "Secondary", 21 | variant: "secondary", 22 | }, 23 | }; 24 | 25 | export const Destructive: Story = { 26 | args: { 27 | children: "Destructive", 28 | variant: "destructive", 29 | }, 30 | }; 31 | 32 | export const Outline: Story = { 33 | args: { 34 | children: "Outline", 35 | variant: "outline", 36 | }, 37 | }; 38 | 39 | export const Ghost: Story = { 40 | args: { 41 | children: "Ghost", 42 | variant: "ghost", 43 | }, 44 | }; 45 | 46 | export const Link: Story = { 47 | args: { 48 | children: "Link", 49 | variant: "link", 50 | }, 51 | }; 52 | 53 | export const WithIcon: Story = { 54 | render: (args) => ( 55 | 58 | ), 59 | }; 60 | 61 | export const Loading: Story = { 62 | render: (args) => ( 63 | 67 | ), 68 | args: { 69 | disabled: true, 70 | }, 71 | }; 72 | -------------------------------------------------------------------------------- /apps/web/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { LuLaptop, LuMoon, LuSunMedium } from "react-icons/lu"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | export const ModeToggle: React.FC = () => { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | 29 | Light 30 | 31 | setTheme("dark")}> 32 | 33 | Dark 34 | 35 | setTheme("system")}> 36 | 37 | System 38 | 39 | 40 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/tmdb-request/test/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { parser } from "@/mod.ts"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | describe("parser", () => { 5 | it("should parse route", () => { 6 | const context = parser("/foo/{bar}", { 7 | headers: { authorization: "Bearer xxx" }, 8 | bar: "baz", 9 | }); 10 | 11 | expect(context.method).toEqual("GET"); 12 | expect(context.url).toEqual("/foo/baz"); 13 | expect(context.headers.authorization).toEqual("Bearer xxx"); 14 | 15 | delete context.headers["user-agent"]; 16 | expect(context).toMatchSnapshot(); 17 | }); 18 | 19 | it("should parse endpoint", () => { 20 | const context = parser({ 21 | url: "POST /foo/{bar}", 22 | headers: { 23 | authorization: "Bearer xxx", 24 | "content-type": "application/json", 25 | }, 26 | body: JSON.stringify({ foo: "bar" }), 27 | bar: "baz", 28 | }); 29 | 30 | expect(context.method).toEqual("POST"); 31 | expect(context.url).toEqual("/foo/baz"); 32 | expect(context.headers.authorization).toEqual("Bearer xxx"); 33 | expect(context.headers["content-type"]).toEqual("application/json"); 34 | 35 | delete context.headers["user-agent"]; 36 | expect(context).toMatchSnapshot(); 37 | }); 38 | 39 | it("should be covered by route", () => { 40 | expect( 41 | parser("/foo/bar", { url: "/This/should/be/covered/by/route" }).url 42 | ).toEqual("/foo/bar"); 43 | }); 44 | 45 | it("should pass undefined values", () => { 46 | expect(parser("/foo/{bar}", { baz: "bar" }).url).toEqual("/foo/"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/is-plain-object/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mogeko/is-plain-object", 3 | "version": "0.0.3", 4 | "private": false, 5 | "type": "module", 6 | "description": "Returns true if an object was created by the `Object` constructor, or Object.create(null)", 7 | "author": { 8 | "name": "Zheng Junyi", 9 | "email": "zhengjunyi@live.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mogeko/movisea.git", 14 | "directory": "packages/tmdb-api" 15 | }, 16 | "homepage": "https://github.com/mogeko/movisea/tree/master/packages/tmdb-api#readme", 17 | "bugs": { 18 | "url": "https://github.com/mogeko/movisea/issues", 19 | "email": "zhengjunyi@live.com" 20 | }, 21 | "keywords": [ 22 | "type", 23 | "check", 24 | "is", 25 | "plain", 26 | "object" 27 | ], 28 | "license": "MIT", 29 | "main": "dist/is-plain-object.js", 30 | "types": "dist/is-plain-object.d.ts", 31 | "files": [ 32 | "!dist/metafile-*.json", 33 | "!dist/*.map", 34 | "dist", 35 | "CHANGELOG.md" 36 | ], 37 | "scripts": { 38 | "build": "tsup", 39 | "test": "vitest run", 40 | "cov": "vitest run --coverage", 41 | "type-check": "tsc --noEmit", 42 | "lint": "prettier --check \"src/**/*.{js,ts}\" --cache", 43 | "fmt": "prettier --write \"src/**/*.{js,ts}\" --cache" 44 | }, 45 | "devDependencies": { 46 | "@types/node": "20.4.2", 47 | "@vitest/coverage-v8": "^0.33.0", 48 | "tsconfig": "workspace:*", 49 | "tsup": "^7.1.0", 50 | "typescript": "^5.1.6", 51 | "vite-tsconfig-paths": "^4.2.0", 52 | "vitest": "^0.33.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/tmdb-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mogeko/tmdb-api", 3 | "version": "0.1.8", 4 | "private": false, 5 | "type": "module", 6 | "description": "A HTTP client for TMDB APIs", 7 | "author": { 8 | "name": "Zheng Junyi", 9 | "email": "zhengjunyi@live.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mogeko/movisea.git", 14 | "directory": "packages/tmdb-api" 15 | }, 16 | "homepage": "https://github.com/mogeko/movisea/tree/master/packages/tmdb-api#readme", 17 | "bugs": { 18 | "url": "https://github.com/mogeko/movisea/issues", 19 | "email": "zhengjunyi@live.com" 20 | }, 21 | "keywords": [ 22 | "themoviedb", 23 | "tmdb", 24 | "api" 25 | ], 26 | "license": "MIT", 27 | "main": "dist/tmdb-api.js", 28 | "types": "dist/tmdb-api.d.ts", 29 | "files": [ 30 | "!dist/metafile-*.json", 31 | "!dist/*.map", 32 | "dist", 33 | "CHANGELOG.md" 34 | ], 35 | "scripts": { 36 | "build": "tsup", 37 | "test": "vitest run", 38 | "cov": "vitest run --coverage", 39 | "type-check": "tsc --noEmit", 40 | "lint": "prettier --check \"src/**/*.{js,ts}\" --cache", 41 | "fmt": "prettier --write \"src/**/*.{js,ts}\" --cache", 42 | "release": "pnpm publish --access public" 43 | }, 44 | "dependencies": { 45 | "@mogeko/tmdb-request": "workspace:^" 46 | }, 47 | "devDependencies": { 48 | "@types/node": "20.4.2", 49 | "@vitest/coverage-v8": "^0.33.0", 50 | "tsconfig": "workspace:*", 51 | "tsup": "^7.1.0", 52 | "typescript": "^5.1.6", 53 | "vite-tsconfig-paths": "^4.2.0", 54 | "vitest": "^0.33.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/web/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/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 | -------------------------------------------------------------------------------- /apps/web/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --muted: 240 4.8% 95.9%; 11 | --muted-foreground: 240 3.8% 46.1%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 240 10% 3.9%; 18 | 19 | --border: 240 5.9% 90%; 20 | --input: 240 5.9% 90%; 21 | 22 | --primary: 240 5.9% 10%; 23 | --primary-foreground: 0 0% 98%; 24 | 25 | --secondary: 240 4.8% 95.9%; 26 | --secondary-foreground: 240 5.9% 10%; 27 | 28 | --accent: 240 4.8% 95.9%; 29 | --accent-foreground: ; 30 | 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | 34 | --ring: 240 5% 64.9%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 240 10% 3.9%; 41 | --foreground: 0 0% 98%; 42 | 43 | --muted: 240 3.7% 15.9%; 44 | --muted-foreground: 240 5% 64.9%; 45 | 46 | --popover: 240 10% 3.9%; 47 | --popover-foreground: 0 0% 98%; 48 | 49 | --card: 240 10% 3.9%; 50 | --card-foreground: 0 0% 98%; 51 | 52 | --border: 240 3.7% 15.9%; 53 | --input: 240 3.7% 15.9%; 54 | 55 | --primary: 0 0% 98%; 56 | --primary-foreground: 240 5.9% 10%; 57 | 58 | --secondary: 240 3.7% 15.9%; 59 | --secondary-foreground: 0 0% 98%; 60 | 61 | --accent: 240 3.7% 15.9%; 62 | --accent-foreground: ; 63 | 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 85.7% 97.3%; 66 | 67 | --ring: 240 3.7% 15.9%; 68 | } 69 | } 70 | 71 | @layer base { 72 | * { 73 | @apply border-border; 74 | } 75 | body { 76 | @apply bg-background text-foreground; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/tmdb-api/test/tmdb.test.ts: -------------------------------------------------------------------------------- 1 | import { TMDB } from "@/mod"; 2 | import { beforeEach, describe, expect, it, vi } from "vitest"; 3 | 4 | const tmdb = new TMDB({ auth: "Bearer ONLY_FOR_TESTING" }); 5 | 6 | describe("Re-exported form tmdb-request", () => { 7 | beforeEach(() => (vi.resetAllMocks(), void 0)); 8 | 9 | it("constructor", () => { 10 | expect(tmdb).toBeDefined(); 11 | }); 12 | 13 | it("should request", async () => { 14 | const spy = vi.spyOn(tmdb, "request").mockImplementation(async () => { 15 | return { test: "test" }; 16 | }); 17 | 18 | expect( 19 | await tmdb.request("/movie/{id}?language={language}", { 20 | language: "en-US", 21 | id: 10997, 22 | }) 23 | ).toEqual({ test: "test" }); 24 | 25 | expect(spy).toHaveBeenCalledOnce(); 26 | }); 27 | 28 | it("should parse", () => { 29 | const context = tmdb.parser("/foo/{bar}", { bar: "baz" }); 30 | 31 | expect(context.method).toEqual("GET"); 32 | expect(context.url).toEqual("/foo/baz"); 33 | expect(context.headers?.authorization).toEqual("Bearer ONLY_FOR_TESTING"); 34 | 35 | delete context.headers?.["user-agent"]; 36 | expect(context).toMatchSnapshot(); 37 | }); 38 | }); 39 | 40 | describe("TMDB REST API", () => { 41 | beforeEach(() => (vi.resetAllMocks(), void 0)); 42 | 43 | it("should get movie details", async () => { 44 | const spy = vi.spyOn(tmdb, "request").mockImplementation(async () => { 45 | return { test: "test" }; 46 | }); 47 | 48 | const result = await tmdb.rest.movie.details({ id: 10997 }); 49 | 50 | expect(spy).toHaveBeenCalledOnce(); 51 | expect(spy).toHaveBeenCalledWith( 52 | "GET /movie/{id}?append_to_response={append_to_response}&language={language}", 53 | { language: "en-US", id: 10997 } 54 | ); 55 | expect(result).toEqual({ test: "test" }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/tmdb-request/src/merge-deep.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from "@mogeko/is-plain-object"; 2 | 3 | /** 4 | * Deep merge two objects. 5 | * 6 | * It will merge any plain object properties recursively. If the property 7 | * is not a plain object, it will be covered by the right hand object. 8 | * 9 | * @param lObj The left hand object to be merged 10 | * @param rObj The right hand object to be merged 11 | * @returns The merged object 12 | * 13 | * @example 14 | * ```ts 15 | * mergeDeep({ foo: { bar1: "baz" } }, { foo: { bar2: "qux" } }); 16 | * // => { foo: { bar1: "baz", bar2: "qux" } } 17 | * 18 | * mergeDeep({ foo: { bar: "baz" } }, { foo: { bar: "qux" } }); 19 | * // => { foo: { bar: "qux" } } 20 | * ``` 21 | */ 22 | export function mergeDeep< 23 | L extends Record, 24 | R extends Record 25 | >(lObj: L, rObj: R): L & R { 26 | const result: Record = Object.assign({}, lObj); 27 | 28 | Object.keys(rObj).forEach((key) => { 29 | if (isPlainObject(rObj[key])) { 30 | if (!(key in lObj)) { 31 | Object.assign(result, { [key]: rObj[key] }); 32 | } else { 33 | result[key] = mergeDeep(lObj[key], rObj[key]); 34 | } 35 | } else { 36 | Object.assign(result, { [key]: rObj[key] }); 37 | } 38 | }); 39 | 40 | return result; 41 | } 42 | 43 | if (import.meta.vitest) { 44 | const { describe, it, expect } = await import("vitest"); 45 | 46 | describe("mergeDeep", () => { 47 | it("should merge objects", () => { 48 | expect( 49 | mergeDeep({ foo: { bar1: "baz" } }, { foo: { bar2: "qux" } }) 50 | ).toEqual({ foo: { bar1: "baz", bar2: "qux" } }); 51 | }); 52 | 53 | it("should cover by right hand", () => { 54 | expect( 55 | mergeDeep({ foo: { bar: "baz" } }, { foo: { bar: "qux" } }) 56 | ).toEqual({ foo: { bar: "qux" } }); 57 | }); 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /packages/tmdb-request/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mogeko/tmdb-request", 3 | "version": "1.3.2", 4 | "private": false, 5 | "type": "module", 6 | "description": "A simple wrapper for TMDB APIs", 7 | "author": { 8 | "name": "Zheng Junyi", 9 | "email": "zhengjunyi@live.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mogeko/movisea.git", 14 | "directory": "packages/tmdb-request" 15 | }, 16 | "homepage": "https://github.com/mogeko/movisea/tree/master/packages/tmdb-request#readme", 17 | "bugs": { 18 | "url": "https://github.com/mogeko/movisea/issues", 19 | "email": "zhengjunyi@live.com" 20 | }, 21 | "keywords": [ 22 | "themoviedb", 23 | "tmdb", 24 | "api" 25 | ], 26 | "license": "MIT", 27 | "main": "dist/tmdb-request.js", 28 | "types": "dist/tmdb-request.d.ts", 29 | "exports": { 30 | ".": { 31 | "import": "./dist/tmdb-request.js", 32 | "types": "./dist/tmdb-request.d.ts" 33 | }, 34 | "./merge-deep": { 35 | "import": "./dist/merge-deep.js", 36 | "types": "./dist/merge-deep.d.ts" 37 | } 38 | }, 39 | "files": [ 40 | "!dist/metafile-*.json", 41 | "!dist/*.map", 42 | "dist", 43 | "CHANGELOG.md" 44 | ], 45 | "scripts": { 46 | "build": "tsup", 47 | "test": "vitest run", 48 | "cov": "vitest run --coverage", 49 | "type-check": "tsc --noEmit", 50 | "lint": "prettier --check \"src/**/*.{js,ts}\" --cache", 51 | "fmt": "prettier --write \"src/**/*.{js,ts}\" --cache", 52 | "release": "pnpm publish --access public" 53 | }, 54 | "dependencies": { 55 | "@mogeko/is-plain-object": "workspace:^", 56 | "universal-user-agent": "^7.0.1", 57 | "url-template": "^3.1.0" 58 | }, 59 | "devDependencies": { 60 | "@types/node": "20.4.2", 61 | "@vitest/coverage-v8": "^0.33.0", 62 | "tsconfig": "workspace:*", 63 | "tsup": "^7.1.0", 64 | "typescript": "^5.1.6", 65 | "vite-tsconfig-paths": "^4.2.0", 66 | "vitest": "^0.33.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /apps/web/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/accordion.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | 3 | import { 4 | Accordion, 5 | AccordionContent, 6 | AccordionItem, 7 | AccordionTrigger, 8 | } from "@/components/ui/accordion"; 9 | 10 | export default { 11 | title: "Shadcn-ui/Accordion", 12 | component: Accordion, 13 | argTypes: { 14 | type: { 15 | options: ["single", "multiple"], 16 | control: { type: "select" }, 17 | }, 18 | }, 19 | } as Meta; 20 | type Story = StoryObj; 21 | 22 | export const Default: Story = { 23 | render: (args) => ( 24 | 25 | 26 | Is it accessible? 27 | 28 | Yes. It adheres to the WAI-ARIA design pattern. 29 | 30 | 31 | 32 | ), 33 | args: { 34 | collapsible: true, 35 | type: "single", 36 | }, 37 | }; 38 | 39 | export const Demo1: Story = { 40 | render: (args) => ( 41 | 42 | 43 | Is it accessible? 44 | 45 | Yes. It adheres to the WAI-ARIA design pattern. 46 | 47 | 48 | 49 | Is it styled? 50 | 51 | Yes. It comes with default styles that matches the other 52 | components' aesthetic. 53 | 54 | 55 | 56 | Is it animated? 57 | 58 | Yes. It's animated by default, but you can disable it if you 59 | prefer. 60 | 61 | 62 | 63 | ), 64 | args: { 65 | className: "w-full", 66 | collapsible: true, 67 | type: "single", 68 | }, 69 | }; 70 | -------------------------------------------------------------------------------- /apps/web/app/search/components/sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useParams, useSearchParams } from "next/navigation"; 5 | import { MdLocalMovies, MdTv } from "react-icons/md"; 6 | import { RiFireLine } from "react-icons/ri"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | import { Button } from "@/components/ui/button"; 10 | 11 | type SidebarProps = React.HTMLAttributes; 12 | 13 | export const Sidebar: React.FC = ({ className }) => { 14 | return ( 15 |
16 |
17 |
18 |

19 | Filter by 20 |

21 |
22 | 23 | 24 | Top Results 25 | 26 | 27 | 28 | Movies 29 | 30 | 31 | 32 | TV Shows 33 | 34 |
35 |
36 |
37 |
38 | ); 39 | }; 40 | 41 | const SidebarButton: React.FC<{ 42 | mark: "multi" | "movie" | "tv"; 43 | children?: React.ReactNode; 44 | }> = ({ mark, children }) => { 45 | const searchParams = useSearchParams(); 46 | const { type } = useParams(); 47 | 48 | if (type === mark) { 49 | return ( 50 | 57 | ); 58 | } 59 | return ( 60 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /apps/web/app/movie/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { request } from "@mogeko/tmdb-request"; 2 | 3 | import { tokens } from "@/config/tokens"; 4 | 5 | const MoviePage: React.FC<{ 6 | params: { id: string }; 7 | }> = async ({ params }) => { 8 | const data = await getMovieInfo(params.id); 9 | 10 | return
{/* TODO: Fill content */}
; 11 | }; 12 | 13 | const getMovieInfo = async (id: string, params?: SearchParams) => { 14 | return request( 15 | "/movie/{id}?append_to_response={append_to_response}&language={language}", 16 | { 17 | headers: { authorization: `Bearer ${tokens.tmdb}` }, 18 | append_to_response: params?.append_to_response ?? "", 19 | language: params?.language ?? "en-US", 20 | id: id, 21 | } 22 | ).catch((error) => { 23 | console.error(error); 24 | return null; 25 | }); 26 | }; 27 | 28 | type SearchParams = { 29 | append_to_response?: string; 30 | language?: string; 31 | }; 32 | 33 | export type MovieInfo = { 34 | adult: boolean; 35 | backdrop_path: string; 36 | belongs_to_collection: null; 37 | budget: number; 38 | genres: Array<{ 39 | id: number; 40 | name: string; 41 | }>; 42 | homepage: string; 43 | id: number; 44 | imdb_id: `tt${number}`; 45 | original_language: string; 46 | original_title: string; 47 | overview: string; 48 | popularity: number; 49 | poster_path: string; 50 | production_companies: Array<{ 51 | id: number; 52 | logo_path: string; 53 | name: string; 54 | origin_country: string; 55 | }>; 56 | production_countries: Array<{ 57 | iso_3166_1: string; 58 | name: string; 59 | }>; 60 | release_date: string; 61 | revenue: number; 62 | runtime: number; 63 | spoken_languages: Array<{ 64 | english_name: string; 65 | iso_639_1: string; 66 | name: string; 67 | }>; 68 | status: 69 | | "Rumored" 70 | | "Planned" 71 | | "In Production" 72 | | "Post Production" 73 | | "Released" 74 | | "Canceled"; 75 | tagline: string; 76 | title: string; 77 | video: boolean; 78 | vote_average: number; 79 | vote_count: number; 80 | }; 81 | 82 | export default MoviePage; 83 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/form.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { zodResolver } from "@hookform/resolvers/zod"; 3 | import type { Meta, StoryObj } from "@storybook/react"; 4 | import { useForm } from "react-hook-form"; 5 | import * as z from "zod"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormDescription, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { Input } from "@/components/ui/input"; 18 | 19 | export default { 20 | title: "Shadcn-ui/Form", 21 | component: Form, 22 | decorators: [ 23 | (Story) => ( 24 |
25 | 26 | 27 | ), 28 | ], 29 | } as Meta; 30 | type Story = StoryObj; 31 | 32 | const schema = z.object({ 33 | username: z.string().min(2, { 34 | message: "Username must be at least 2 characters.", 35 | }), 36 | }); 37 | 38 | const DemoForm: React.FC = () => { 39 | const form = useForm>({ 40 | resolver: zodResolver(schema), 41 | defaultValues: { 42 | username: "", 43 | }, 44 | }); 45 | const onSubmit = (values: z.infer) => { 46 | console.log(values); 47 | }; 48 | 49 | return ( 50 |
51 |
52 | 53 | ( 57 | 58 | Username 59 | 60 | 61 | 62 | 63 | This is your public display name. 64 | 65 | 66 | 67 | )} 68 | /> 69 | 70 | 71 | 72 |
73 | ); 74 | }; 75 | 76 | export const Demo1: Story = { 77 | render: (_args) => , 78 | }; 79 | -------------------------------------------------------------------------------- /apps/web/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"; 5 | import { LuChevronDown } from "react-icons/lu"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Accordion = AccordionPrimitive.Root; 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )); 21 | AccordionItem.displayName = "AccordionItem"; 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )); 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 55 |
{children}
56 |
57 | )); 58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName; 59 | 60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; 61 | -------------------------------------------------------------------------------- /apps/web/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = "CardTitle"; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = "CardContent"; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = "CardFooter"; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /packages/tmdb-request/test/request.test.ts: -------------------------------------------------------------------------------- 1 | import { request } from "@/mod.ts"; 2 | import { getUserAgent } from "universal-user-agent"; 3 | import { beforeEach, describe, expect, it, vi } from "vitest"; 4 | 5 | describe("request", () => { 6 | beforeEach(() => (vi.resetAllMocks(), void 0)); 7 | 8 | it("should request", async () => { 9 | const spy = vi.spyOn(global, "fetch").mockImplementation(async () => { 10 | return { 11 | json: vi.fn(async (_) => ({ test: "test" })), 12 | } as any; 13 | }); 14 | 15 | const result = await request("/movie/{id}?language={language}", { 16 | headers: { authorization: "Bearer foo" }, 17 | language: "en-US", 18 | id: 10997, 19 | }); 20 | 21 | expect(spy).toHaveBeenCalledOnce(); 22 | expect(spy).toHaveBeenCalledWith( 23 | "https://api.themoviedb.org/3/movie/10997?language=en-US", 24 | { 25 | method: "GET", 26 | headers: { 27 | accept: "application/json", 28 | authorization: "Bearer foo", 29 | "user-agent": getUserAgent(), 30 | }, 31 | body: null, 32 | } 33 | ); 34 | expect(result).toEqual({ test: "test" }); 35 | }); 36 | 37 | it("should request with endpoint", async () => { 38 | const spy = vi.spyOn(global, "fetch").mockImplementation(async () => { 39 | return { 40 | json: vi.fn(async (_) => ({ test: "test" })), 41 | } as any; 42 | }); 43 | 44 | const result = await request({ 45 | url: "POST /movie/{id}?language={language}", 46 | headers: { 47 | authorization: "Bearer foo", 48 | "content-type": "application/json", 49 | }, 50 | body: JSON.stringify({ foo: "bar" }), 51 | language: "en-US", 52 | id: 10997, 53 | }); 54 | 55 | expect(spy).toHaveBeenCalledOnce(); 56 | expect(spy).toHaveBeenCalledWith( 57 | "https://api.themoviedb.org/3/movie/10997?language=en-US", 58 | { 59 | method: "POST", 60 | headers: { 61 | accept: "application/json", 62 | authorization: "Bearer foo", 63 | "content-type": "application/json", 64 | "user-agent": getUserAgent(), 65 | }, 66 | body: '{"foo":"bar"}', 67 | } 68 | ); 69 | expect(result).toEqual({ test: "test" }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: ["./{app,components}/**/*.{ts,tsx}"], 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: "2rem", 9 | screens: { 10 | "2xl": "1400px", 11 | }, 12 | }, 13 | extend: { 14 | colors: { 15 | border: "hsl(var(--border))", 16 | input: "hsl(var(--input))", 17 | ring: "hsl(var(--ring))", 18 | background: "hsl(var(--background))", 19 | foreground: "hsl(var(--foreground))", 20 | primary: { 21 | DEFAULT: "hsl(var(--primary))", 22 | foreground: "hsl(var(--primary-foreground))", 23 | }, 24 | secondary: { 25 | DEFAULT: "hsl(var(--secondary))", 26 | foreground: "hsl(var(--secondary-foreground))", 27 | }, 28 | destructive: { 29 | DEFAULT: "hsl(var(--destructive))", 30 | foreground: "hsl(var(--destructive-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | popover: { 41 | DEFAULT: "hsl(var(--popover))", 42 | foreground: "hsl(var(--popover-foreground))", 43 | }, 44 | card: { 45 | DEFAULT: "hsl(var(--card))", 46 | foreground: "hsl(var(--card-foreground))", 47 | }, 48 | }, 49 | borderRadius: { 50 | lg: "var(--radius)", 51 | md: "calc(var(--radius) - 2px)", 52 | sm: "calc(var(--radius) - 4px)", 53 | }, 54 | keyframes: { 55 | "accordion-down": { 56 | from: { height: 0 }, 57 | to: { height: "var(--radix-accordion-content-height)" }, 58 | }, 59 | "accordion-up": { 60 | from: { height: "var(--radix-accordion-content-height)" }, 61 | to: { height: 0 }, 62 | }, 63 | }, 64 | animation: { 65 | "accordion-down": "accordion-down 0.2s ease-out", 66 | "accordion-up": "accordion-up 0.2s ease-out", 67 | }, 68 | }, 69 | }, 70 | plugins: [require("tailwindcss-animate")], 71 | }; 72 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/types/account/mod.ts: -------------------------------------------------------------------------------- 1 | import type { AccountAddFavorite } from "@/types/account/add-favorite"; 2 | import type { AccountAddWatchlist } from "@/types/account/add-watchlist"; 3 | import type { AccountDetails } from "@/types/account/details"; 4 | import type { AccountFavoriteMovies } from "@/types/account/favorite-movies"; 5 | import type { AccountFavoriteTV } from "@/types/account/favorite-tv"; 6 | import type { AccountGetParams } from "@/types/account/get-params"; 7 | import type { AccountLists } from "@/types/account/lists"; 8 | import type { AccountPostParams } from "@/types/account/post-params"; 9 | import type { AccountRatedMovies } from "@/types/account/rated-movies"; 10 | import type { AccountRatedTV } from "@/types/account/rated-tv"; 11 | import type { AccountrRatedTVEpisodes } from "@/types/account/rated-tv-episodes"; 12 | import type { AccountWatchlistMovies } from "@/types/account/watchlist-movies"; 13 | import type { AccountWatchlistTV } from "@/types/account/watchlist-tv"; 14 | import type { Assoc } from "@/types/utils"; 15 | 16 | export type AccountInterface = Assoc< 17 | ["account", "detail"], 18 | (params: Pick) => AccountDetails 19 | > & 20 | Assoc< 21 | ["account", "addFavorite"], 22 | (params: AccountPostParams) => AccountAddFavorite 23 | > & 24 | Assoc< 25 | ["account", "addWatchlist"], 26 | (params: AccountPostParams) => AccountAddWatchlist 27 | > & 28 | Assoc< 29 | ["account", "favoriteMovies"], 30 | (params: AccountGetParams) => AccountFavoriteMovies 31 | > & 32 | Assoc< 33 | ["account", "favoriteTV"], 34 | (params: AccountGetParams) => AccountFavoriteTV 35 | > & 36 | Assoc< 37 | ["account", "lists"], 38 | (params: Omit) => AccountLists 39 | > & 40 | Assoc< 41 | ["account", "ratedMovies"], 42 | (params: AccountGetParams) => AccountRatedMovies 43 | > & 44 | Assoc<["account", "ratedTV"], (params: AccountGetParams) => AccountRatedTV> & 45 | Assoc< 46 | ["account", "ratedTVEpisodes"], 47 | (params: AccountGetParams) => AccountrRatedTVEpisodes 48 | > & 49 | Assoc< 50 | ["account", "watchlistMovies"], 51 | (params: AccountGetParams) => AccountWatchlistMovies 52 | > & 53 | Assoc< 54 | ["account", "watchlistTV"], 55 | (params: AccountGetParams) => AccountWatchlistTV 56 | >; 57 | -------------------------------------------------------------------------------- /apps/stories/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # movisea-stories 2 | 3 | ## 0.0.13 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies []: 8 | - movisea-web@0.0.13 9 | 10 | ## 0.0.12 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies []: 15 | - movisea-web@0.0.12 16 | 17 | ## 0.0.11 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies []: 22 | - movisea-web@0.0.11 23 | 24 | ## 0.0.10 25 | 26 | ### Patch Changes 27 | 28 | - [#41](https://github.com/mogeko/movisea/pull/41) [`c7304c1`](https://github.com/mogeko/movisea/commit/c7304c10629a443c00465c41e1d32ca1c4de9774) Thanks [@mogeko](https://github.com/mogeko)! - Upgrade dependencies 29 | 30 | - bump `@storybook/testing-library` from `0.1.0` to `0.2.0` ([#40](https://github.com/mogeko/movisea/pull/40)) 31 | - bump `@storybook/addon-styling` from `1.3.1` to `1.3.2` ([#39](https://github.com/mogeko/movisea/pull/39)) 32 | - bump `eslint` from `8.41.0` to `8.44.0` ([#38](https://github.com/mogeko/movisea/pull/38)) 33 | - bump `@types/node` from `20.2.5` to `20.3.3` ([#37](https://github.com/mogeko/movisea/pull/37)) 34 | - bump `@tsconfig/next` from `1.0.5` to `2.0.0` ([#36](https://github.com/mogeko/movisea/pull/36)) 35 | 36 | - Updated dependencies [[`c7304c1`](https://github.com/mogeko/movisea/commit/c7304c10629a443c00465c41e1d32ca1c4de9774)]: 37 | - movisea-web@0.0.10 38 | 39 | ## 0.0.9 40 | 41 | ### Patch Changes 42 | 43 | - Updated dependencies []: 44 | - movisea-web@0.0.9 45 | 46 | ## 0.0.8 47 | 48 | ### Patch Changes 49 | 50 | - Updated dependencies []: 51 | - movisea-web@0.0.8 52 | 53 | ## 0.0.7 54 | 55 | ### Patch Changes 56 | 57 | - Updated dependencies []: 58 | - movisea-web@0.0.7 59 | 60 | ## 0.0.6 61 | 62 | ### Patch Changes 63 | 64 | - Updated dependencies []: 65 | - movisea-web@0.0.6 66 | 67 | ## 0.0.5 68 | 69 | ### Patch Changes 70 | 71 | - Updated dependencies []: 72 | - movisea-web@0.0.5 73 | 74 | ## 0.0.4 75 | 76 | ### Patch Changes 77 | 78 | - Updated dependencies []: 79 | - movisea-web@0.0.4 80 | 81 | ## 0.0.3 82 | 83 | ### Patch Changes 84 | 85 | - [#9](https://github.com/mogeko/movisea/pull/9) [`294a53a`](https://github.com/mogeko/movisea/commit/294a53a67618da738f0e43510533a819de936385) Thanks [@mogeko](https://github.com/mogeko)! - Set the package to a private package 86 | 87 | - Updated dependencies []: 88 | - movisea-web@0.0.3 89 | 90 | ## 0.0.2 91 | 92 | ### Patch Changes 93 | 94 | - Updated dependencies []: 95 | - movisea-web@0.0.2 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* -------------------------------------------------------------------------------- /packages/is-plain-object/src/mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 Zheng Junyi. All rights reserved. Licensed under the MIT license. 2 | 3 | /** 4 | * This module provides a function to check if a value is a plain object. 5 | * 6 | * This project originated from jonschlinkert/is-plain-object (released under MIT license). 7 | * 8 | * @see {@link https://github.com/jonschlinkert/is-plain-object} 9 | * 10 | * @packageDocumentation 11 | */ 12 | 13 | /** 14 | * Returns true if the given value is an object. 15 | * 16 | * @param value The value to test 17 | */ 18 | function isObject(value: any) { 19 | return value !== null && typeof value === "object"; 20 | } 21 | 22 | /** 23 | * Returns true if an object was created by the `Object` constructor, or Object.create(null). 24 | * 25 | * @param o The object to test 26 | * 27 | * @example 28 | * ```ts 29 | * isPlainObject(Object.create({})); // => true 30 | * isPlainObject(Object.create(Object.prototype)); // => true 31 | * isPlainObject({foo: "bar"}); // => true 32 | * isPlainObject({}); // => true 33 | * isPlainObject(Object.create(null)); // => true 34 | * 35 | * isPlainObject(["foo", "bar"]); // => false 36 | * isPlainObject([]); // => false 37 | * isPlainObject(new Foo); // => false 38 | * ``` 39 | */ 40 | export function isPlainObject(o: object): o is Record { 41 | if (isObject(o) === false) return false; 42 | 43 | // If has modified constructor 44 | const ctor = o.constructor; 45 | if (ctor === undefined) return true; 46 | 47 | // If has modified prototype 48 | const prot = ctor.prototype; 49 | if (isObject(prot) === false) return false; 50 | 51 | // If constructor does not have an Object-specific method 52 | if (prot.hasOwnProperty("isPrototypeOf") === false) { 53 | return false; 54 | } 55 | 56 | // Most likely a plain Object 57 | return true; 58 | } 59 | 60 | if (import.meta.vitest) { 61 | const { describe, it, expect } = await import("vitest"); 62 | 63 | describe("isPlainObject", () => { 64 | it("returns true for plain objects", () => { 65 | expect(isPlainObject(Object.create({}))).toBe(true); 66 | expect(isPlainObject(Object.create(Object.prototype))).toBe(true); 67 | expect(isPlainObject({ foo: "bar" })).toBe(true); 68 | expect(isPlainObject({})).toBe(true); 69 | expect(isPlainObject(Object.create(null))).toBe(true); 70 | }); 71 | 72 | it("returns false for non-plain objects", () => { 73 | expect(isPlainObject(["foo", "bar"])).toBe(false); 74 | expect(isPlainObject([])).toBe(false); 75 | expect(isPlainObject(new Date())).toBe(false); 76 | // @ts-expect-error 77 | expect(isPlainObject(null)).toBe(false); 78 | // @ts-expect-error 79 | expect(isPlainObject(1)).toBe(false); 80 | }); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /apps/stories/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Storybook build outputs 133 | storybook-static 134 | -------------------------------------------------------------------------------- /packages/tmdb-api/src/endpoints.ts: -------------------------------------------------------------------------------- 1 | export const ENDPOINTS = { 2 | account: { 3 | details: ["GET /account/{id}?session_id={session_id}", {}], 4 | addFavorite: ["POST /account/{id}/favorite?session_id={session_id}", {}], 5 | addWatchlist: ["POST /account/{id}/watchlist?session_id={session_id}", {}], 6 | favoriteMovies: [ 7 | "GET /account/{id}/favorite/movies?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 8 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 9 | ], 10 | favoriteTV: [ 11 | "GET /account/{id}/favorite/tv?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 12 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 13 | ], 14 | lists: [ 15 | "GET /account/{id}/lists?page={page}&session_id={session_id}", 16 | { page: 1 }, 17 | ], 18 | ratedMovies: [ 19 | "GET /account/{id}/rated/movies?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 20 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 21 | ], 22 | ratedTV: [ 23 | "GET /account/{id}/rated/tv?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 24 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 25 | ], 26 | ratedTVEpisodes: [ 27 | "GET /account/{id}/rated/tv/episodes?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 28 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 29 | ], 30 | watchlistMovies: [ 31 | "GET /account/{id}/watchlist/movies?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 32 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 33 | ], 34 | watchlistTV: [ 35 | "GET /account/{id}/watchlist/tv?language={language}&page={page}&session_id={session_id}&sort_by={sort_by}", 36 | { language: "en-US", page: 1, sort_by: "created_at.asc" }, 37 | ], 38 | }, 39 | movie: { 40 | details: [ 41 | "GET /movie/{id}?append_to_response={append_to_response}&language={language}", 42 | { language: "en-US" }, 43 | ], 44 | }, 45 | search: { 46 | multi: [ 47 | "GET /search/multi?query={query}&include_adult={include_adult}&language={language}&page={page}", 48 | { include_adult: false, language: "en-US", page: 1 }, 49 | ], 50 | movie: [ 51 | "GET /search/movie?query={query}&include_adult={include_adult}&language={language}&primary_release_year={primary_release_year}&page={page}®ion={region}&year={year}", 52 | { include_adult: false, language: "en-US", page: 1 }, 53 | ], 54 | tv: [ 55 | "GET /search/tv?query={query}&first_air_date_year={first_air_date_year}&include_adult={include_adult}&language={language}&page={page}&year={year}", 56 | { include_adult: false, language: "en-US", page: 1 }, 57 | ], 58 | }, 59 | } as const; 60 | -------------------------------------------------------------------------------- /packages/tmdb-request/src/parse.ts: -------------------------------------------------------------------------------- 1 | import type { Context, DefaultParams } from "@/defaults"; 2 | import { mergeDeep } from "@/merge-deep"; 3 | import { splitObj } from "@/split-obj"; 4 | import { parseTemplate, type Template } from "url-template"; 5 | 6 | export function parse(defaults: DefaultParams) { 7 | return (route?: string, opts: Options = {}): Context => { 8 | // replace :varname with {varname} to make it RFC 6570 compatible 9 | const _route = (route || "/").replace(/:([a-z]\w+)/g, "{$1}"); 10 | const [_method, path] = _route.trim().split(" ") as [string, string?]; 11 | const [params, options] = splitParams(mergeDeep(defaults, opts)); 12 | 13 | const method = path ? _method.toUpperCase() : defaults.method; 14 | const url = parseTemplate(path ?? _method).expand(options); 15 | const body = method === "POST" ? params.body : defaults.body; 16 | 17 | return Object.assign(params, { method, url, body }); 18 | }; 19 | } 20 | 21 | function splitParams(opts: Options): [Context, ExpandParams] { 22 | return splitObj(opts, ["method", "headers", "baseUrl", "url", "body"]); 23 | } 24 | 25 | /** Any object that can be expanded into a URL */ 26 | type ExpandParams = Parameters[0]; 27 | /** The `opts` parameter of {@link request} and {@link parser}.*/ 28 | export type Options = DefaultParams & ExpandParams; 29 | 30 | if (import.meta.vitest) { 31 | const { describe, it, expect } = await import("vitest"); 32 | const { DEFAULTS } = await import("@/defaults"); 33 | 34 | describe("splitParams", () => { 35 | it("should split params", () => { 36 | const [params, options] = splitParams(DEFAULTS); 37 | 38 | expect(params.method).toEqual("GET"); 39 | delete params.headers?.["user-agent"]; // Eliminate environmental impacts 40 | expect(params.headers).toEqual({ accept: "application/json" }); 41 | expect(params.url).toBeUndefined(); 42 | expect(options).toEqual({}); 43 | }); 44 | }); 45 | 46 | describe("parse", () => { 47 | const parser = parse(DEFAULTS); 48 | 49 | it("should parse route", () => { 50 | expect(typeof parser).toBe("function"); 51 | 52 | const getCtx = parser("/foo/{bar}", { body: "foo", bar: "baz" }); 53 | 54 | expect(getCtx.url).toEqual("/foo/baz"); 55 | expect(getCtx.body).toBeNull(); 56 | expect(getCtx.method).toEqual("GET"); 57 | 58 | const postCtx = parser("POST /foo/{bar}", { body: "foo", bar: "baz" }); 59 | 60 | expect(postCtx.url).toEqual("/foo/baz"); 61 | expect(postCtx.body).toEqual("foo"); 62 | expect(postCtx.method).toEqual("POST"); 63 | 64 | expect(parser().url).toEqual("/"); 65 | }); 66 | 67 | it("should parse route with custom baseUrl", () => { 68 | expect(typeof parser).toBe("function"); 69 | 70 | expect(parser("/foo/{bar}", { bar: "baz" }).baseUrl).toEqual( 71 | "https://api.themoviedb.org/3" 72 | ); 73 | 74 | expect( 75 | parser("/foo/{bar}", { bar: "baz", baseUrl: "https://foo.bar" }).baseUrl 76 | ).toEqual("https://foo.bar"); 77 | }); 78 | 79 | it("should replace :varname with {varname}", () => { 80 | expect(parser("/foo/:bar", { bar: "baz" }).url).toEqual("/foo/baz"); 81 | }); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/card.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { LuBellRing, LuCheck } from "react-icons/lu"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { Switch } from "@/components/ui/switch"; 15 | 16 | export default { 17 | title: "Shadcn-ui/Card", 18 | component: Card, 19 | argTypes: { 20 | className: { control: { type: "text" } }, 21 | }, 22 | } as Meta; 23 | type Story = StoryObj; 24 | 25 | export const Default: Story = { 26 | render: ({ className, ...props }) => ( 27 | 28 | 29 | Card Title 30 | Card Description 31 | 32 | 33 |

Card Content

34 |
35 | 36 |

Card Footer

37 |
38 |
39 | ), 40 | }; 41 | 42 | const notifications = [ 43 | { 44 | title: "Your call has been confirmed.", 45 | description: "1 hour ago", 46 | }, 47 | { 48 | title: "You have a new message!", 49 | description: "1 hour ago", 50 | }, 51 | { 52 | title: "Your subscription is expiring soon!", 53 | description: "2 hours ago", 54 | }, 55 | ]; 56 | 57 | export const Notification: Story = { 58 | render: ({ className, ...props }) => ( 59 | 60 | 61 | Notifications 62 | You have 3 unread messages. 63 | 64 | 65 |
66 | 67 |
68 |

69 | Push Notifications 70 |

71 |

72 | Send notifications to device. 73 |

74 |
75 | 76 |
77 |
78 | {notifications.map((notification, index) => ( 79 |
83 | 84 |
85 |

86 | {notification.title} 87 |

88 |

89 | {notification.description} 90 |

91 |
92 |
93 | ))} 94 |
95 |
96 | 97 | 100 | 101 |
102 | ), 103 | }; 104 | -------------------------------------------------------------------------------- /apps/web/app/search/components/pagination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useMemo } from "react"; 4 | import Link from "next/link"; 5 | import { usePathname, useSearchParams } from "next/navigation"; 6 | import { LuChevronLeft, LuChevronRight } from "react-icons/lu"; 7 | 8 | import { cn, range } from "@/lib/utils"; 9 | import { Button, buttonVariants } from "@/components/ui/button"; 10 | 11 | export const Pagination: React.FC<{ 12 | totalPages: number; 13 | }> = ({ totalPages }) => { 14 | const searchParams = useSearchParams()!; 15 | const pathname = usePathname(); 16 | const currentPage = Number(searchParams.get("page") ?? 1); 17 | const createQueryString = useCallback( 18 | (page: number) => { 19 | const params = new URLSearchParams(searchParams.toString()); 20 | params.set("page", page.toString()); 21 | return params.toString(); 22 | }, 23 | [searchParams] 24 | ); 25 | const createPagination = useCallback( 26 | (start: number, end: number) => { 27 | return range(start, end + 1).map((page) => ( 28 | 41 | )); 42 | }, 43 | [currentPage, pathname, createQueryString] 44 | ); 45 | const pagination = useMemo(() => { 46 | if (totalPages <= 9) return createPagination(1, totalPages); 47 | if (currentPage < 4 || currentPage > totalPages - 4) { 48 | return ( 49 | <> 50 | {createPagination(1, 4)} 51 | 52 | {createPagination(totalPages - 3, totalPages)} 53 | 54 | ); 55 | } 56 | return ( 57 | <> 58 | {createPagination(1, 2)} 59 | 60 | {createPagination(currentPage - 1, currentPage + 1)} 61 | 62 | {createPagination(totalPages - 1, totalPages)} 63 | 64 | ); 65 | }, [currentPage, totalPages, createPagination]); 66 | 67 | return ( 68 | 97 | ); 98 | }; 99 | 100 | const Ellipsis: React.FC = () => ( 101 |
107 | ... 108 |
109 | ); 110 | -------------------------------------------------------------------------------- /apps/web/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # movisea-web 2 | 3 | ## 0.0.13 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [[`02c2203`](https://github.com/mogeko/movisea/commit/02c22031e9f0fd9d286abfc51c6f125eb6914090), [`14fc3a4`](https://github.com/mogeko/movisea/commit/14fc3a4829d4602b5935ced24ea13c3e451f4c04), [`9803a6e`](https://github.com/mogeko/movisea/commit/9803a6e53cc802d2bb0ca43ffd3ec657eabeca8d)]: 8 | - @mogeko/tmdb-request@1.3.2 9 | 10 | ## 0.0.12 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies [[`772e193`](https://github.com/mogeko/movisea/commit/772e193647ab4f1aeb405e6be250de1b197914cf), [`040bc76`](https://github.com/mogeko/movisea/commit/040bc768e8934c4e0bbb955c3e12340b2da41784)]: 15 | - @mogeko/tmdb-request@1.3.1 16 | 17 | ## 0.0.11 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies [[`f1edd70`](https://github.com/mogeko/movisea/commit/f1edd705fa368d3d5dbc5f4cfbbff93c4c972abd), [`67fad93`](https://github.com/mogeko/movisea/commit/67fad93720aa6d4716eaa5a312c11823152dec78), [`0c0916b`](https://github.com/mogeko/movisea/commit/0c0916bce1db46eee04f0dc5802fcb280294beb5), [`aa1fa17`](https://github.com/mogeko/movisea/commit/aa1fa176ffb4a14513b9b6dd1809e89f0b4d81b6)]: 22 | - @mogeko/tmdb-request@1.3.0 23 | 24 | ## 0.0.10 25 | 26 | ### Patch Changes 27 | 28 | - [#41](https://github.com/mogeko/movisea/pull/41) [`c7304c1`](https://github.com/mogeko/movisea/commit/c7304c10629a443c00465c41e1d32ca1c4de9774) Thanks [@mogeko](https://github.com/mogeko)! - Upgrade dependencies 29 | 30 | - bump `eslint` from `8.41.0` to `8.44.0` ([#38](https://github.com/mogeko/movisea/pull/38)) 31 | - bump `@types/node` from `20.2.5` to `20.3.3` ([#37](https://github.com/mogeko/movisea/pull/37)) 32 | - bump `@tsconfig/next` from `1.0.5` to `2.0.0` ([#36](https://github.com/mogeko/movisea/pull/36)) 33 | 34 | - Updated dependencies [[`c7304c1`](https://github.com/mogeko/movisea/commit/c7304c10629a443c00465c41e1d32ca1c4de9774)]: 35 | - @mogeko/tmdb-request@1.2.5 36 | 37 | ## 0.0.9 38 | 39 | ### Patch Changes 40 | 41 | - Updated dependencies [[`8a7c476`](https://github.com/mogeko/movisea/commit/8a7c4767fc817e495792e1ce99fbc12e6f4722b5)]: 42 | - @mogeko/tmdb-request@1.2.4 43 | 44 | ## 0.0.8 45 | 46 | ### Patch Changes 47 | 48 | - Updated dependencies [[`f02efa6`](https://github.com/mogeko/movisea/commit/f02efa69403ef02284b49ff0e0e7b050a9b4c99c)]: 49 | - tmdb-request@1.2.3 50 | 51 | ## 0.0.7 52 | 53 | ### Patch Changes 54 | 55 | - Updated dependencies [[`a87825e`](https://github.com/mogeko/movisea/commit/a87825e9ee8de8e817d21ac09c6b23612c07c48c), [`a87825e`](https://github.com/mogeko/movisea/commit/a87825e9ee8de8e817d21ac09c6b23612c07c48c)]: 56 | - tmdb-request@1.2.2 57 | 58 | ## 0.0.6 59 | 60 | ### Patch Changes 61 | 62 | - Updated dependencies [[`0bfb3b1`](https://github.com/mogeko/movisea/commit/0bfb3b19ee76fcc89d33d9e200be815e50f60848), [`18c06db`](https://github.com/mogeko/movisea/commit/18c06db12b40056c4f287046e89a2117b704f6e8), [`3d13fcc`](https://github.com/mogeko/movisea/commit/3d13fcc1b9456b45aba9026fc7621caae711182d)]: 63 | - tmdb-request@1.2.1 64 | 65 | ## 0.0.5 66 | 67 | ### Patch Changes 68 | 69 | - Updated dependencies [[`3698e8d`](https://github.com/mogeko/movisea/commit/3698e8dfcb77f465519b84287ca95c464106d048)]: 70 | - tmdb-request@1.2.0 71 | 72 | ## 0.0.4 73 | 74 | ### Patch Changes 75 | 76 | - Updated dependencies [[`9393959`](https://github.com/mogeko/movisea/commit/9393959f8e7fcba6fc3c9d5d23713655863d9bbd)]: 77 | - tmdb-request@1.1.1 78 | 79 | ## 0.0.3 80 | 81 | ### Patch Changes 82 | 83 | - Updated dependencies [[`73e6e90`](https://github.com/mogeko/movisea/commit/73e6e9075ee8bd28bf10bfbd255cf7d43c56e0ca), [`2837897`](https://github.com/mogeko/movisea/commit/2837897af7d5c3b3396601ec1534f7ee86333215), [`f9ec3ad`](https://github.com/mogeko/movisea/commit/f9ec3adb187a7642a85db9a28c4ffe0284bbd7d6)]: 84 | - tmdb-request@1.1.0 85 | 86 | ## 0.0.2 87 | 88 | ### Patch Changes 89 | 90 | - Updated dependencies []: 91 | - tmdb-request@1.0.0 92 | -------------------------------------------------------------------------------- /apps/web/app/search/[type]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { request } from "@mogeko/tmdb-request"; 3 | 4 | import { tokens } from "@/config/tokens"; 5 | import type { XOR } from "@/lib/utils"; 6 | import { Separator } from "@/components/ui/separator"; 7 | import { PosterImage } from "@/components/poster-image"; 8 | import { Pagination } from "@/app/search/components/pagination"; 9 | 10 | const SearchPage: React.FC<{ 11 | searchParams: { q: string; page?: number }; 12 | params: { type: string }; 13 | }> = async ({ searchParams: { q, page }, params }) => { 14 | const data = await getSearchInfo(params.type, { 15 | page: page?.toString(), 16 | query: q, 17 | }); 18 | 19 | return ( 20 |
21 |
22 |

23 | {data.total_results} media results 24 |

25 |
26 | {data.results.map((result) => ( 27 |
28 | 29 | 33 | 39 |
40 |

41 | {result.title ?? result.name} 42 |

43 |

44 | {new Date( 45 | result.release_date ?? result.first_air_date 46 | ).toLocaleDateString()} 47 |

48 |

49 | {result.overview} 50 |

51 |
52 | 53 |
54 | ))} 55 | 56 |
57 | ); 58 | }; 59 | 60 | const getSearchInfo = async (type: string, params: SearchParams) => { 61 | return await request( 62 | "/search/{type}?query={query}&include_adult={include_adult}&language={language}&page={page}", 63 | { 64 | headers: { authorization: `Bearer ${tokens.tmdb}` }, 65 | query: params.query, 66 | include_adult: params.include_adult ?? "false", 67 | language: params.language ?? "en-US", 68 | page: params.page ?? "1", 69 | type: type, 70 | } 71 | ); 72 | }; 73 | 74 | type SearchParams = { 75 | query: string; 76 | include_adult?: "false" | "true"; 77 | language?: string; 78 | page?: string; 79 | }; 80 | 81 | export type SearchInfo = { 82 | page: number; 83 | results: Array< 84 | XOR< 85 | { 86 | media_type: "movie"; 87 | title: string; 88 | original_title: string; 89 | release_date: string; 90 | }, 91 | { 92 | media_type: "tv"; 93 | name: string; 94 | original_name: string; 95 | first_air_date: string; 96 | origin_country: string[]; 97 | } 98 | > & { 99 | ault: boolean; 100 | backdrop_path: string; 101 | id: number; 102 | original_language: string; 103 | overview: string; 104 | poster_path: string; 105 | genre_ids: number[]; 106 | popularity: number; 107 | origin_country: string[]; 108 | vote_average: number; 109 | vote_count: number; 110 | } 111 | >; 112 | total_pages: number; 113 | total_results: number; 114 | }; 115 | 116 | export default SearchPage; 117 | -------------------------------------------------------------------------------- /apps/stories/shadcn-ui/input.stories.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from "@hookform/resolvers/zod"; 2 | import type { Meta, StoryObj } from "@storybook/react"; 3 | import { useForm } from "react-hook-form"; 4 | import * as z from "zod"; 5 | 6 | import { toast } from "@/lib/use-toast"; 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormDescription, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Label } from "@/components/ui/label"; 19 | 20 | export default { 21 | title: "Shadcn-ui/Input", 22 | component: Input, 23 | argTypes: { 24 | type: { 25 | options: ["text", "search", "email", "password", "number", "file"], 26 | control: { type: "select" }, 27 | }, 28 | disabled: { control: { type: "boolean" } }, 29 | }, 30 | } as Meta; 31 | type Story = StoryObj; 32 | 33 | export const Search: Story = { 34 | args: { 35 | placeholder: "Search...", 36 | type: "search", 37 | }, 38 | }; 39 | 40 | export const Disable: Story = { 41 | args: { 42 | disabled: true, 43 | ...Search.args, 44 | }, 45 | }; 46 | 47 | export const WithButton: Story = { 48 | render: (args) => ( 49 |
50 | 51 | 52 |
53 | ), 54 | args: { 55 | placeholder: "Email address", 56 | type: "email", 57 | }, 58 | }; 59 | 60 | export const WithLabel: Story = { 61 | render: ({ id, ...props }) => ( 62 |
63 | 64 | 65 |

Enter your email address.

66 |
67 | ), 68 | args: { 69 | placeholder: "Email address", 70 | type: "email", 71 | }, 72 | }; 73 | 74 | export const File: Story = { 75 | render: ({ id, ...props }) => ( 76 |
77 | 78 | 79 |
80 | ), 81 | args: { 82 | id: "picture", 83 | type: "file", 84 | }, 85 | }; 86 | 87 | const schema = z.object({ 88 | username: z.string().min(2, { 89 | message: "Username must be at least 2 characters.", 90 | }), 91 | }); 92 | 93 | const InputInForm: React.FC = (_args) => { 94 | const form = useForm>({ 95 | resolver: zodResolver(schema), 96 | }); 97 | 98 | function onSubmit(data: z.infer) { 99 | toast({ 100 | title: "You submitted the following values:", 101 | description: ( 102 |
103 |           {JSON.stringify(data, null, 2)}
104 |         
105 | ), 106 | }); 107 | } 108 | 109 | return ( 110 |
111 | 112 | ( 116 | 117 | Username 118 | 119 | 120 | 121 | 122 | This is your public display name. 123 | 124 | 125 | 126 | )} 127 | /> 128 | 129 | 130 | 131 | ); 132 | }; 133 | 134 | export const Demo1: Story = { 135 | render: () => , 136 | parameters: { 137 | controls: { 138 | hideNoControlsWarning: true, 139 | exclude: /type|disabled/g, 140 | }, 141 | }, 142 | }; 143 | -------------------------------------------------------------------------------- /apps/web/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { LuX } from "react-icons/lu"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Dialog = DialogPrimitive.Root; 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger; 12 | 13 | const DialogPortal = ({ 14 | className, 15 | children, 16 | ...props 17 | }: DialogPrimitive.DialogPortalProps) => ( 18 | 19 |
20 | {children} 21 |
22 |
23 | ); 24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName; 25 | 26 | const DialogOverlay = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, ...props }, ref) => ( 30 | 38 | )); 39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 40 | 41 | const DialogContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 46 | 47 | 55 | {children} 56 | 57 | 58 | Close 59 | 60 | 61 | 62 | )); 63 | DialogContent.displayName = DialogPrimitive.Content.displayName; 64 | 65 | const DialogHeader = ({ 66 | className, 67 | ...props 68 | }: React.HTMLAttributes) => ( 69 |
76 | ); 77 | DialogHeader.displayName = "DialogHeader"; 78 | 79 | const DialogFooter = ({ 80 | className, 81 | ...props 82 | }: React.HTMLAttributes) => ( 83 |
90 | ); 91 | DialogFooter.displayName = "DialogFooter"; 92 | 93 | const DialogTitle = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, ...props }, ref) => ( 97 | 105 | )); 106 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 107 | 108 | const DialogDescription = React.forwardRef< 109 | React.ElementRef, 110 | React.ComponentPropsWithoutRef 111 | >(({ className, ...props }, ref) => ( 112 | 117 | )); 118 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 119 | 120 | export { 121 | Dialog, 122 | DialogTrigger, 123 | DialogContent, 124 | DialogHeader, 125 | DialogFooter, 126 | DialogTitle, 127 | DialogDescription, 128 | }; 129 | -------------------------------------------------------------------------------- /packages/tmdb-request/src/defaults.ts: -------------------------------------------------------------------------------- 1 | import { getUserAgent } from "universal-user-agent"; 2 | 3 | export const DEFAULTS = { 4 | method: "GET", 5 | baseUrl: "https://api.themoviedb.org/3", 6 | headers: { 7 | accept: "application/json", 8 | "user-agent": getUserAgent(), 9 | }, 10 | body: null, 11 | } as const; 12 | 13 | /** 14 | * The type for parameters of {@link parse}. It will set the default 15 | * behavior of {@link parser} and {@link request}. 16 | * 17 | * @remarks 18 | * The signature of this type is same as {@link Context} but with 19 | * optional properties. 20 | */ 21 | export type DefaultParams = { 22 | /** 23 | * The HTTP method for the request. 24 | * 25 | * @remarks 26 | * HTTP defines a set of request methods to indicate the desired action to be 27 | * performed for a given resource. 28 | * 29 | * In this case, the only methods supported by TMDB are `GET`, `POST`, and `DELETE`. 30 | * 31 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods} 32 | * 33 | * @defaultValue "GET" 34 | */ 35 | method?: "GET" | "POST" | "DELETE"; 36 | /** 37 | * The `headers` for the request. 38 | * 39 | * @remarks 40 | * HTTP `headers` let the client and the server pass additional information with 41 | * an HTTP request or response. 42 | * 43 | * @see {@link https://developer.mozilla.org/en-US/docs/web/http/headers} 44 | */ 45 | headers?: { 46 | /** 47 | * The `Accept` header for the request. 48 | * 49 | * @remarks 50 | * The `Accept` request HTTP header indicates which content types, expressed as MIME types, 51 | * the client is able to understand. 52 | * 53 | * For TMDB API, the only response format we support is JSON. 54 | * 55 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept} 56 | * @see {@link https://developer.themoviedb.org/docs/json-and-jsonp} 57 | * 58 | * @defaultValue "application/json" 59 | */ 60 | accept?: "application/json"; 61 | /** 62 | * The `Content-Type` header for the request with `POST` and `DELETE` methods. 63 | * 64 | * @remarks 65 | * The Content-Type representation header is used to indicate the original media type of the resource 66 | * (prior to any content encoding applied for sending). 67 | * 68 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type} 69 | */ 70 | "content-type"?: "application/json" | "application/json;charset=utf-8"; 71 | /** 72 | * The `Authorization` header for the request. 73 | * 74 | * @remarks 75 | * The HTTP `Authorization` request header can be used to provide credentials that authenticate 76 | * a user agent with a server, allowing access to a protected resource. 77 | * 78 | * In here, it should be a string that starts with `Bearer `. 79 | * 80 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization} 81 | */ 82 | authorization?: `Bearer ${string}`; 83 | /** 84 | * The `User-Agent` header for the request. 85 | * 86 | * @remarks 87 | * The `User-Agent` request header is a characteristic string that lets servers and network peers 88 | * identify the application, operating system, vendor, and/or version of the requesting user agent. 89 | * 90 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent} 91 | */ 92 | "user-agent"?: string; 93 | /** other headers */ 94 | [key: string]: any; 95 | }; 96 | /** 97 | * The base URL for the request. 98 | * 99 | * @defaultValue "https://api.themoviedb.org/3" 100 | */ 101 | baseUrl?: `https://${string}` | `http://${string}`; 102 | /** 103 | * The URL (relative to the {@link baseUrl}) for the request. 104 | * 105 | * @remarks 106 | * It has to be a string consisting of `URL Template` and the request method, e.g. `GET /movie/{id}`. 107 | * If it’s set to a pure URL, only the method defaults to `GET`. 108 | * 109 | * @see {@link https://www.rfc-editor.org/rfc/rfc6570} 110 | */ 111 | url?: `/${string}` | `${Context["method"]} /${string}`; 112 | /** 113 | * The `body` for the request. 114 | * 115 | * @remarks 116 | * The `body` of a request is the data sent by the client to your API. 117 | * 118 | * It will be sent as-is in a `POST` request. For `GET` and `DELETE` request, it will always be `null`. 119 | * 120 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Request/body} 121 | * 122 | * @defaultValue null 123 | */ 124 | body?: BodyInit | null; 125 | }; 126 | 127 | /** The result of {@link parser}. */ 128 | export type Context = Required; 129 | -------------------------------------------------------------------------------- /apps/web/lib/use-toast.ts: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from "react"; 3 | 4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 5 | 6 | const TOAST_LIMIT = 1; 7 | const TOAST_REMOVE_DELAY = 1000000; 8 | 9 | type ToasterToast = ToastProps & { 10 | id: string; 11 | title?: React.ReactNode; 12 | description?: React.ReactNode; 13 | action?: ToastActionElement; 14 | }; 15 | 16 | const actionTypes = { 17 | ADD_TOAST: "ADD_TOAST", 18 | UPDATE_TOAST: "UPDATE_TOAST", 19 | DISMISS_TOAST: "DISMISS_TOAST", 20 | REMOVE_TOAST: "REMOVE_TOAST", 21 | } as const; 22 | 23 | let count = 0; 24 | 25 | function genId() { 26 | count = (count + 1) % Number.MAX_VALUE; 27 | return count.toString(); 28 | } 29 | 30 | type ActionType = typeof actionTypes; 31 | 32 | type Action = 33 | | { 34 | type: ActionType["ADD_TOAST"]; 35 | toast: ToasterToast; 36 | } 37 | | { 38 | type: ActionType["UPDATE_TOAST"]; 39 | toast: Partial; 40 | } 41 | | { 42 | type: ActionType["DISMISS_TOAST"]; 43 | toastId?: ToasterToast["id"]; 44 | } 45 | | { 46 | type: ActionType["REMOVE_TOAST"]; 47 | toastId?: ToasterToast["id"]; 48 | }; 49 | 50 | interface State { 51 | toasts: ToasterToast[]; 52 | } 53 | 54 | const toastTimeouts = new Map>(); 55 | 56 | const addToRemoveQueue = (toastId: string) => { 57 | if (toastTimeouts.has(toastId)) { 58 | return; 59 | } 60 | 61 | const timeout = setTimeout(() => { 62 | toastTimeouts.delete(toastId); 63 | dispatch({ 64 | type: "REMOVE_TOAST", 65 | toastId: toastId, 66 | }); 67 | }, TOAST_REMOVE_DELAY); 68 | 69 | toastTimeouts.set(toastId, timeout); 70 | }; 71 | 72 | export const reducer = (state: State, action: Action): State => { 73 | switch (action.type) { 74 | case "ADD_TOAST": 75 | return { 76 | ...state, 77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 78 | }; 79 | 80 | case "UPDATE_TOAST": 81 | return { 82 | ...state, 83 | toasts: state.toasts.map((t) => 84 | t.id === action.toast.id ? { ...t, ...action.toast } : t 85 | ), 86 | }; 87 | 88 | case "DISMISS_TOAST": { 89 | const { toastId } = action; 90 | 91 | // ! Side effects ! - This could be extracted into a dismissToast() action, 92 | // but I'll keep it here for simplicity 93 | if (toastId) { 94 | addToRemoveQueue(toastId); 95 | } else { 96 | state.toasts.forEach((toast) => { 97 | addToRemoveQueue(toast.id); 98 | }); 99 | } 100 | 101 | return { 102 | ...state, 103 | toasts: state.toasts.map((t) => 104 | t.id === toastId || toastId === undefined 105 | ? { 106 | ...t, 107 | open: false, 108 | } 109 | : t 110 | ), 111 | }; 112 | } 113 | case "REMOVE_TOAST": 114 | if (action.toastId === undefined) { 115 | return { 116 | ...state, 117 | toasts: [], 118 | }; 119 | } 120 | return { 121 | ...state, 122 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 123 | }; 124 | } 125 | }; 126 | 127 | const listeners: Array<(state: State) => void> = []; 128 | 129 | let memoryState: State = { toasts: [] }; 130 | 131 | function dispatch(action: Action) { 132 | memoryState = reducer(memoryState, action); 133 | listeners.forEach((listener) => { 134 | listener(memoryState); 135 | }); 136 | } 137 | 138 | type Toast = Omit; 139 | 140 | function toast({ ...props }: Toast) { 141 | const id = genId(); 142 | 143 | const update = (props: ToasterToast) => 144 | dispatch({ 145 | type: "UPDATE_TOAST", 146 | toast: { ...props, id }, 147 | }); 148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 149 | 150 | dispatch({ 151 | type: "ADD_TOAST", 152 | toast: { 153 | ...props, 154 | id, 155 | open: true, 156 | onOpenChange: (open) => { 157 | if (!open) dismiss(); 158 | }, 159 | }, 160 | }); 161 | 162 | return { 163 | id: id, 164 | dismiss, 165 | update, 166 | }; 167 | } 168 | 169 | function useToast() { 170 | const [state, setState] = React.useState(memoryState); 171 | 172 | React.useEffect(() => { 173 | listeners.push(setState); 174 | return () => { 175 | const index = listeners.indexOf(setState); 176 | if (index > -1) { 177 | listeners.splice(index, 1); 178 | } 179 | }; 180 | }, [state]); 181 | 182 | return { 183 | ...state, 184 | toast, 185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 186 | }; 187 | } 188 | 189 | export { useToast, toast }; 190 | -------------------------------------------------------------------------------- /apps/web/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as LabelPrimitive from "@radix-ui/react-label"; 3 | import { Slot } from "@radix-ui/react-slot"; 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form"; 12 | 13 | import { cn } from "@/lib/utils"; 14 | import { Label } from "@/components/ui/label"; 15 | 16 | const Form = FormProvider; 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { name: TName }; 22 | 23 | const FormFieldContext = React.createContext( 24 | {} as FormFieldContextValue 25 | ); 26 | 27 | const FormField = < 28 | TFieldValues extends FieldValues = FieldValues, 29 | TName extends FieldPath = FieldPath 30 | >({ 31 | ...props 32 | }: ControllerProps) => { 33 | return ( 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const useFormField = () => { 41 | const fieldContext = React.useContext(FormFieldContext); 42 | const itemContext = React.useContext(FormItemContext); 43 | const { getFieldState, formState } = useFormContext(); 44 | 45 | const fieldState = getFieldState(fieldContext.name, formState); 46 | 47 | if (!fieldContext) { 48 | throw new Error("useFormField should be used within "); 49 | } 50 | 51 | const { id } = itemContext; 52 | 53 | return { 54 | id, 55 | name: fieldContext.name, 56 | formItemId: `${id}-form-item`, 57 | formDescriptionId: `${id}-form-item-description`, 58 | formMessageId: `${id}-form-item-message`, 59 | ...fieldState, 60 | }; 61 | }; 62 | 63 | type FormItemContextValue = { id: string }; 64 | 65 | const FormItemContext = React.createContext( 66 | {} as FormItemContextValue 67 | ); 68 | 69 | const FormItem = React.forwardRef< 70 | HTMLDivElement, 71 | React.HTMLAttributes 72 | >(({ className, ...props }, ref) => { 73 | const id = React.useId(); 74 | 75 | return ( 76 | 77 |
78 | 79 | ); 80 | }); 81 | FormItem.displayName = "FormItem"; 82 | 83 | const FormLabel = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => { 87 | const { error, formItemId } = useFormField(); 88 | 89 | return ( 90 |