├── resources
├── js
│ └── app.js
├── views
│ ├── pages
│ │ ├── errors
│ │ │ ├── not-found.edge
│ │ │ └── server-error.edge
│ │ ├── directors
│ │ │ ├── show.edge
│ │ │ └── index.edge
│ │ ├── writers
│ │ │ ├── show.edge
│ │ │ └── index.edge
│ │ ├── admin
│ │ │ ├── dashboard.edge
│ │ │ └── movies
│ │ │ │ ├── index.edge
│ │ │ │ └── createOrEdit.edge
│ │ ├── profiles
│ │ │ ├── show.edge
│ │ │ └── edit.edge
│ │ ├── auth
│ │ │ ├── register.edge
│ │ │ └── login.edge
│ │ ├── home.edge
│ │ ├── movies
│ │ │ ├── index.edge
│ │ │ └── show.edge
│ │ └── watchlist.edge
│ ├── components
│ │ ├── stat
│ │ │ └── index.edge
│ │ ├── card.edge
│ │ ├── button
│ │ │ └── index.edge
│ │ ├── layout
│ │ │ ├── index.edge
│ │ │ └── admin.edge
│ │ ├── form
│ │ │ └── input.edge
│ │ ├── movie
│ │ │ ├── card_profile.edge
│ │ │ └── card.edge
│ │ └── pagination
│ │ │ └── index.edge
│ └── partials
│ │ └── nav.edge
└── css
│ └── app.css
├── app
├── enums
│ ├── roles.ts
│ └── movie_statuses.ts
├── services
│ ├── profile_service.ts
│ ├── cache_service.ts
│ └── movie_service.ts
├── validators
│ ├── profile.ts
│ ├── auth.ts
│ ├── movie_filter.ts
│ └── movie.ts
├── controllers
│ ├── auth
│ │ ├── logout_controller.ts
│ │ ├── login_controller.ts
│ │ └── register_controller.ts
│ ├── redis_controller.ts
│ ├── storage_controller.ts
│ ├── admin
│ │ ├── dashboard_controller.ts
│ │ └── movies_controller.ts
│ ├── directors_controller.ts
│ ├── home_controller.ts
│ ├── writers_controller.ts
│ ├── movies_controller.ts
│ ├── watchlists_controller.ts
│ └── profiles_controller.ts
├── middleware
│ ├── silent_auth_middleware.ts
│ ├── auth_middleware.ts
│ ├── container_bindings_middleware.ts
│ ├── admin_middleware.ts
│ └── guest_middleware.ts
├── models
│ ├── role.ts
│ ├── movie_status.ts
│ ├── profile.ts
│ ├── watchlist.ts
│ ├── cineast.ts
│ ├── user.ts
│ └── movie.ts
└── exceptions
│ └── handler.ts
├── public
├── db-schema.pdf
└── db-schema.png
├── storage
├── avatars
│ └── av.jpg
└── posters
│ └── idpv90uw9gejwku3zxyqbbnd.gif
├── tailwind.config.js
├── .env.example
├── start
├── globals.ts
├── rules
│ ├── exists.ts
│ └── unique.ts
├── env.ts
├── kernel.ts
└── routes.ts
├── .gitignore
├── .editorconfig
├── database
├── factories
│ ├── profile_factory.ts
│ ├── user_factory.ts
│ ├── cineast_factory.ts
│ └── movie_factory.ts
├── migrations
│ ├── 1709306528164_alter_cast_movies_add_sort_orders_table.ts
│ ├── 1708186268240_create_roles_table.ts
│ ├── 1708187077790_create_movie_statuses_table.ts
│ ├── 1709306923288_create_profiles_table.ts
│ ├── 1715427964298_add_missing_user_profiles_table.ts
│ ├── 1708187108878_create_cineasts_table.ts
│ ├── 1714827034960_create_watchlists_table.ts
│ ├── 1713618707015_create_remember_me_tokens_table.ts
│ ├── 1708186268241_create_users_table.ts
│ ├── 1708187151775_create_cast_movies_table.ts
│ ├── 1708187145074_create_crew_movies_table.ts
│ └── 1708187108879_create_movies_table.ts
├── seeders
│ ├── 00_start_seeder.ts
│ └── 01_fake_seeder.ts
└── data
│ └── movies.ts
├── config
├── static.ts
├── hash.ts
├── database.ts
├── vite.ts
├── auth.ts
├── logger.ts
├── redis.ts
├── shield.ts
├── session.ts
├── app.ts
└── bodyparser.ts
├── vite.config.js
├── ace.js
├── tsconfig.json
├── LICENSE
├── README.md
├── tests
└── bootstrap.ts
├── bin
├── server.ts
├── console.ts
└── test.ts
├── package.json
└── adonisrc.ts
/resources/js/app.js:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/enums/roles.ts:
--------------------------------------------------------------------------------
1 | enum Roles {
2 | USER = 1,
3 | ADMIN = 2,
4 | }
5 |
6 | export default Roles
7 |
--------------------------------------------------------------------------------
/public/db-schema.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adocasts/lets-learn-adonisjs-6/HEAD/public/db-schema.pdf
--------------------------------------------------------------------------------
/public/db-schema.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adocasts/lets-learn-adonisjs-6/HEAD/public/db-schema.png
--------------------------------------------------------------------------------
/storage/avatars/av.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adocasts/lets-learn-adonisjs-6/HEAD/storage/avatars/av.jpg
--------------------------------------------------------------------------------
/storage/posters/idpv90uw9gejwku3zxyqbbnd.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adocasts/lets-learn-adonisjs-6/HEAD/storage/posters/idpv90uw9gejwku3zxyqbbnd.gif
--------------------------------------------------------------------------------
/app/enums/movie_statuses.ts:
--------------------------------------------------------------------------------
1 | enum MovieStatuses {
2 | WRITING = 1,
3 | CASTING = 2,
4 | PRODUCTION = 3,
5 | POST_PRODUCTION = 4,
6 | RELEASED = 5,
7 | }
8 |
9 | export default MovieStatuses
10 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./resources/**/*.{edge,js,ts,jsx,tsx,vue}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/resources/views/pages/errors/not-found.edge:
--------------------------------------------------------------------------------
1 |
404 - Page not found
2 |
3 | This template is rendered by the [status pages
4 | feature](http://docs.adonisjs.com/guides/exception-handling#status-pages) of the global exception handler.
5 |
--------------------------------------------------------------------------------
/resources/views/pages/errors/server-error.edge:
--------------------------------------------------------------------------------
1 | {{ error.code }} - Server error
2 |
3 | This template is rendered by the [status pages
4 | feature](http://docs.adonisjs.com/guides/exception-handling#status-pages) of the global exception handler.
5 |
--------------------------------------------------------------------------------
/resources/views/components/stat/index.edge:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ parseInt(value.$extras.count ?? 0).toLocaleString() }}
4 |
5 |
6 | {{ label }}
7 |
8 |
9 |
--------------------------------------------------------------------------------
/resources/views/pages/directors/show.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | Director {{ director.fullName }}
3 |
4 |
5 | @each (movie in movies)
6 |
7 | @!movie.card({ movie, class: 'w-full' })
8 |
9 | @end
10 |
11 | @end
12 |
--------------------------------------------------------------------------------
/resources/views/pages/directors/index.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | Directors
3 |
4 |
13 | @end
14 |
--------------------------------------------------------------------------------
/resources/views/pages/writers/show.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | Writer {{ writer.fullName }}
3 |
4 |
5 | @each (movie in writer.moviesWritten)
6 |
7 | @!movie.card({ movie, class: 'w-full' })
8 |
9 | @end
10 |
11 | @end
12 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | TZ=UTC
2 | PORT=3333
3 | HOST=0.0.0.0
4 | LOG_LEVEL=info
5 | APP_KEY=o_vO5XfeSh3CqZOM5V5kFOV6UNx5C598
6 | NODE_ENV=development
7 | CACHE_VIEWS=false
8 | SESSION_DRIVER=cookie
9 | REDIS_HOST=127.0.0.1
10 | REDIS_PORT=6379
11 | REDIS_PASSWORD=
12 | DB_HOST=127.0.0.1
13 | DB_PORT=5432
14 | DB_USER=postgres
15 | DB_PASSWORD=
16 | DB_DATABASE=
--------------------------------------------------------------------------------
/start/globals.ts:
--------------------------------------------------------------------------------
1 | import edge from 'edge.js'
2 | import { edgeIconify, addCollection } from 'edge-iconify'
3 | import { icons as phIcons } from '@iconify-json/ph'
4 | import Roles from '#enums/roles'
5 |
6 | addCollection(phIcons)
7 |
8 | edge.use(edgeIconify)
9 |
10 | edge.global('globalExample', 'Global Info')
11 | edge.global('Roles', Roles)
12 |
--------------------------------------------------------------------------------
/resources/views/components/card.edge:
--------------------------------------------------------------------------------
1 | @let(className = 'rounded-lg overflow-hidden border border-slate-200/60 bg-white text-slate-700 shadow-sm mb-3')
2 |
3 |
4 | @if ($slots.picture)
5 | {{{ await $slots.picture() }}}
6 | @endif
7 |
8 | {{{ await $slots.main() }}}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/services/profile_service.ts:
--------------------------------------------------------------------------------
1 | import { inject } from '@adonisjs/core'
2 | import { HttpContext } from '@adonisjs/core/http'
3 |
4 | @inject()
5 | export default class ProfileService {
6 | constructor(protected ctx: HttpContext) {}
7 |
8 | get user() {
9 | return this.ctx.auth.user
10 | }
11 |
12 | async find() {
13 | return this.user!.related('profile').query().firstOrFail()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/validators/profile.ts:
--------------------------------------------------------------------------------
1 | import vine from '@vinejs/vine'
2 | import { fullNameRule } from './auth.js'
3 |
4 | export const profileUpdateValidator = vine.compile(
5 | vine.object({
6 | avatar: vine.file({ extnames: ['jpg', 'png', 'jpeg'], size: '5mb' }).optional(),
7 | avatarUrl: vine.string().optional(),
8 | fullName: fullNameRule,
9 | description: vine.string().optional(),
10 | })
11 | )
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies and AdonisJS build
2 | node_modules
3 | build
4 | tmp
5 |
6 | # Secrets
7 | .env
8 | .env.local
9 | .env.production.local
10 | .env.development.local
11 |
12 | # Frontend assets compiled code
13 | public/assets
14 |
15 | # Build tools specific
16 | npm-debug.log
17 | yarn-error.log
18 |
19 | # Editors specific
20 | .fleet
21 | .idea
22 | .vscode
23 |
24 | # Platform specific
25 | .DS_Store
26 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.json]
12 | insert_final_newline = ignore
13 |
14 | [**.min.js]
15 | indent_style = ignore
16 | insert_final_newline = ignore
17 |
18 | [MakeFile]
19 | indent_style = space
20 |
21 | [*.md]
22 | trim_trailing_whitespace = false
--------------------------------------------------------------------------------
/database/factories/profile_factory.ts:
--------------------------------------------------------------------------------
1 | import factory from '@adonisjs/lucid/factories'
2 | import Profile from '#models/profile'
3 | import { UserFactory } from './user_factory.js'
4 |
5 | export const ProfileFactory = factory
6 | .define(Profile, async ({ faker }) => {
7 | return {
8 | userId: 1,
9 | description: faker.lorem.paragraphs(),
10 | }
11 | })
12 | .relation('user', () => UserFactory)
13 | .build()
14 |
--------------------------------------------------------------------------------
/resources/views/pages/admin/dashboard.edge:
--------------------------------------------------------------------------------
1 | @layout.admin()
2 | Dashboard
3 |
4 |
5 | @!stat({ label: 'Total Movies', value: moviesCount })
6 | @!stat({ label: 'Total Writers', value: writersCount })
7 | @!stat({ label: 'Total Directors', value: directorsCount })
8 | @!stat({ label: 'Movies Watched', value: watchedMoviesCount })
9 |
10 | @end
11 |
--------------------------------------------------------------------------------
/app/controllers/auth/logout_controller.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 |
3 | export default class LogoutController {
4 | async handle({ request, response, auth }: HttpContext) {
5 | await auth.use('web').logout()
6 |
7 | if (request.header('referer', '')?.includes('/admin')) {
8 | return response.redirect().toRoute('auth.login.show')
9 | }
10 |
11 | return response.redirect().back()
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/config/static.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/static'
2 |
3 | /**
4 | * Configuration options to tweak the static files middleware.
5 | * The complete set of options are documented on the
6 | * official documentation website.
7 | *
8 | * https://docs.adonisjs.com/guides/static-assets
9 | */
10 | const staticServerConfig = defineConfig({
11 | enabled: true,
12 | etag: true,
13 | lastModified: true,
14 | dotFiles: 'ignore',
15 | })
16 |
17 | export default staticServerConfig
18 |
--------------------------------------------------------------------------------
/resources/views/pages/writers/index.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | Writers
3 |
4 |
16 | @end
17 |
--------------------------------------------------------------------------------
/app/controllers/redis_controller.ts:
--------------------------------------------------------------------------------
1 | import cache from '#services/cache_service'
2 | import type { HttpContext } from '@adonisjs/core/http'
3 |
4 | export default class RedisController {
5 | async destroy({ response, params }: HttpContext) {
6 | await cache.delete(params.slug)
7 | return response.redirect().back()
8 | }
9 |
10 | async flush({ response }: HttpContext) {
11 | console.log('Flushing Redis Database')
12 | await cache.flushDb()
13 | return response.redirect().back()
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/database/migrations/1709306528164_alter_cast_movies_add_sort_orders_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'cast_movies'
5 |
6 | async up() {
7 | this.schema.alterTable(this.tableName, (table) => {
8 | table.integer('sort_order').notNullable().defaultTo(0)
9 | })
10 | }
11 |
12 | async down() {
13 | this.schema.alterTable(this.tableName, (table) => {
14 | table.dropColumn('sort_order')
15 | })
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/middleware/silent_auth_middleware.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 | import type { NextFn } from '@adonisjs/core/types/http'
3 |
4 | export default class SilentAuthMiddleware {
5 | async handle(ctx: HttpContext, next: NextFn) {
6 | /**
7 | * Middleware logic goes here (before the next call)
8 | */
9 | await ctx.auth.check()
10 |
11 | /**
12 | * Call next method in the pipeline and return its output
13 | */
14 | const output = await next()
15 | return output
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/resources/views/components/button/index.edge:
--------------------------------------------------------------------------------
1 | @let(className = 'inline-flex items-center justify-center w-full h-10 px-4 py-2 text-sm font-medium text-white transition-colors rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none bg-slate-950 hover:bg-slate-950/90')
2 | @let(nodeName = $props.has('href') ? 'a' : 'button')
3 |
4 | <{{ nodeName }} {{ $props.merge({ class: [className] }).toAttrs() }}>
5 | {{{ await $slots.main() }}}
6 | {{ nodeName }}>
7 |
--------------------------------------------------------------------------------
/resources/views/components/layout/index.edge:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title || "Let's Learn AdonisJS 6" }}
7 |
8 | @if ($slots.meta)
9 | {{{ await $slots.meta() }}}
10 | @endif
11 |
12 | @vite(['resources/js/app.js', 'resources/css/app.css'])
13 |
14 |
15 |
16 | @include('partials/nav')
17 |
18 | {{{ await $slots.main() }}}
19 |
20 |
21 |
--------------------------------------------------------------------------------
/database/migrations/1708186268240_create_roles_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'roles'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table.string('name', 50).notNullable()
10 |
11 | table.timestamp('created_at').notNullable()
12 | table.timestamp('updated_at').notNullable()
13 | })
14 | }
15 |
16 | async down() {
17 | this.schema.dropTable(this.tableName)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/database/factories/user_factory.ts:
--------------------------------------------------------------------------------
1 | import factory from '@adonisjs/lucid/factories'
2 | import User from '#models/user'
3 | import Roles from '#enums/roles'
4 | import { ProfileFactory } from './profile_factory.js'
5 |
6 | export const UserFactory = factory
7 | .define(User, async ({ faker }) => {
8 | return {
9 | roleId: Roles.USER,
10 | fullName: faker.person.fullName(),
11 | avatarUrl: faker.image.avatar(),
12 | email: faker.internet.email(),
13 | password: faker.internet.password(),
14 | }
15 | })
16 | .relation('profile', () => ProfileFactory)
17 | .build()
18 |
--------------------------------------------------------------------------------
/config/hash.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, drivers } from '@adonisjs/core/hash'
2 |
3 | const hashConfig = defineConfig({
4 | default: 'scrypt',
5 |
6 | list: {
7 | scrypt: drivers.scrypt({
8 | cost: 16384,
9 | blockSize: 8,
10 | parallelization: 1,
11 | maxMemory: 33554432,
12 | }),
13 | },
14 | })
15 |
16 | export default hashConfig
17 |
18 | /**
19 | * Inferring types for the list of hashers you have configured
20 | * in your application.
21 | */
22 | declare module '@adonisjs/core/types' {
23 | export interface HashersList extends InferHashers {}
24 | }
25 |
--------------------------------------------------------------------------------
/database/migrations/1708187077790_create_movie_statuses_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'movie_statuses'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table.string('name', 50).notNullable()
10 |
11 | table.timestamp('created_at').notNullable()
12 | table.timestamp('updated_at').notNullable()
13 | })
14 | }
15 |
16 | async down() {
17 | this.schema.dropTable(this.tableName)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/app/validators/auth.ts:
--------------------------------------------------------------------------------
1 | import vine from '@vinejs/vine'
2 |
3 | export const fullNameRule = vine.string().maxLength(100).optional()
4 |
5 | export const registerValidator = vine.compile(
6 | vine.object({
7 | fullName: fullNameRule,
8 | email: vine.string().email().toLowerCase().trim().isUnique({ table: 'users', column: 'email' }),
9 | password: vine.string().minLength(8),
10 | })
11 | )
12 |
13 | export const loginValidator = vine.compile(
14 | vine.object({
15 | email: vine.string().email().toLowerCase().trim(),
16 | password: vine.string(),
17 | isRememberMe: vine.accepted().optional(),
18 | })
19 | )
20 |
--------------------------------------------------------------------------------
/config/database.ts:
--------------------------------------------------------------------------------
1 | import env from '#start/env'
2 | import { defineConfig } from '@adonisjs/lucid'
3 |
4 | const dbConfig = defineConfig({
5 | connection: 'postgres',
6 | connections: {
7 | postgres: {
8 | client: 'pg',
9 | connection: {
10 | host: env.get('DB_HOST'),
11 | port: env.get('DB_PORT'),
12 | user: env.get('DB_USER'),
13 | password: env.get('DB_PASSWORD'),
14 | database: env.get('DB_DATABASE'),
15 | },
16 | migrations: {
17 | naturalSort: true,
18 | paths: ['database/migrations'],
19 | },
20 | },
21 | },
22 | })
23 |
24 | export default dbConfig
--------------------------------------------------------------------------------
/database/migrations/1709306923288_create_profiles_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'profiles'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id')
9 | table.integer('user_id').unsigned().references('users.id').notNullable().onDelete('CASCADE')
10 | table.text('description')
11 |
12 | table.timestamp('created_at')
13 | table.timestamp('updated_at')
14 | })
15 | }
16 |
17 | async down() {
18 | this.schema.dropTable(this.tableName)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/models/role.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
3 | import User from './user.js'
4 | import type { HasMany } from '@adonisjs/lucid/types/relations'
5 |
6 | export default class Role extends BaseModel {
7 | @column({ isPrimary: true })
8 | declare id: number
9 |
10 | @column()
11 | declare name: string
12 |
13 | @column.dateTime({ autoCreate: true })
14 | declare createdAt: DateTime
15 |
16 | @column.dateTime({ autoCreate: true, autoUpdate: true })
17 | declare updatedAt: DateTime
18 |
19 | @hasMany(() => User)
20 | declare users: HasMany
21 | }
22 |
--------------------------------------------------------------------------------
/app/services/cache_service.ts:
--------------------------------------------------------------------------------
1 | import redis from '@adonisjs/redis/services/main'
2 |
3 | class CacheService {
4 | async has(...keys: string[]) {
5 | return redis.exists(keys)
6 | }
7 |
8 | async get(key: string) {
9 | const value = await redis.get(key)
10 | return value && JSON.parse(value)
11 | }
12 |
13 | async set(key: string, value: any) {
14 | return redis.set(key, JSON.stringify(value))
15 | }
16 |
17 | async delete(...keys: string[]) {
18 | return redis.del(keys)
19 | }
20 |
21 | async flushDb() {
22 | return redis.flushdb()
23 | }
24 | }
25 |
26 | const cache = new CacheService()
27 | export default cache
28 |
--------------------------------------------------------------------------------
/database/factories/cineast_factory.ts:
--------------------------------------------------------------------------------
1 | import factory from '@adonisjs/lucid/factories'
2 | import Cineast from '#models/cineast'
3 | import { MovieFactory } from './movie_factory.js'
4 |
5 | export const CineastFactory = factory
6 | .define(Cineast, async ({ faker }) => {
7 | return {
8 | firstName: faker.person.firstName(),
9 | lastName: faker.person.lastName(),
10 | headshotUrl: faker.image.avatar(),
11 | }
12 | })
13 | .relation('moviesDirected', () => MovieFactory)
14 | .relation('moviesWritten', () => MovieFactory)
15 | .relation('castMovies', () => MovieFactory)
16 | .relation('castMovies', () => MovieFactory)
17 | .build()
18 |
--------------------------------------------------------------------------------
/app/controllers/storage_controller.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 | import app from '@adonisjs/core/services/app'
3 | import { normalize } from 'node:path'
4 |
5 | const PATH_TRAVERSAL_REGEX = /(?:^|[\\/])\.\.(?:[\\/]|$)/
6 |
7 | export default class StorageController {
8 | async show({ response, params }: HttpContext) {
9 | const filePath = params['*'].join('/')
10 | const normalizedPath = normalize(filePath)
11 |
12 | if (PATH_TRAVERSAL_REGEX.test(normalizedPath)) {
13 | return response.badRequest('Malformed path')
14 | }
15 |
16 | return response.download(app.makePath('storage', normalizedPath))
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/config/vite.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/vite'
2 |
3 | const viteBackendConfig = defineConfig({
4 | /**
5 | * The output of vite will be written inside this
6 | * directory. The path should be relative from
7 | * the application root.
8 | */
9 | buildDirectory: 'public/assets',
10 |
11 | /**
12 | * The path to the manifest file generated by the
13 | * "vite build" command.
14 | */
15 | manifestFile: 'public/assets/.vite/manifest.json',
16 |
17 | /**
18 | * Feel free to change the value of the "assetsUrl" to
19 | * point to a CDN in production.
20 | */
21 | assetsUrl: '/assets',
22 | })
23 |
24 | export default viteBackendConfig
25 |
--------------------------------------------------------------------------------
/app/models/movie_status.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
3 | import Movie from './movie.js'
4 | import type { HasMany } from '@adonisjs/lucid/types/relations'
5 |
6 | export default class MovieStatus extends BaseModel {
7 | @column({ isPrimary: true })
8 | declare id: number
9 |
10 | @column()
11 | declare name: string
12 |
13 | @column.dateTime({ autoCreate: true })
14 | declare createdAt: DateTime
15 |
16 | @column.dateTime({ autoCreate: true, autoUpdate: true })
17 | declare updatedAt: DateTime
18 |
19 | @hasMany(() => Movie, {
20 | foreignKey: 'statusId',
21 | })
22 | declare movies: HasMany
23 | }
24 |
--------------------------------------------------------------------------------
/database/migrations/1715427964298_add_missing_user_profiles_table.ts:
--------------------------------------------------------------------------------
1 | import Profile from '#models/profile'
2 | import User from '#models/user'
3 | import { BaseSchema } from '@adonisjs/lucid/schema'
4 |
5 | export default class extends BaseSchema {
6 | protected tableName = 'add_missing_user_profiles'
7 |
8 | async up() {
9 | // Create profiles for any users missing one
10 | this.defer(async (_db) => {
11 | const users = await User.query()
12 | .whereDoesntHave('profile', (query) => query)
13 | .select('id')
14 | const profiles = users.map((user) => ({ userId: user.id }))
15 |
16 | await Profile.createMany(profiles)
17 | })
18 | }
19 |
20 | async down() {}
21 | }
22 |
--------------------------------------------------------------------------------
/app/models/profile.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
3 | import User from './user.js'
4 | import type { BelongsTo } from '@adonisjs/lucid/types/relations'
5 |
6 | export default class Profile extends BaseModel {
7 | @column({ isPrimary: true })
8 | declare id: number
9 |
10 | @column()
11 | declare userId: number
12 |
13 | @column()
14 | declare description: string | null
15 |
16 | @column.dateTime({ autoCreate: true })
17 | declare createdAt: DateTime
18 |
19 | @column.dateTime({ autoCreate: true, autoUpdate: true })
20 | declare updatedAt: DateTime
21 |
22 | @belongsTo(() => User)
23 | declare user: BelongsTo
24 | }
25 |
--------------------------------------------------------------------------------
/database/migrations/1708187108878_create_cineasts_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'cineasts'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table.string('first_name', 100).notNullable()
10 | table.string('last_name', 100).notNullable()
11 | table.string('headshot_url').notNullable().defaultTo('')
12 |
13 | table.timestamp('created_at').notNullable()
14 | table.timestamp('updated_at').notNullable()
15 | })
16 | }
17 |
18 | async down() {
19 | this.schema.dropTable(this.tableName)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import adonisjs from '@adonisjs/vite/client'
3 | import tailwindcss from 'tailwindcss'
4 | import autoprefixer from 'autoprefixer'
5 |
6 | export default defineConfig({
7 | plugins: [
8 | adonisjs({
9 | /**
10 | * Entrypoints of your application. Each entrypoint will
11 | * result in a separate bundle.
12 | */
13 | entrypoints: ['resources/js/app.js', 'resources/css/app.css'],
14 |
15 | /**
16 | * Paths to watch and reload the browser on file change
17 | */
18 | reload: ['resources/views/**/*.edge'],
19 | }),
20 | ],
21 |
22 | css: {
23 | postcss: {
24 | plugins: [tailwindcss, autoprefixer],
25 | },
26 | },
27 | })
28 |
--------------------------------------------------------------------------------
/ace.js:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | JavaScript entrypoint for running ace commands
4 | |--------------------------------------------------------------------------
5 | |
6 | | Since, we cannot run TypeScript source code using "node" binary, we need
7 | | a JavaScript entrypoint to run ace commands.
8 | |
9 | | This file registers the "ts-node/esm" hook with the Node.js module system
10 | | and then imports the "bin/console.ts" file.
11 | |
12 | */
13 |
14 | /**
15 | * Register hook to process TypeScript files using ts-node
16 | */
17 | import { register } from 'node:module'
18 | register('ts-node/esm', import.meta.url)
19 |
20 | /**
21 | * Import ace console entrypoint
22 | */
23 | await import('./bin/console.js')
24 |
--------------------------------------------------------------------------------
/app/middleware/auth_middleware.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 | import type { NextFn } from '@adonisjs/core/types/http'
3 | import type { Authenticators } from '@adonisjs/auth/types'
4 |
5 | /**
6 | * Auth middleware is used authenticate HTTP requests and deny
7 | * access to unauthenticated users.
8 | */
9 | export default class AuthMiddleware {
10 | /**
11 | * The URL to redirect to, when authentication fails
12 | */
13 | redirectTo = '/auth/login'
14 |
15 | async handle(
16 | ctx: HttpContext,
17 | next: NextFn,
18 | options: {
19 | guards?: (keyof Authenticators)[]
20 | } = {}
21 | ) {
22 | await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
23 | return next()
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/resources/css/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | margin: 0;
7 | padding: 0;
8 | }
9 |
10 | html,
11 | body {
12 | height: 100%;
13 | width: 100%;
14 | }
15 |
16 | h1 {
17 | @apply text-3xl font-bold mb-3 mt-6;
18 | }
19 |
20 | h2 {
21 | @apply text-2xl font-bold mb-2 mt-4;
22 | }
23 |
24 | nav {
25 | @apply flex gap-2 pb-2 border-b border-slate-100;
26 | }
27 |
28 | nav a,
29 | nav button {
30 | @apply px-2 py-1 rounded hover:bg-slate-100 duration-300;
31 | }
32 |
33 | input:not([type='radio']):not([type='checkbox']),
34 | select,
35 | textarea {
36 | @apply border border-slate-300 rounded px-3 py-1.5 focus:border-indigo-500 duration-300;
37 | }
38 |
39 | select {
40 | @apply py-[0.55rem] bg-white;
41 | }
42 |
--------------------------------------------------------------------------------
/app/middleware/container_bindings_middleware.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from '@adonisjs/core/logger'
2 | import { HttpContext } from '@adonisjs/core/http'
3 | import { NextFn } from '@adonisjs/core/types/http'
4 |
5 | /**
6 | * The container bindings middleware binds classes to their request
7 | * specific value using the container resolver.
8 | *
9 | * - We bind "HttpContext" class to the "ctx" object
10 | * - And bind "Logger" class to the "ctx.logger" object
11 | */
12 | export default class ContainerBindingsMiddleware {
13 | async handle(ctx: HttpContext, next: NextFn) {
14 | ctx.containerResolver.bindValue(HttpContext, ctx)
15 | ctx.containerResolver.bindValue(Logger, ctx.logger)
16 |
17 | await next()
18 |
19 | // perform something on the way down the call tree here
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/database/migrations/1714827034960_create_watchlists_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'watchlists'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id')
9 | table.integer('user_id').unsigned().references('users.id').notNullable().onDelete('CASCADE')
10 | table.integer('movie_id').unsigned().references('movies.id').notNullable().onDelete('CASCADE')
11 | table.timestamp('watched_at')
12 | table.timestamp('created_at')
13 | table.timestamp('updated_at')
14 |
15 | table.unique(['user_id', 'movie_id'])
16 | })
17 | }
18 |
19 | async down() {
20 | this.schema.dropTable(this.tableName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/resources/views/pages/profiles/show.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 |
3 | @if (user.avatarUrl)
4 |
5 |
6 |
7 | @endif
8 |
9 |
10 |
{{ user.fullName }}
11 |
12 | @if (user.profile.description)
13 |
{{ user.profile.description }}
14 | @endif
15 |
16 |
17 |
18 | Movies {{ user.fullName }} Has Watched
19 |
20 | @each (movie in movies)
21 |
22 | @!movie.cardProfile({ movie, class: 'w-full' })
23 |
24 | @end
25 |
26 | @end
27 |
--------------------------------------------------------------------------------
/resources/views/components/layout/admin.edge:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ title || "Let's Learn AdonisJS 6" }}
7 |
8 | @if ($slots.meta)
9 | {{{ await $slots.meta() }}}
10 | @endif
11 |
12 | @vite(['resources/js/app.js', 'resources/css/app.css'])
13 |
14 |
15 |
16 | @include('partials/nav')
17 |
18 |
26 |
27 | {{{ await $slots.main() }}}
28 |
29 |
30 |
--------------------------------------------------------------------------------
/app/controllers/auth/login_controller.ts:
--------------------------------------------------------------------------------
1 | import User from '#models/user'
2 | import { loginValidator } from '#validators/auth'
3 | import type { HttpContext } from '@adonisjs/core/http'
4 |
5 | export default class LoginController {
6 | async show({ view }: HttpContext) {
7 | return view.render('pages/auth/login')
8 | }
9 |
10 | async store({ request, response, auth }: HttpContext) {
11 | // 1. Grab our validated data off the request
12 | const { email, password, isRememberMe } = await request.validateUsing(loginValidator)
13 | // 2. Verify the user's credentials
14 | const user = await User.verifyCredentials(email, password)
15 | // 3. Login our user
16 | await auth.use('web').login(user, isRememberMe)
17 | // 4. Return our user back to the home page
18 | return response.redirect().toRoute('home')
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/resources/views/pages/auth/register.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 |
3 |
Register
4 |
5 | @card({ class: 'max-w-md mt-4' })
6 |
7 |
31 |
32 | @end
33 |
34 | @end
35 |
--------------------------------------------------------------------------------
/app/middleware/admin_middleware.ts:
--------------------------------------------------------------------------------
1 | import Roles from '#enums/roles'
2 | import { Exception } from '@adonisjs/core/exceptions'
3 | import type { HttpContext } from '@adonisjs/core/http'
4 | import type { NextFn } from '@adonisjs/core/types/http'
5 |
6 | export default class AdminMiddleware {
7 | async handle(ctx: HttpContext, next: NextFn) {
8 | /**
9 | * Middleware logic goes here (before the next call)
10 | */
11 | const isAdmin = ctx.auth.user?.roleId === Roles.ADMIN
12 |
13 | if (!isAdmin) {
14 | throw new Exception('You are not authorized to perform this action', {
15 | code: 'E_NOT_AUTHORIZED',
16 | status: 401,
17 | })
18 | }
19 |
20 | /**
21 | * Call next method in the pipeline and return its output
22 | */
23 | const output = await next()
24 | return output
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/database/migrations/1713618707015_create_remember_me_tokens_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'remember_me_tokens'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments()
9 | table
10 | .integer('tokenable_id')
11 | .notNullable()
12 | .unsigned()
13 | .references('id')
14 | .inTable('users')
15 | .onDelete('CASCADE')
16 |
17 | table.string('hash').notNullable().unique()
18 | table.timestamp('created_at').notNullable()
19 | table.timestamp('updated_at').notNullable()
20 | table.timestamp('expires_at').notNullable()
21 | })
22 | }
23 |
24 | async down() {
25 | this.schema.dropTable(this.tableName)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/controllers/auth/register_controller.ts:
--------------------------------------------------------------------------------
1 | import User from '#models/user'
2 | import { registerValidator } from '#validators/auth'
3 | import type { HttpContext } from '@adonisjs/core/http'
4 |
5 | export default class RegisterController {
6 | async show({ view }: HttpContext) {
7 | return view.render('pages/auth/register')
8 | }
9 |
10 | async store({ request, response, auth }: HttpContext) {
11 | // 1. Grab our request data and validate it
12 | const data = await request.validateUsing(registerValidator)
13 | // 2. Create our user
14 | const user = await User.create(data)
15 | // 3. Create profile for user
16 | await user.related('profile').create({})
17 | // 4. Login that user
18 | await auth.use('web').login(user)
19 | // 5. Return the user back to home
20 | return response.redirect().toRoute('home')
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/database/migrations/1708186268241_create_users_table.ts:
--------------------------------------------------------------------------------
1 | import Roles from '#enums/roles'
2 | import { BaseSchema } from '@adonisjs/lucid/schema'
3 |
4 | export default class extends BaseSchema {
5 | protected tableName = 'users'
6 |
7 | async up() {
8 | this.schema.createTable(this.tableName, (table) => {
9 | table.increments('id').notNullable()
10 | table.integer('role_id').unsigned().references('roles.id').notNullable().defaultTo(Roles.USER)
11 | table.string('full_name').nullable()
12 | table.string('avatar_url').nullable()
13 | table.string('email', 254).notNullable().unique()
14 | table.string('password').notNullable()
15 |
16 | table.timestamp('created_at').notNullable()
17 | table.timestamp('updated_at').nullable()
18 | })
19 | }
20 |
21 | async down() {
22 | this.schema.dropTable(this.tableName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/models/watchlist.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
3 | import User from './user.js'
4 | import type { BelongsTo } from '@adonisjs/lucid/types/relations'
5 | import Movie from './movie.js'
6 |
7 | export default class Watchlist extends BaseModel {
8 | @column({ isPrimary: true })
9 | declare id: number
10 |
11 | @column()
12 | declare userId: number
13 |
14 | @column()
15 | declare movieId: number
16 |
17 | @column.dateTime()
18 | declare watchedAt: DateTime | null
19 |
20 | @column.dateTime({ autoCreate: true })
21 | declare createdAt: DateTime
22 |
23 | @column.dateTime({ autoCreate: true, autoUpdate: true })
24 | declare updatedAt: DateTime
25 |
26 | @belongsTo(() => User)
27 | declare user: BelongsTo
28 |
29 | @belongsTo(() => Movie)
30 | declare movie: BelongsTo
31 | }
32 |
--------------------------------------------------------------------------------
/config/auth.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/auth'
2 | import { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
3 | import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
4 |
5 | const authConfig = defineConfig({
6 | default: 'web',
7 | guards: {
8 | web: sessionGuard({
9 | useRememberMeTokens: true,
10 | rememberMeTokensAge: '2 years',
11 | provider: sessionUserProvider({
12 | model: () => import('#models/user'),
13 | }),
14 | }),
15 | },
16 | })
17 |
18 | export default authConfig
19 |
20 | /**
21 | * Inferring types from the configured auth
22 | * guards.
23 | */
24 | declare module '@adonisjs/auth/types' {
25 | interface Authenticators extends InferAuthenticators {}
26 | }
27 | declare module '@adonisjs/core/types' {
28 | interface EventsList extends InferAuthEvents {}
29 | }
30 |
--------------------------------------------------------------------------------
/database/migrations/1708187151775_create_cast_movies_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'cast_movies'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table
10 | .integer('cineast_id')
11 | .unsigned()
12 | .references('cineasts.id')
13 | .notNullable()
14 | .onDelete('CASCADE')
15 | table.integer('movie_id').unsigned().references('movies.id').notNullable().onDelete('CASCADE')
16 | table.string('character_name', 200).notNullable().defaultTo('')
17 |
18 | table.timestamp('created_at').notNullable()
19 | table.timestamp('updated_at').notNullable()
20 | })
21 | }
22 |
23 | async down() {
24 | this.schema.dropTable(this.tableName)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/resources/views/components/form/input.edge:
--------------------------------------------------------------------------------
1 | @let(valueOld = name ? old(name) : '')
2 | @let(value = $props.value || valueOld)
3 |
4 |
5 | @if ($slots.label || label)
6 |
7 | @if ($slots.label)
8 | {{{ await $slots.label() }}}
9 | @else
10 | {{ label }}
11 | @endif
12 |
13 | @endif
14 |
15 | @if (type === 'select')
16 |
17 | {{{ await $slots.main() }}}
18 |
19 | @elseif (type === 'textarea')
20 |
21 | @else
22 |
23 | @endif
24 |
25 | @if (name)
26 | @inputError(name)
27 |
28 | {{ $messages.join(', ') }}
29 |
30 | @end
31 | @endif
32 |
33 |
--------------------------------------------------------------------------------
/database/migrations/1708187145074_create_crew_movies_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'crew_movies'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table
10 | .integer('cineast_id')
11 | .unsigned()
12 | .references('cineasts.id')
13 | .notNullable()
14 | .onDelete('CASCADE')
15 | table.integer('movie_id').unsigned().references('movies.id').notNullable().onDelete('CASCADE')
16 | table.string('title', 100).notNullable().defaultTo('')
17 | table.integer('sort_order').notNullable().defaultTo(0)
18 |
19 | table.timestamp('created_at').notNullable()
20 | table.timestamp('updated_at').notNullable()
21 | })
22 | }
23 |
24 | async down() {
25 | this.schema.dropTable(this.tableName)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/app/validators/movie_filter.ts:
--------------------------------------------------------------------------------
1 | import MovieService from '#services/movie_service'
2 | import vine from '@vinejs/vine'
3 |
4 | const sharedMovieFilterSchema = vine.object({
5 | search: vine.string().optional(),
6 | status: vine
7 | .number()
8 | .exists(async (db, value) => {
9 | if (!value) return true
10 | const exists = await db.from('movie_statuses').select('id').where('id', value).first()
11 | return !!exists
12 | })
13 | .optional(),
14 | sort: vine
15 | .string()
16 | .exists(async (_db, value) => {
17 | return MovieService.sortOptions.some((option) => option.id === value)
18 | })
19 | .optional(),
20 | })
21 |
22 | export const movieFilterValidator = vine.compile(sharedMovieFilterSchema)
23 |
24 | export const watchlistFilterValidator = vine.compile(
25 | vine.object({
26 | ...sharedMovieFilterSchema.getProperties(),
27 | watched: vine.enum(['all', 'watched', 'unwatched']).optional(),
28 | })
29 | )
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@adonisjs/tsconfig/tsconfig.app.json",
3 | "compilerOptions": {
4 | "rootDir": "./",
5 | "outDir": "./build",
6 | "paths": {
7 | "#controllers/*": ["./app/controllers/*.js"],
8 | "#enums/*": ["./app/enums/*.js"],
9 | "#exceptions/*": ["./app/exceptions/*.js"],
10 | "#models/*": ["./app/models/*.js"],
11 | "#mails/*": ["./app/mails/*.js"],
12 | "#services/*": ["./app/services/*.js"],
13 | "#listeners/*": ["./app/listeners/*.js"],
14 | "#events/*": ["./app/events/*.js"],
15 | "#middleware/*": ["./app/middleware/*.js"],
16 | "#validators/*": ["./app/validators/*.js"],
17 | "#providers/*": ["./providers/*.js"],
18 | "#policies/*": ["./app/policies/*.js"],
19 | "#abilities/*": ["./app/abilities/*.js"],
20 | "#database/*": ["./database/*.js"],
21 | "#tests/*": ["./tests/*.js"],
22 | "#start/*": ["./start/*.js"],
23 | "#config/*": ["./config/*.js"]
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/middleware/guest_middleware.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 | import type { NextFn } from '@adonisjs/core/types/http'
3 | import type { Authenticators } from '@adonisjs/auth/types'
4 |
5 | /**
6 | * Guest middleware is used to deny access to routes that should
7 | * be accessed by unauthenticated users.
8 | *
9 | * For example, the login page should not be accessible if the user
10 | * is already logged-in
11 | */
12 | export default class GuestMiddleware {
13 | /**
14 | * The URL to redirect to when user is logged-in
15 | */
16 | redirectTo = '/'
17 |
18 | async handle(
19 | ctx: HttpContext,
20 | next: NextFn,
21 | options: { guards?: (keyof Authenticators)[] } = {}
22 | ) {
23 | for (let guard of options.guards || [ctx.auth.defaultGuard]) {
24 | if (await ctx.auth.use(guard).check()) {
25 | return ctx.response.redirect(this.redirectTo, true)
26 | }
27 | }
28 |
29 | await next()
30 |
31 | // after next
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/controllers/admin/dashboard_controller.ts:
--------------------------------------------------------------------------------
1 | import Cineast from '#models/cineast'
2 | import Movie from '#models/movie'
3 | import Watchlist from '#models/watchlist'
4 | import type { HttpContext } from '@adonisjs/core/http'
5 |
6 | export default class DashboardController {
7 | async handle({ view }: HttpContext) {
8 | const moviesCount = await Movie.query().count('id').firstOrFail()
9 | const writersCount = await Cineast.query()
10 | .whereHas('moviesWritten', (query) => query)
11 | .count('id')
12 | .firstOrFail()
13 | const directorsCount = await Cineast.query()
14 | .whereHas('moviesDirected', (query) => query)
15 | .count('id')
16 | .firstOrFail()
17 | const watchedMoviesCount = await Watchlist.query()
18 | .whereNotNull('watchedAt')
19 | .count('id')
20 | .firstOrFail()
21 |
22 | return view.render('pages/admin/dashboard', {
23 | moviesCount,
24 | writersCount,
25 | directorsCount,
26 | watchedMoviesCount,
27 | })
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/app/controllers/directors_controller.ts:
--------------------------------------------------------------------------------
1 | import Cineast from '#models/cineast'
2 | import type { HttpContext } from '@adonisjs/core/http'
3 |
4 | export default class DirectorsController {
5 | async index({ view }: HttpContext) {
6 | const directors = await Cineast.query()
7 | .whereHas('moviesDirected', (query) => query.apply((scope) => scope.released()))
8 | .orderBy([
9 | { column: 'firstName', order: 'asc' },
10 | { column: 'lastName', order: 'asc' },
11 | ])
12 |
13 | return view.render('pages/directors/index', { directors })
14 | }
15 |
16 | async show({ view, params, auth }: HttpContext) {
17 | const director = await Cineast.findOrFail(params.id)
18 | const movies = await director
19 | .related('moviesDirected')
20 | .query()
21 | .if(auth.user, (query) =>
22 | query.preload('watchlist', (watchlist) => watchlist.where('userId', auth.user!.id))
23 | )
24 | .orderBy('title')
25 | return view.render('pages/directors/show', { director, movies })
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/config/logger.ts:
--------------------------------------------------------------------------------
1 | import env from '#start/env'
2 | import app from '@adonisjs/core/services/app'
3 | import { defineConfig, targets } from '@adonisjs/core/logger'
4 |
5 | const loggerConfig = defineConfig({
6 | default: 'app',
7 |
8 | /**
9 | * The loggers object can be used to define multiple loggers.
10 | * By default, we configure only one logger (named "app").
11 | */
12 | loggers: {
13 | app: {
14 | enabled: true,
15 | name: env.get('APP_NAME'),
16 | level: env.get('LOG_LEVEL'),
17 | transport: {
18 | targets: targets()
19 | .pushIf(!app.inProduction, targets.pretty())
20 | .pushIf(app.inProduction, targets.file({ destination: 1 }))
21 | .toArray(),
22 | },
23 | },
24 | },
25 | })
26 |
27 | export default loggerConfig
28 |
29 | /**
30 | * Inferring types for the list of loggers you have configured
31 | * in your application.
32 | */
33 | declare module '@adonisjs/core/types' {
34 | export interface LoggersList extends InferLoggers {}
35 | }
36 |
--------------------------------------------------------------------------------
/app/controllers/home_controller.ts:
--------------------------------------------------------------------------------
1 | import Movie from '#models/movie'
2 | import type { HttpContext } from '@adonisjs/core/http'
3 |
4 | export default class HomeController {
5 | async index({ view, auth }: HttpContext) {
6 | const comingSoon = await Movie.query()
7 | .apply((scope) => scope.notReleased())
8 | .if(auth.user, (query) =>
9 | query.preload('watchlist', (watchlist) => watchlist.where('userId', auth.user!.id))
10 | )
11 | .preload('director')
12 | .preload('writer')
13 | .whereNotNull('releasedAt')
14 | .orderBy('releasedAt')
15 | .limit(3)
16 |
17 | const recentlyReleased = await Movie.query()
18 | .apply((scope) => scope.released())
19 | .if(auth.user, (query) =>
20 | query.preload('watchlist', (watchlist) => watchlist.where('userId', auth.user!.id))
21 | )
22 | .preload('director')
23 | .preload('writer')
24 | .orderBy('releasedAt', 'desc')
25 | .limit(9)
26 |
27 | return view.render('pages/home', { comingSoon, recentlyReleased })
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/resources/views/pages/auth/login.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 |
3 |
Login
4 |
5 | @card({ class: 'max-w-md mt-4' })
6 |
7 |
37 |
38 | @end
39 |
40 | @end
41 |
--------------------------------------------------------------------------------
/resources/views/pages/home.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | @slot('meta')
3 |
4 | @endslot
5 |
6 | Coming Soon
7 |
8 | @each (movie in comingSoon)
9 |
10 | @!movie.card({ movie, class: 'w-full', showWriterDirector: true })
11 |
12 | @end
13 |
14 |
15 | Recently Released
16 |
17 | @each (movie in recentlyReleased)
18 |
19 | @!movie.card({ movie, class: 'w-full', showWriterDirector: true })
20 |
21 | @end
22 |
23 |
24 |
25 |
33 |
34 | @end
35 |
--------------------------------------------------------------------------------
/start/rules/exists.ts:
--------------------------------------------------------------------------------
1 | import type { FieldContext } from '@vinejs/vine/types'
2 | import db from '@adonisjs/lucid/services/db'
3 | import vine from '@vinejs/vine'
4 | import { VineNumber } from '@vinejs/vine'
5 |
6 | type Options = {
7 | table: string
8 | column: string
9 | }
10 |
11 | async function isExists(value: unknown, options: Options, field: FieldContext) {
12 | if (typeof value !== 'string' && typeof value !== 'number') {
13 | return
14 | }
15 |
16 | const result = await db
17 | .from(options.table)
18 | .select(options.column)
19 | .where(options.column, value)
20 | .first()
21 |
22 | if (!result) {
23 | // Report that the value is NOT unique
24 | field.report('Value for {{ field }} does not exist', 'isExists', field)
25 | }
26 | }
27 |
28 | export const isExistsRule = vine.createRule(isExists)
29 |
30 | declare module '@vinejs/vine' {
31 | interface VineNumber {
32 | isExists(options: Options): this
33 | }
34 | }
35 |
36 | VineNumber.macro('isExists', function (this: VineNumber, options: Options) {
37 | return this.use(isExistsRule(options))
38 | })
39 |
--------------------------------------------------------------------------------
/database/migrations/1708187108879_create_movies_table.ts:
--------------------------------------------------------------------------------
1 | import { BaseSchema } from '@adonisjs/lucid/schema'
2 |
3 | export default class extends BaseSchema {
4 | protected tableName = 'movies'
5 |
6 | async up() {
7 | this.schema.createTable(this.tableName, (table) => {
8 | table.increments('id').notNullable()
9 | table.integer('status_id').unsigned().references('movie_statuses.id').notNullable()
10 | table.integer('writer_id').unsigned().references('cineasts.id').notNullable()
11 | table.integer('director_id').unsigned().references('cineasts.id').notNullable()
12 | table.string('title', 100).notNullable()
13 | table.string('slug', 200).notNullable().unique()
14 | table.string('summary').notNullable().defaultTo('')
15 | table.text('abstract')
16 | table.string('poster_url').notNullable().defaultTo('')
17 |
18 | table.timestamp('released_at').nullable()
19 | table.timestamp('created_at').notNullable()
20 | table.timestamp('updated_at').notNullable()
21 | })
22 | }
23 |
24 | async down() {
25 | this.schema.dropTable(this.tableName)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Adocasts
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Let's Learn AdonisJS 6
4 |
5 | In this series, we'll learn AdonisJS 6 step-by-step in a beginner-friendly way.
6 | Covering topics like routing, controllers, services, EdgeJS, Lucid ORM, forms, filtering,
7 | authentication, etc.
8 |
9 | [View Let's Learn AdonisJS 6 Series](https://adocasts.com/series/lets-learn-adonisjs-6)
10 |
11 | [](https://youtube.com/adocasts)
12 | [](https://twitter.com/adocasts)
13 | [](https://twitch.tv/adocasts)
14 |
15 | ### Series Database Diagram
16 |
17 | Below you can find the schema diagram for the database used throughout this series, for reference.
18 | 
19 |
--------------------------------------------------------------------------------
/database/seeders/00_start_seeder.ts:
--------------------------------------------------------------------------------
1 | import Role from '#models/role'
2 | import { BaseSeeder } from '@adonisjs/lucid/seeders'
3 | import Roles from '#enums/roles'
4 | import MovieStatus from '#models/movie_status'
5 | import MovieStatuses from '#enums/movie_statuses'
6 |
7 | export default class extends BaseSeeder {
8 | async run() {
9 | // Write your database queries inside the run method
10 | await Role.createMany([
11 | {
12 | id: Roles.USER,
13 | name: 'User',
14 | },
15 | {
16 | id: Roles.ADMIN,
17 | name: 'Admin',
18 | },
19 | ])
20 |
21 | await MovieStatus.createMany([
22 | {
23 | id: MovieStatuses.WRITING,
24 | name: 'Writing',
25 | },
26 | {
27 | id: MovieStatuses.CASTING,
28 | name: 'Casting',
29 | },
30 | {
31 | id: MovieStatuses.PRODUCTION,
32 | name: 'Production',
33 | },
34 | {
35 | id: MovieStatuses.POST_PRODUCTION,
36 | name: 'Post Production',
37 | },
38 | {
39 | id: MovieStatuses.RELEASED,
40 | name: 'Released',
41 | },
42 | ])
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/config/redis.ts:
--------------------------------------------------------------------------------
1 | import env from '#start/env'
2 | import { defineConfig } from '@adonisjs/redis'
3 | import { InferConnections } from '@adonisjs/redis/types'
4 |
5 | const redisConfig = defineConfig({
6 | connection: 'main',
7 |
8 | connections: {
9 | /*
10 | |--------------------------------------------------------------------------
11 | | The default connection
12 | |--------------------------------------------------------------------------
13 | |
14 | | The main connection you want to use to execute redis commands. The same
15 | | connection will be used by the session provider, if you rely on the
16 | | redis driver.
17 | |
18 | */
19 | main: {
20 | host: env.get('REDIS_HOST'),
21 | port: env.get('REDIS_PORT'),
22 | password: env.get('REDIS_PASSWORD', ''),
23 | db: 1,
24 | keyPrefix: '',
25 | retryStrategy(times) {
26 | return times > 10 ? null : times * 50
27 | },
28 | },
29 | },
30 | })
31 |
32 | export default redisConfig
33 |
34 | declare module '@adonisjs/redis/types' {
35 | export interface RedisConnections extends InferConnections {}
36 | }
37 |
--------------------------------------------------------------------------------
/config/shield.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/shield'
2 |
3 | const shieldConfig = defineConfig({
4 | /**
5 | * Configure CSP policies for your app. Refer documentation
6 | * to learn more
7 | */
8 | csp: {
9 | enabled: false,
10 | directives: {},
11 | reportOnly: false,
12 | },
13 |
14 | /**
15 | * Configure CSRF protection options. Refer documentation
16 | * to learn more
17 | */
18 | csrf: {
19 | enabled: true,
20 | exceptRoutes: [],
21 | enableXsrfCookie: false,
22 | methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
23 | },
24 |
25 | /**
26 | * Control how your website should be embedded inside
27 | * iFrames
28 | */
29 | xFrame: {
30 | enabled: true,
31 | action: 'DENY',
32 | },
33 |
34 | /**
35 | * Force browser to always use HTTPS
36 | */
37 | hsts: {
38 | enabled: true,
39 | maxAge: '180 days',
40 | },
41 |
42 | /**
43 | * Disable browsers from sniffing the content type of a
44 | * response and always rely on the "content-type" header.
45 | */
46 | contentTypeSniffing: {
47 | enabled: true,
48 | },
49 | })
50 |
51 | export default shieldConfig
52 |
--------------------------------------------------------------------------------
/config/session.ts:
--------------------------------------------------------------------------------
1 | import env from '#start/env'
2 | import app from '@adonisjs/core/services/app'
3 | import { defineConfig, stores } from '@adonisjs/session'
4 |
5 | const sessionConfig = defineConfig({
6 | enabled: true,
7 | cookieName: 'adonis-session',
8 |
9 | /**
10 | * When set to true, the session id cookie will be deleted
11 | * once the user closes the browser.
12 | */
13 | clearWithBrowser: false,
14 |
15 | /**
16 | * Define how long to keep the session data alive without
17 | * any activity.
18 | */
19 | age: '2h',
20 |
21 | /**
22 | * Configuration for session cookie and the
23 | * cookie store
24 | */
25 | cookie: {
26 | path: '/',
27 | httpOnly: true,
28 | secure: app.inProduction,
29 | sameSite: 'lax',
30 | },
31 |
32 | /**
33 | * The store to use. Make sure to validate the environment
34 | * variable in order to infer the store name without any
35 | * errors.
36 | */
37 | store: env.get('SESSION_DRIVER'),
38 |
39 | /**
40 | * List of configured stores. Refer documentation to see
41 | * list of available stores and their config.
42 | */
43 | stores: {
44 | cookie: stores.cookie(),
45 | },
46 | })
47 |
48 | export default sessionConfig
49 |
--------------------------------------------------------------------------------
/config/app.ts:
--------------------------------------------------------------------------------
1 | import env from '#start/env'
2 | import app from '@adonisjs/core/services/app'
3 | import { Secret } from '@adonisjs/core/helpers'
4 | import { defineConfig } from '@adonisjs/core/http'
5 |
6 | /**
7 | * The app key is used for encrypting cookies, generating signed URLs,
8 | * and by the "encryption" module.
9 | *
10 | * The encryption module will fail to decrypt data if the key is lost or
11 | * changed. Therefore it is recommended to keep the app key secure.
12 | */
13 | export const appKey = new Secret(env.get('APP_KEY'))
14 |
15 | /**
16 | * The configuration settings used by the HTTP server
17 | */
18 | export const http = defineConfig({
19 | generateRequestId: true,
20 | allowMethodSpoofing: true,
21 |
22 | /**
23 | * Enabling async local storage will let you access HTTP context
24 | * from anywhere inside your application.
25 | */
26 | useAsyncLocalStorage: false,
27 |
28 | /**
29 | * Manage cookies configuration. The settings for the session id cookie are
30 | * defined inside the "config/session.ts" file.
31 | */
32 | cookie: {
33 | domain: '',
34 | path: '/',
35 | maxAge: '2h',
36 | httpOnly: true,
37 | secure: app.inProduction,
38 | sameSite: 'lax',
39 | },
40 | })
41 |
--------------------------------------------------------------------------------
/app/controllers/writers_controller.ts:
--------------------------------------------------------------------------------
1 | import Cineast from '#models/cineast'
2 | import type { HttpContext } from '@adonisjs/core/http'
3 |
4 | export default class WritersController {
5 | async index({ view }: HttpContext) {
6 | const writers = await Cineast.query()
7 | .whereHas('moviesWritten', (query) => query)
8 | .withCount('moviesWritten', (query) =>
9 | query.apply((scope) => scope.released()).as('releasedCount')
10 | )
11 | .withCount('moviesWritten', (query) =>
12 | query.apply((scope) => scope.notReleased()).as('notReleasedCount')
13 | )
14 | .orderBy([
15 | { column: 'firstName', order: 'asc' },
16 | { column: 'lastName', order: 'asc' },
17 | ])
18 |
19 | return view.render('pages/writers/index', { writers })
20 | }
21 |
22 | async show({ view, params, auth }: HttpContext) {
23 | const writer = await Cineast.query()
24 | .where({ id: params.id })
25 | .preload('moviesWritten', (written) =>
26 | written.if(auth.user, (query) =>
27 | query.preload('watchlist', (watchlist) => watchlist.where('userId', auth.user!.id))
28 | )
29 | )
30 | .firstOrFail()
31 |
32 | return view.render('pages/writers/show', { writer })
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/resources/views/partials/nav.edge:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
10 | @if (auth.user)
11 |
33 | @else
34 |
Login
35 |
Register
36 | @endif
37 |
38 |
39 |
--------------------------------------------------------------------------------
/tests/bootstrap.ts:
--------------------------------------------------------------------------------
1 | import { assert } from '@japa/assert'
2 | import app from '@adonisjs/core/services/app'
3 | import type { Config } from '@japa/runner/types'
4 | import { pluginAdonisJS } from '@japa/plugin-adonisjs'
5 | import testUtils from '@adonisjs/core/services/test_utils'
6 |
7 | /**
8 | * This file is imported by the "bin/test.ts" entrypoint file
9 | */
10 |
11 | /**
12 | * Configure Japa plugins in the plugins array.
13 | * Learn more - https://japa.dev/docs/runner-config#plugins-optional
14 | */
15 | export const plugins: Config['plugins'] = [assert(), pluginAdonisJS(app)]
16 |
17 | /**
18 | * Configure lifecycle function to run before and after all the
19 | * tests.
20 | *
21 | * The setup functions are executed before all the tests
22 | * The teardown functions are executer after all the tests
23 | */
24 | export const runnerHooks: Required> = {
25 | setup: [],
26 | teardown: [],
27 | }
28 |
29 | /**
30 | * Configure suites by tapping into the test suite instance.
31 | * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
32 | */
33 | export const configureSuite: Config['configureSuite'] = (suite) => {
34 | if (['browser', 'functional', 'e2e'].includes(suite.name)) {
35 | return suite.setup(() => testUtils.httpServer().start())
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/start/rules/unique.ts:
--------------------------------------------------------------------------------
1 | import type { FieldContext } from "@vinejs/vine/types";
2 | import db from '@adonisjs/lucid/services/db'
3 | import vine from '@vinejs/vine'
4 | import { VineString, VineNumber } from "@vinejs/vine";
5 |
6 | type Options = {
7 | table: string
8 | column: string
9 | }
10 |
11 | async function isUnique(value: unknown, options: Options, field: FieldContext) {
12 | if (typeof value !== 'string' && typeof value !== 'number') {
13 | return
14 | }
15 |
16 | const result = await db
17 | .from(options.table)
18 | .select(options.column)
19 | .where(options.column, value)
20 | .first()
21 |
22 | if (result) {
23 | // Report that the value is NOT unique
24 | field.report('This {{ field }} is already taken', 'isUnique', field)
25 | }
26 | }
27 |
28 | export const isUniqueRule = vine.createRule(isUnique)
29 |
30 | declare module '@vinejs/vine' {
31 | interface VineString {
32 | isUnique(options: Options): this
33 | }
34 |
35 | interface VineNumber {
36 | isUnique(options: Options): this
37 | }
38 | }
39 |
40 | VineString.macro('isUnique', function (this: VineString, options: Options) {
41 | return this.use(isUniqueRule(options))
42 | })
43 |
44 | VineNumber.macro('isUnique', function (this: VineNumber, options: Options) {
45 | return this.use(isUniqueRule(options))
46 | })
47 |
--------------------------------------------------------------------------------
/database/factories/movie_factory.ts:
--------------------------------------------------------------------------------
1 | import factory from '@adonisjs/lucid/factories'
2 | import Movie from '#models/movie'
3 | import MovieStatuses from '#enums/movie_statuses'
4 | import { DateTime } from 'luxon'
5 | import { CineastFactory } from './cineast_factory.js'
6 |
7 | export const MovieFactory = factory
8 | .define(Movie, async ({ faker }) => {
9 | return {
10 | statusId: MovieStatuses.WRITING,
11 | title: faker.music.songName(),
12 | summary: faker.lorem.sentence(),
13 | abstract: faker.lorem.paragraphs(),
14 | posterUrl: faker.image.urlPicsumPhotos(),
15 | releasedAt: null,
16 | }
17 | })
18 | .state('released', (row, { faker }) => {
19 | row.statusId = MovieStatuses.RELEASED
20 | row.releasedAt = DateTime.fromJSDate(faker.date.past())
21 | })
22 | .state('releasingSoon', (row, { faker }) => {
23 | row.statusId = MovieStatuses.RELEASED
24 | row.releasedAt = DateTime.fromJSDate(faker.date.soon())
25 | })
26 | .state('postProduction', (row, { faker }) => {
27 | row.statusId = MovieStatuses.POST_PRODUCTION
28 | row.releasedAt = DateTime.fromJSDate(faker.date.soon())
29 | })
30 | .relation('director', () => CineastFactory)
31 | .relation('writer', () => CineastFactory)
32 | .relation('castMembers', () => CineastFactory)
33 | .relation('crewMembers', () => CineastFactory)
34 | .build()
35 |
--------------------------------------------------------------------------------
/app/validators/movie.ts:
--------------------------------------------------------------------------------
1 | import vine from '@vinejs/vine'
2 | import { DateTime } from 'luxon'
3 |
4 | export const movieValidator = vine.compile(
5 | vine.object({
6 | poster: vine.file({ extnames: ['png', 'jpg', 'jpeg', 'gif'], size: '5mb' }).optional(),
7 | posterUrl: vine.string().optional(),
8 | title: vine.string().maxLength(100),
9 | summary: vine
10 | .string()
11 | .maxLength(255)
12 | .nullable()
13 | .transform((value) => (value ? value : '')),
14 | abstract: vine.string(),
15 | writerId: vine.number().isExists({ table: 'cineasts', column: 'id' }),
16 | directorId: vine.number().isExists({ table: 'cineasts', column: 'id' }),
17 | statusId: vine.number().isExists({ table: 'movie_statuses', column: 'id' }),
18 | releasedAt: vine
19 | .date()
20 | .nullable()
21 | .transform((value) => {
22 | if (!value) return
23 | return DateTime.fromJSDate(value)
24 | }),
25 | crew: vine
26 | .array(
27 | vine.object({
28 | id: vine.number().isExists({ table: 'cineasts', column: 'id' }),
29 | title: vine.string(),
30 | })
31 | )
32 | .optional(),
33 | cast: vine
34 | .array(
35 | vine.object({
36 | id: vine.number().isExists({ table: 'cineasts', column: 'id' }),
37 | character_name: vine.string(),
38 | })
39 | )
40 | .optional(),
41 | })
42 | )
43 |
--------------------------------------------------------------------------------
/bin/server.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | HTTP server entrypoint
4 | |--------------------------------------------------------------------------
5 | |
6 | | The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
7 | | server. Either you can run this file directly or use the "serve"
8 | | command to run this file and monitor file changes
9 | |
10 | */
11 |
12 | import 'reflect-metadata'
13 | import { Ignitor, prettyPrintError } from '@adonisjs/core'
14 |
15 | /**
16 | * URL to the application root. AdonisJS need it to resolve
17 | * paths to file and directories for scaffolding commands
18 | */
19 | const APP_ROOT = new URL('../', import.meta.url)
20 |
21 | /**
22 | * The importer is used to import files in context of the
23 | * application.
24 | */
25 | const IMPORTER = (filePath: string) => {
26 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
27 | return import(new URL(filePath, APP_ROOT).href)
28 | }
29 | return import(filePath)
30 | }
31 |
32 | new Ignitor(APP_ROOT, { importer: IMPORTER })
33 | .tap((app) => {
34 | app.booting(async () => {
35 | await import('#start/env')
36 | })
37 | app.listen('SIGTERM', () => app.terminate())
38 | app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
39 | })
40 | .httpServer()
41 | .start()
42 | .catch((error) => {
43 | process.exitCode = 1
44 | prettyPrintError(error)
45 | })
46 |
--------------------------------------------------------------------------------
/app/models/cineast.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { BaseModel, column, computed, hasMany, manyToMany } from '@adonisjs/lucid/orm'
3 | import Movie from './movie.js'
4 | import type { HasMany, ManyToMany } from '@adonisjs/lucid/types/relations'
5 |
6 | export default class Cineast extends BaseModel {
7 | serializeExtras = true
8 |
9 | @column({ isPrimary: true })
10 | declare id: number
11 |
12 | @column()
13 | declare firstName: string
14 |
15 | @column()
16 | declare lastName: string
17 |
18 | @column()
19 | declare headshotUrl: string
20 |
21 | @column.dateTime({ autoCreate: true })
22 | declare createdAt: DateTime
23 |
24 | @column.dateTime({ autoCreate: true, autoUpdate: true })
25 | declare updatedAt: DateTime
26 |
27 | @computed()
28 | get fullName() {
29 | return `${this.firstName} ${this.lastName}`
30 | }
31 |
32 | @hasMany(() => Movie, {
33 | foreignKey: 'directorId',
34 | })
35 | declare moviesDirected: HasMany
36 |
37 | @hasMany(() => Movie, {
38 | foreignKey: 'writerId',
39 | })
40 | declare moviesWritten: HasMany
41 |
42 | @manyToMany(() => Movie, {
43 | pivotTable: 'crew_movies',
44 | pivotTimestamps: true,
45 | })
46 | declare crewMovies: ManyToMany
47 |
48 | @manyToMany(() => Movie, {
49 | pivotTable: 'cast_movies',
50 | pivotTimestamps: true,
51 | pivotColumns: ['character_name', 'sort_order'],
52 | })
53 | declare castMovies: ManyToMany
54 | }
55 |
--------------------------------------------------------------------------------
/bin/console.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | Ace entry point
4 | |--------------------------------------------------------------------------
5 | |
6 | | The "console.ts" file is the entrypoint for booting the AdonisJS
7 | | command-line framework and executing commands.
8 | |
9 | | Commands do not boot the application, unless the currently running command
10 | | has "options.startApp" flag set to true.
11 | |
12 | */
13 |
14 | import 'reflect-metadata'
15 | import { Ignitor, prettyPrintError } from '@adonisjs/core'
16 |
17 | /**
18 | * URL to the application root. AdonisJS need it to resolve
19 | * paths to file and directories for scaffolding commands
20 | */
21 | const APP_ROOT = new URL('../', import.meta.url)
22 |
23 | /**
24 | * The importer is used to import files in context of the
25 | * application.
26 | */
27 | const IMPORTER = (filePath: string) => {
28 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
29 | return import(new URL(filePath, APP_ROOT).href)
30 | }
31 | return import(filePath)
32 | }
33 |
34 | new Ignitor(APP_ROOT, { importer: IMPORTER })
35 | .tap((app) => {
36 | app.booting(async () => {
37 | await import('#start/env')
38 | })
39 | app.listen('SIGTERM', () => app.terminate())
40 | app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
41 | })
42 | .ace()
43 | .handle(process.argv.splice(2))
44 | .catch((error) => {
45 | process.exitCode = 1
46 | prettyPrintError(error)
47 | })
48 |
--------------------------------------------------------------------------------
/config/bodyparser.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/core/bodyparser'
2 |
3 | const bodyParserConfig = defineConfig({
4 | /**
5 | * The bodyparser middleware will parse the request body
6 | * for the following HTTP methods.
7 | */
8 | allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
9 |
10 | /**
11 | * Config for the "application/x-www-form-urlencoded"
12 | * content-type parser
13 | */
14 | form: {
15 | convertEmptyStringsToNull: true,
16 | types: ['application/x-www-form-urlencoded'],
17 | },
18 |
19 | /**
20 | * Config for the JSON parser
21 | */
22 | json: {
23 | convertEmptyStringsToNull: true,
24 | types: [
25 | 'application/json',
26 | 'application/json-patch+json',
27 | 'application/vnd.api+json',
28 | 'application/csp-report',
29 | ],
30 | },
31 |
32 | /**
33 | * Config for the "multipart/form-data" content-type parser.
34 | * File uploads are handled by the multipart parser.
35 | */
36 | multipart: {
37 | /**
38 | * Enabling auto process allows bodyparser middleware to
39 | * move all uploaded files inside the tmp folder of your
40 | * operating system
41 | */
42 | autoProcess: true,
43 | convertEmptyStringsToNull: true,
44 | processManually: [],
45 |
46 | /**
47 | * Maximum limit of data to parse including all files
48 | * and fields
49 | */
50 | limit: '20mb',
51 | types: ['multipart/form-data'],
52 | },
53 | })
54 |
55 | export default bodyParserConfig
56 |
--------------------------------------------------------------------------------
/resources/views/pages/movies/index.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | @slot('meta')
3 |
4 | @endslot
5 |
6 |
37 |
38 | All Movies
39 |
40 | @each (movie in movies)
41 |
42 | @!movie.card({ movie, class: 'w-full', showWriterDirector: true, showStatus: true })
43 |
44 | @end
45 |
46 |
47 | @!pagination({ paginator: movies })
48 | @end
49 |
--------------------------------------------------------------------------------
/start/env.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | Environment variables service
4 | |--------------------------------------------------------------------------
5 | |
6 | | The `Env.create` method creates an instance of the Env service. The
7 | | service validates the environment variables and also cast values
8 | | to JavaScript data types.
9 | |
10 | */
11 |
12 | import { Env } from '@adonisjs/core/env'
13 |
14 | export default await Env.create(new URL('../', import.meta.url), {
15 | NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
16 | PORT: Env.schema.number(),
17 | APP_KEY: Env.schema.string(),
18 | HOST: Env.schema.string({ format: 'host' }),
19 | LOG_LEVEL: Env.schema.string(),
20 | CACHE_VIEWS: Env.schema.boolean(),
21 |
22 | /*
23 | |----------------------------------------------------------
24 | | Variables for configuring session package
25 | |----------------------------------------------------------
26 | */
27 | SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),
28 |
29 | REDIS_HOST: Env.schema.string({ format: 'host' }),
30 | REDIS_PORT: Env.schema.number(),
31 |
32 | /*
33 | |----------------------------------------------------------
34 | | Variables for configuring database connection
35 | |----------------------------------------------------------
36 | */
37 | DB_HOST: Env.schema.string({ format: 'host' }),
38 | DB_PORT: Env.schema.number(),
39 | DB_USER: Env.schema.string(),
40 | DB_PASSWORD: Env.schema.string.optional(),
41 | DB_DATABASE: Env.schema.string(),
42 | })
43 |
--------------------------------------------------------------------------------
/app/controllers/movies_controller.ts:
--------------------------------------------------------------------------------
1 | import type { HttpContext } from '@adonisjs/core/http'
2 | import Movie from '#models/movie'
3 | import MovieStatus from '#models/movie_status'
4 | import MovieService from '#services/movie_service'
5 | import { movieFilterValidator } from '#validators/movie_filter'
6 | import router from '@adonisjs/core/services/router'
7 |
8 | export default class MoviesController {
9 | async index({ request, view, auth }: HttpContext) {
10 | const page = request.input('page', 1)
11 | const filters = await movieFilterValidator.validate(request.qs())
12 | const movies = await MovieService.getFiltered(filters, auth.user).paginate(page, 15)
13 | const movieStatuses = await MovieStatus.query().orderBy('name').select('id', 'name')
14 | const movieSortOptions = MovieService.sortOptions
15 |
16 | movies.baseUrl(router.makeUrl('movies.index'))
17 | movies.queryString(filters)
18 |
19 | return view.render('pages/movies/index', {
20 | movies,
21 | movieStatuses,
22 | movieSortOptions,
23 | filters,
24 | })
25 | }
26 |
27 | async show({ view, params }: HttpContext) {
28 | const movie = await Movie.findByOrFail('slug', params.slug)
29 | const cast = await movie.related('castMembers').query().orderBy('pivot_sort_order')
30 | const crew = await movie
31 | .related('crewMembers')
32 | .query()
33 | .pivotColumns(['title', 'sort_order'])
34 | .orderBy('pivot_sort_order')
35 |
36 | await movie.load('director')
37 | await movie.load('writer')
38 |
39 | return view.render('pages/movies/show', { movie, cast, crew })
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/models/user.ts:
--------------------------------------------------------------------------------
1 | import { DateTime } from 'luxon'
2 | import { withAuthFinder } from '@adonisjs/auth'
3 | import hash from '@adonisjs/core/services/hash'
4 | import { compose } from '@adonisjs/core/helpers'
5 | import { BaseModel, belongsTo, column, hasMany, hasOne } from '@adonisjs/lucid/orm'
6 | import Profile from './profile.js'
7 | import type { BelongsTo, HasMany, HasOne } from '@adonisjs/lucid/types/relations'
8 | import Role from './role.js'
9 | import { DbRememberMeTokensProvider } from '@adonisjs/auth/session'
10 | import Watchlist from './watchlist.js'
11 |
12 | const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
13 | uids: ['email'],
14 | passwordColumnName: 'password',
15 | })
16 |
17 | export default class User extends compose(BaseModel, AuthFinder) {
18 | static rememberMeTokens = DbRememberMeTokensProvider.forModel(User)
19 |
20 | @column({ isPrimary: true })
21 | declare id: number
22 |
23 | @column()
24 | declare roleId: number
25 |
26 | @column()
27 | declare fullName: string | null
28 |
29 | @column()
30 | declare avatarUrl: string | null
31 |
32 | @column()
33 | declare email: string
34 |
35 | @column()
36 | declare password: string
37 |
38 | @column.dateTime({ autoCreate: true })
39 | declare createdAt: DateTime
40 |
41 | @column.dateTime({ autoCreate: true, autoUpdate: true })
42 | declare updatedAt: DateTime | null
43 |
44 | @hasOne(() => Profile)
45 | declare profile: HasOne
46 |
47 | @belongsTo(() => Role)
48 | declare role: BelongsTo
49 |
50 | @hasMany(() => Watchlist)
51 | declare watchlist: HasMany
52 | }
53 |
--------------------------------------------------------------------------------
/start/kernel.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | HTTP kernel file
4 | |--------------------------------------------------------------------------
5 | |
6 | | The HTTP kernel file is used to register the middleware with the server
7 | | or the router.
8 | |
9 | */
10 |
11 | import router from '@adonisjs/core/services/router'
12 | import server from '@adonisjs/core/services/server'
13 |
14 | /**
15 | * The error handler is used to convert an exception
16 | * to a HTTP response.
17 | */
18 | server.errorHandler(() => import('#exceptions/handler'))
19 |
20 | /**
21 | * The server middleware stack runs middleware on all the HTTP
22 | * requests, even if there is no route registered for
23 | * the request URL.
24 | */
25 | server.use([
26 | () => import('#middleware/container_bindings_middleware'),
27 | () => import('@adonisjs/static/static_middleware'),
28 | ])
29 |
30 | /**
31 | * The router middleware stack runs middleware on all the HTTP
32 | * requests with a registered route.
33 | */
34 | router.use([
35 | () => import('@adonisjs/core/bodyparser_middleware'),
36 | () => import('@adonisjs/session/session_middleware'),
37 | () => import('@adonisjs/shield/shield_middleware'),
38 | () => import('@adonisjs/auth/initialize_auth_middleware'),
39 | () => import('#middleware/silent_auth_middleware')
40 | ])
41 |
42 | /**
43 | * Named middleware collection must be explicitly assigned to
44 | * the routes or the routes group.
45 | */
46 | export const middleware = router.named({
47 | admin: () => import('#middleware/admin_middleware'),
48 | guest: () => import('#middleware/guest_middleware'),
49 | auth: () => import('#middleware/auth_middleware')
50 | })
51 |
--------------------------------------------------------------------------------
/resources/views/components/movie/card_profile.edge:
--------------------------------------------------------------------------------
1 | @card({ ...$props.except(['movie']) })
2 | @slot('picture')
3 |
4 |
5 |
6 | @endslot
7 |
8 |
9 | {{ movie.title }}
10 |
11 |
12 | @if (showStatus)
13 |
14 |
15 |
Status:
16 |
17 | {{ movie.status.name }}
18 |
19 |
20 |
21 | @endif
22 |
23 | @if (showWriterDirector)
24 |
25 |
33 |
41 |
42 | @else
43 |
44 | {{ movie.summary }}
45 |
46 | @endif
47 |
48 |
49 | Watched At: {{ movie.watchlist.at(0).watchedAt.toFormat('MMM dd, yyyy HH:mm') }}
50 |
51 |
52 |
53 | @if ($slots.action)
54 | {{{ await $slots.action() }}}
55 | @endif
56 |
57 | @button({ href: route('movies.show', { slug: movie.slug }) })
58 | View Movie
59 | @end
60 |
61 | @end
62 |
--------------------------------------------------------------------------------
/resources/views/components/pagination/index.edge:
--------------------------------------------------------------------------------
1 | @let(rangeMin = paginator.currentPage - 3)
2 | @let(rangeMax = paginator.currentPage + 3)
3 | @let(pagination = paginator.getUrlsForRange(1, paginator.lastPage).filter((item) => {
4 | return item.page >= rangeMin && item.page <= rangeMax
5 | }))
6 |
7 |
8 | @if (paginator.hasPages)
9 |
12 | <<
13 |
14 | @endif
15 |
16 | @if (paginator.currentPage > paginator.firstPage)
17 |
20 | <
21 |
22 | @endif
23 |
24 | @each(item in pagination)
25 |
35 | {{ item.page }}
36 |
37 | @endeach
38 |
39 | @if (paginator.currentPage < paginator.lastPage)
40 |
43 | >
44 |
45 | @endif
46 |
47 | @if (paginator.hasPages)
48 |
51 | >>
52 |
53 | @endif
54 |
55 |
--------------------------------------------------------------------------------
/resources/views/pages/profiles/edit.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 |
3 |
Edit Your Profile
4 |
5 | @card({ class: 'max-w-md mt-4' })
6 |
7 |
53 |
54 | @end
55 |
56 | @end
57 |
--------------------------------------------------------------------------------
/app/exceptions/handler.ts:
--------------------------------------------------------------------------------
1 | import app from '@adonisjs/core/services/app'
2 | import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
3 | import { StatusPageRange, StatusPageRenderer } from '@adonisjs/http-server/types'
4 |
5 | export default class HttpExceptionHandler extends ExceptionHandler {
6 | /**
7 | * In debug mode, the exception handler will display verbose errors
8 | * with pretty printed stack traces.
9 | */
10 | protected debug = !app.inProduction
11 |
12 | /**
13 | * Status pages are used to display a custom HTML pages for certain error
14 | * codes. You might want to enable them in production only, but feel
15 | * free to enable them in development as well.
16 | */
17 | protected renderStatusPages = app.inProduction
18 |
19 | /**
20 | * Status pages is a collection of error code range and a callback
21 | * to return the HTML contents to send as a response.
22 | */
23 | protected statusPages: Record = {
24 | '404': (error, { view }) => {
25 | return view.render('errors/not-found', { error })
26 | },
27 | '500..599': (error, { view }) => {
28 | return view.render('errors/server-error', { error })
29 | },
30 | }
31 |
32 | /**
33 | * The method is used for handling errors and returning
34 | * response to the client
35 | */
36 | async handle(error: unknown, ctx: HttpContext) {
37 | return super.handle(error, ctx)
38 | }
39 |
40 | /**
41 | * The method is used to report error to the logging service or
42 | * the a third party error monitoring service.
43 | *
44 | * @note You should not attempt to send a response from this method.
45 | */
46 | async report(error: unknown, ctx: HttpContext) {
47 | return super.report(error, ctx)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/resources/views/pages/movies/show.edge:
--------------------------------------------------------------------------------
1 | @layout({ title: movie.title })
2 | @slot('meta')
3 |
4 | @endslot
5 |
6 | {{ movie.title }}
7 |
8 |
9 |
17 |
25 |
26 |
27 | @if (movie.abstract)
28 |
29 | {{{ movie.abstract }}}
30 |
31 | @else
32 | No Abstract
33 | @endif
34 |
35 |
36 |
37 |
Cast
38 |
39 | @each (cineast in cast)
40 |
41 |
{{ cineast.$extras.pivot_character_name }}
42 | {{ cineast.fullName }}
43 |
44 | @endeach
45 |
46 |
47 |
48 |
49 |
Crew
50 |
51 | @each (cineast in crew)
52 |
53 |
{{ cineast.fullName }}
54 | {{ cineast.$extras.pivot_title }}
55 |
56 | @endeach
57 |
58 |
59 |
60 | @end
61 |
--------------------------------------------------------------------------------
/resources/views/components/movie/card.edge:
--------------------------------------------------------------------------------
1 | @card({ ...$props.except(['movie']) })
2 | @slot('picture')
3 |
4 |
5 |
6 | @endslot
7 |
8 |
9 | {{ movie.title }}
10 |
11 |
12 | @if (showStatus)
13 |
14 |
15 |
Status:
16 |
17 | {{ movie.status.name }}
18 |
19 |
20 |
21 | @endif
22 |
23 | @if (showWriterDirector)
24 |
25 |
33 |
41 |
42 | @else
43 |
44 | {{ movie.summary }}
45 |
46 | @endif
47 |
48 |
49 | @if ($slots.action)
50 | {{{ await $slots.action() }}}
51 | @elseif (auth.user && movie.watchlist)
52 |
59 | @endif
60 |
61 | @button({ href: route('movies.show', { slug: movie.slug }) })
62 | View Movie
63 | @end
64 |
65 | @end
66 |
--------------------------------------------------------------------------------
/bin/test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | Test runner entrypoint
4 | |--------------------------------------------------------------------------
5 | |
6 | | The "test.ts" file is the entrypoint for running tests using Japa.
7 | |
8 | | Either you can run this file directly or use the "test"
9 | | command to run this file and monitor file changes.
10 | |
11 | */
12 |
13 | process.env.NODE_ENV = 'test'
14 |
15 | import 'reflect-metadata'
16 | import { Ignitor, prettyPrintError } from '@adonisjs/core'
17 | import { configure, processCLIArgs, run } from '@japa/runner'
18 |
19 | /**
20 | * URL to the application root. AdonisJS need it to resolve
21 | * paths to file and directories for scaffolding commands
22 | */
23 | const APP_ROOT = new URL('../', import.meta.url)
24 |
25 | /**
26 | * The importer is used to import files in context of the
27 | * application.
28 | */
29 | const IMPORTER = (filePath: string) => {
30 | if (filePath.startsWith('./') || filePath.startsWith('../')) {
31 | return import(new URL(filePath, APP_ROOT).href)
32 | }
33 | return import(filePath)
34 | }
35 |
36 | new Ignitor(APP_ROOT, { importer: IMPORTER })
37 | .tap((app) => {
38 | app.booting(async () => {
39 | await import('#start/env')
40 | })
41 | app.listen('SIGTERM', () => app.terminate())
42 | app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
43 | })
44 | .testRunner()
45 | .configure(async (app) => {
46 | const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
47 |
48 | processCLIArgs(process.argv.splice(2))
49 | configure({
50 | ...app.rcFile.tests,
51 | ...config,
52 | ...{
53 | setup: runnerHooks.setup,
54 | teardown: runnerHooks.teardown.concat([() => app.terminate()]),
55 | },
56 | })
57 | })
58 | .run(() => run())
59 | .catch((error) => {
60 | process.exitCode = 1
61 | prettyPrintError(error)
62 | })
63 |
--------------------------------------------------------------------------------
/resources/views/pages/admin/movies/index.edge:
--------------------------------------------------------------------------------
1 | @layout.admin()
2 |
3 |
Movies
4 |
5 |
6 | @button({ href: route('admin.movies.create') })
7 | Create Movie
8 | @end
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Title
17 | Status
18 | Writer/Director
19 | Count
20 |
21 |
22 |
23 |
24 | @each(movie in movies)
25 |
26 | {{ movie.title }}
27 | {{ movie.status.name }}
28 |
29 | Writer: {{ movie.writer.fullName }}
30 | Director: {{ movie.director.fullName }}
31 |
32 |
33 | Cast Members: {{ movie.$extras.castMembers_count }}
34 | Crew Members: {{ movie.$extras.crewMembers_count }}
35 |
36 |
37 | Edit
38 |
46 |
47 |
48 | @endeach
49 |
50 |
51 |
52 |
53 | @!pagination({ paginator: movies })
54 | @end
55 |
--------------------------------------------------------------------------------
/app/controllers/watchlists_controller.ts:
--------------------------------------------------------------------------------
1 | import MovieStatus from '#models/movie_status'
2 | import Watchlist from '#models/watchlist'
3 | import MovieService from '#services/movie_service'
4 | import { watchlistFilterValidator } from '#validators/movie_filter'
5 | import type { HttpContext } from '@adonisjs/core/http'
6 | import router from '@adonisjs/core/services/router'
7 | import { DateTime } from 'luxon'
8 |
9 | export default class WatchlistsController {
10 | async index({ view, request, auth }: HttpContext) {
11 | const page = request.input('page', 1)
12 | const filters = await watchlistFilterValidator.validate(request.qs())
13 | const movies = await MovieService.getFiltered(filters, auth.user)
14 | .whereHas('watchlist', (query) =>
15 | query
16 | .where('userId', auth.user!.id)
17 | .if(filters.watched === 'watched', (watchlist) => watchlist.whereNotNull('watchedAt'))
18 | .if(filters.watched === 'unwatched', (watchlist) => watchlist.whereNull('watchedAt'))
19 | )
20 | .paginate(page, 15)
21 | const movieStatuses = await MovieStatus.query().orderBy('name').select('id', 'name')
22 | const movieSortOptions = MovieService.sortOptions
23 |
24 | movies.baseUrl(router.makeUrl('watchlists.index'))
25 | movies.queryString(filters)
26 |
27 | return view.render('pages/watchlist', {
28 | movies,
29 | movieStatuses,
30 | movieSortOptions,
31 | filters,
32 | })
33 | }
34 |
35 | async toggle({ response, params, auth }: HttpContext) {
36 | const { movieId } = params
37 | const userId = auth.user!.id
38 | const watchlist = await Watchlist.query().where({ movieId, userId }).first()
39 |
40 | if (watchlist) {
41 | await watchlist.delete()
42 | } else {
43 | await Watchlist.create({ movieId, userId })
44 | }
45 |
46 | return response.redirect().back()
47 | }
48 |
49 | async toggleWatched({ response, params, auth }: HttpContext) {
50 | const { movieId } = params
51 | const userId = auth.user!.id
52 | const watchlist = await Watchlist.query().where({ movieId, userId }).firstOrFail()
53 |
54 | if (watchlist.watchedAt) {
55 | watchlist.watchedAt = null
56 | } else {
57 | watchlist.watchedAt = DateTime.now()
58 | }
59 |
60 | await watchlist.save()
61 |
62 | return response.redirect().withQs().back()
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/app/controllers/profiles_controller.ts:
--------------------------------------------------------------------------------
1 | import Movie from '#models/movie'
2 | import User from '#models/user'
3 | import ProfileService from '#services/profile_service'
4 | import { profileUpdateValidator } from '#validators/profile'
5 | import { inject } from '@adonisjs/core'
6 | import type { HttpContext } from '@adonisjs/core/http'
7 | import app from '@adonisjs/core/services/app'
8 | import db from '@adonisjs/lucid/services/db'
9 | import { unlink } from 'node:fs/promises'
10 |
11 | @inject()
12 | export default class ProfilesController {
13 | constructor(protected profileService: ProfileService) {}
14 |
15 | async show({ view, params }: HttpContext) {
16 | const user = await User.findOrFail(params.id)
17 | const movies = await Movie.query()
18 | .whereHas('watchlist', (query) => query.where('userId', user.id).whereNotNull('watched_at'))
19 | .preload('watchlist', (query) => query.where('userId', user.id))
20 | .join('watchlists', 'watchlists.movie_id', 'movies.id')
21 | .where('watchlists.user_id', user.id)
22 | .orderBy('watchlists.watched_at', 'desc')
23 | .select('movies.*')
24 |
25 | await user.load('profile')
26 |
27 | return view.render('pages/profiles/show', { user, movies })
28 | }
29 |
30 | async edit({ view }: HttpContext) {
31 | const profile = await this.profileService.find()
32 | return view.render('pages/profiles/edit', { profile })
33 | }
34 |
35 | async update({ request, response, session, auth }: HttpContext) {
36 | const { fullName, description, avatar, avatarUrl } =
37 | await request.validateUsing(profileUpdateValidator)
38 | const trx = await db.transaction()
39 |
40 | auth.user!.useTransaction(trx)
41 |
42 | try {
43 | const profile = await this.profileService.find()
44 |
45 | if (avatar) {
46 | await avatar.move(app.makePath('storage/avatars'))
47 | auth.user!.avatarUrl = `/storage/avatars/${avatar.fileName}`
48 | } else if (!avatarUrl && auth.user?.avatarUrl) {
49 | await unlink(app.makePath('.', auth.user.avatarUrl))
50 | auth.user!.avatarUrl = null
51 | }
52 |
53 | await auth.user!.merge({ fullName }).save()
54 | await profile.merge({ description }).save()
55 |
56 | await trx.commit()
57 | } catch (error) {
58 | await trx.rollback()
59 | session.flash('errorsBag.form', 'Something went wrong')
60 | }
61 |
62 | return response.redirect().back()
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/resources/views/pages/watchlist.edge:
--------------------------------------------------------------------------------
1 | @layout()
2 | @slot('meta')
3 |
4 | @endslot
5 |
6 |
49 |
50 | Movies In Your Watchlist
51 |
52 | @each (movie in movies)
53 |
54 | @movie.card({ movie, class: 'w-full', showWriterDirector: true, showStatus: true })
55 | @slot('action')
56 | @let(watchlist = movie.watchlist.at(0))
57 |
64 | @endslot
65 | @end
66 |
67 | @end
68 |
69 |
70 | @!pagination({ paginator: movies })
71 | @end
72 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "lets-learn-adonisjs-6",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "license": "UNLICENSED",
7 | "scripts": {
8 | "start": "node bin/server.js",
9 | "build": "node ace build",
10 | "dev": "node ace serve --watch",
11 | "test": "node ace test",
12 | "lint": "eslint .",
13 | "format": "prettier --write .",
14 | "typecheck": "tsc --noEmit"
15 | },
16 | "imports": {
17 | "#controllers/*": "./app/controllers/*.js",
18 | "#enums/*": "./app/enums/*.js",
19 | "#exceptions/*": "./app/exceptions/*.js",
20 | "#models/*": "./app/models/*.js",
21 | "#mails/*": "./app/mails/*.js",
22 | "#services/*": "./app/services/*.js",
23 | "#listeners/*": "./app/listeners/*.js",
24 | "#events/*": "./app/events/*.js",
25 | "#middleware/*": "./app/middleware/*.js",
26 | "#validators/*": "./app/validators/*.js",
27 | "#providers/*": "./providers/*.js",
28 | "#policies/*": "./app/policies/*.js",
29 | "#abilities/*": "./app/abilities/*.js",
30 | "#database/*": "./database/*.js",
31 | "#tests/*": "./tests/*.js",
32 | "#start/*": "./start/*.js",
33 | "#config/*": "./config/*.js"
34 | },
35 | "devDependencies": {
36 | "@adonisjs/assembler": "^7.2.3",
37 | "@adonisjs/eslint-config": "^1.3.0",
38 | "@adonisjs/prettier-config": "^1.3.0",
39 | "@adonisjs/tsconfig": "^1.3.0",
40 | "@japa/assert": "^2.1.0",
41 | "@japa/plugin-adonisjs": "^3.0.0",
42 | "@japa/runner": "^3.1.1",
43 | "@swc/core": "^1.3.104",
44 | "@types/luxon": "^3.4.2",
45 | "@types/node": "^20.11.28",
46 | "autoprefixer": "^10.4.18",
47 | "eslint": "^8.57.0",
48 | "pino-pretty": "^10.3.1",
49 | "postcss": "^8.4.35",
50 | "prettier": "^3.2.5",
51 | "tailwindcss": "^3.4.1",
52 | "ts-node": "^10.9.2",
53 | "typescript": "^5.4.2",
54 | "vite": "^5.1.6"
55 | },
56 | "dependencies": {
57 | "@adonisjs/auth": "^9.1.1",
58 | "@adonisjs/core": "^6.3.1",
59 | "@adonisjs/lucid": "^20.4.0",
60 | "@adonisjs/redis": "^8.0.1",
61 | "@adonisjs/session": "^7.1.1",
62 | "@adonisjs/shield": "^8.1.1",
63 | "@adonisjs/static": "^1.1.1",
64 | "@adonisjs/vite": "^2.0.2",
65 | "@dimerapp/markdown": "^8.0.1",
66 | "@iconify-json/ph": "^1.1.11",
67 | "@vinejs/vine": "^1.8.0",
68 | "better-sqlite3": "^9.3.0",
69 | "edge-iconify": "^2.0.1",
70 | "edge.js": "^6.0.1",
71 | "luxon": "^3.4.4",
72 | "pg": "^8.11.3",
73 | "reflect-metadata": "^0.2.1"
74 | },
75 | "eslintConfig": {
76 | "extends": "@adonisjs/eslint-config/app"
77 | },
78 | "prettier": "@adonisjs/prettier-config"
79 | }
80 |
--------------------------------------------------------------------------------
/adonisrc.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@adonisjs/core/app'
2 |
3 | export default defineConfig({
4 | /*
5 | |--------------------------------------------------------------------------
6 | | Commands
7 | |--------------------------------------------------------------------------
8 | |
9 | | List of ace commands to register from packages. The application commands
10 | | will be scanned automatically from the "./commands" directory.
11 | |
12 | */
13 | commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands')],
14 |
15 | /*
16 | |--------------------------------------------------------------------------
17 | | Service providers
18 | |--------------------------------------------------------------------------
19 | |
20 | | List of service providers to import and register when booting the
21 | | application
22 | |
23 | */
24 | providers: [
25 | () => import('@adonisjs/core/providers/app_provider'),
26 | () => import('@adonisjs/core/providers/hash_provider'),
27 | {
28 | file: () => import('@adonisjs/core/providers/repl_provider'),
29 | environment: ['repl', 'test'],
30 | },
31 | () => import('@adonisjs/core/providers/vinejs_provider'),
32 | () => import('@adonisjs/core/providers/edge_provider'),
33 | () => import('@adonisjs/session/session_provider'),
34 | () => import('@adonisjs/vite/vite_provider'),
35 | () => import('@adonisjs/shield/shield_provider'),
36 | () => import('@adonisjs/static/static_provider'),
37 | () => import('@adonisjs/lucid/database_provider'),
38 | () => import('@adonisjs/auth/auth_provider'),
39 | () => import('@adonisjs/redis/redis_provider'),
40 | ],
41 |
42 | /*
43 | |--------------------------------------------------------------------------
44 | | Preloads
45 | |--------------------------------------------------------------------------
46 | |
47 | | List of modules to import before starting the application.
48 | |
49 | */
50 | preloads: [
51 | () => import('#start/routes'),
52 | () => import('#start/kernel'),
53 | () => import('#start/globals'),
54 | () => import('#start/rules/unique'),
55 | () => import('#start/rules/exists'),
56 | ],
57 |
58 | /*
59 | |--------------------------------------------------------------------------
60 | | Tests
61 | |--------------------------------------------------------------------------
62 | |
63 | | List of test suites to organize tests by their type. Feel free to remove
64 | | and add additional suites.
65 | |
66 | */
67 | tests: {
68 | suites: [
69 | {
70 | files: ['tests/unit/**/*.spec(.ts|.js)'],
71 | name: 'unit',
72 | timeout: 2000,
73 | },
74 | {
75 | files: ['tests/functional/**/*.spec(.ts|.js)'],
76 | name: 'functional',
77 | timeout: 30000,
78 | },
79 | ],
80 | forceExit: false,
81 | },
82 | metaFiles: [
83 | {
84 | pattern: 'resources/views/**/*.edge',
85 | reloadServer: false,
86 | },
87 | {
88 | pattern: 'public/**',
89 | reloadServer: false,
90 | },
91 | ],
92 | })
93 |
--------------------------------------------------------------------------------
/app/services/movie_service.ts:
--------------------------------------------------------------------------------
1 | import Movie from '#models/movie'
2 | import { Infer } from '@vinejs/vine/types'
3 | import { movieFilterValidator } from '#validators/movie_filter'
4 | import User from '#models/user'
5 | import Cineast from '#models/cineast'
6 | import MovieStatus from '#models/movie_status'
7 | import { MultipartFile } from '@adonisjs/core/bodyparser'
8 | import { cuid } from '@adonisjs/core/helpers'
9 | import app from '@adonisjs/core/services/app'
10 | import { movieValidator } from '#validators/movie'
11 |
12 | type MovieSortOption = {
13 | id: string
14 | text: string
15 | field: string
16 | dir: 'asc' | 'desc' | undefined
17 | }
18 |
19 | export default class MovieService {
20 | static sortOptions: MovieSortOption[] = [
21 | { id: 'title_asc', text: 'Title (asc)', field: 'title', dir: 'asc' },
22 | { id: 'title_desc', text: 'Title (desc)', field: 'title', dir: 'desc' },
23 | { id: 'releasedAt_asc', text: 'Release Date (asc)', field: 'releasedAt', dir: 'asc' },
24 | { id: 'releasedAt_desc', text: 'Release Date (desc)', field: 'releasedAt', dir: 'desc' },
25 | { id: 'writer_asc', text: 'Writer Name (asc)', field: 'cineasts.last_name', dir: 'asc' },
26 | { id: 'writer_desc', text: 'Writer Name (desc)', field: 'cineasts.last_name', dir: 'desc' },
27 | ]
28 |
29 | static getFiltered(filters: Infer, user: User | undefined) {
30 | const sort =
31 | this.sortOptions.find((option) => option.id === filters.sort) || this.sortOptions[0]
32 |
33 | return Movie.query()
34 | .if(filters.search, (query) => query.whereILike('title', `%${filters.search}%`))
35 | .if(filters.status, (query) => query.where('statusId', filters.status!))
36 | .if(['writer_asc', 'writer_desc'].includes(sort.id), (query) => {
37 | query.join('cineasts', 'cineasts.id', 'writer_id').select('movies.*')
38 | })
39 | .if(user, (query) =>
40 | query.preload('watchlist', (watchlist) => watchlist.where('userId', user!.id))
41 | )
42 | .preload('director')
43 | .preload('writer')
44 | .preload('status')
45 | .orderBy(sort.field, sort.dir)
46 | }
47 |
48 | static async getFormData() {
49 | const statuses = await MovieStatus.query().orderBy('name')
50 | const cineasts = await Cineast.query().orderBy('lastName')
51 | return { statuses, cineasts }
52 | }
53 |
54 | static async storePoster(poster: MultipartFile) {
55 | const fileName = `${cuid()}.${poster.extname}`
56 |
57 | await poster.move(app.makePath('storage/posters'), {
58 | name: fileName,
59 | })
60 |
61 | return `/storage/posters/${fileName}`
62 | }
63 |
64 | static async syncCastAndCrew(
65 | movie: Movie,
66 | cast: Infer['cast'],
67 | crew: Infer['crew']
68 | ) {
69 | const crewMembers = crew?.reduce>(
70 | (acc, row, index) => {
71 | acc[row.id] = { title: row.title, sort_order: index }
72 | return acc
73 | },
74 | {}
75 | )
76 |
77 | const castMembers = cast?.reduce<
78 | Record
79 | >((acc, row, index) => {
80 | acc[row.id] = { character_name: row.character_name, sort_order: index }
81 | return acc
82 | }, {})
83 |
84 | await movie.related('crewMembers').sync(crewMembers ?? [])
85 | await movie.related('castMembers').sync(castMembers ?? [])
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/app/controllers/admin/movies_controller.ts:
--------------------------------------------------------------------------------
1 | import Movie from '#models/movie'
2 | import MovieService from '#services/movie_service'
3 | import { movieValidator } from '#validators/movie'
4 | import type { HttpContext } from '@adonisjs/core/http'
5 | import app from '@adonisjs/core/services/app'
6 | import router from '@adonisjs/core/services/router'
7 | import db from '@adonisjs/lucid/services/db'
8 | import { unlink } from 'node:fs/promises'
9 |
10 | export default class MoviesController {
11 | /**
12 | * Display a list of resource
13 | */
14 | async index({ request, view }: HttpContext) {
15 | const page = request.input('page', 1)
16 | const movies = await Movie.query()
17 | .preload('status')
18 | .preload('director')
19 | .preload('writer')
20 | .withCount('castMembers')
21 | .withCount('crewMembers')
22 | .orderBy('updatedAt', 'desc')
23 | .paginate(page, 30)
24 |
25 | movies.baseUrl(router.makeUrl('admin.movies.index'))
26 |
27 | return view.render('pages/admin/movies/index', { movies })
28 | }
29 |
30 | /**
31 | * Display form to create a new record
32 | */
33 | async create({ view }: HttpContext) {
34 | const data = await MovieService.getFormData()
35 | return view.render('pages/admin/movies/createOrEdit', data)
36 | }
37 |
38 | /**
39 | * Handle form submission for the create action
40 | */
41 | async store({ request, response }: HttpContext) {
42 | const { poster, cast, crew, ...data } = await request.validateUsing(movieValidator)
43 |
44 | if (poster) {
45 | data.posterUrl = await MovieService.storePoster(poster)
46 | }
47 |
48 | await db.transaction(async (trx) => {
49 | const movie = await Movie.create(data, { client: trx })
50 | await MovieService.syncCastAndCrew(movie, cast, crew)
51 | })
52 |
53 | return response.redirect().toRoute('admin.movies.index')
54 | }
55 |
56 | /**
57 | * Show individual record
58 | */
59 | async show({ params }: HttpContext) {}
60 |
61 | /**
62 | * Edit individual record
63 | */
64 | async edit({ view, params }: HttpContext) {
65 | const movie = await Movie.findOrFail(params.id)
66 | const data = await MovieService.getFormData()
67 | const crewMembers = await db
68 | .from('crew_movies')
69 | .where('movie_id', movie.id)
70 | .orderBy('sort_order')
71 |
72 | const castMembers = await db
73 | .from('cast_movies')
74 | .where('movie_id', movie.id)
75 | .orderBy('sort_order')
76 |
77 | return view.render('pages/admin/movies/createOrEdit', {
78 | ...data,
79 | movie,
80 | crewMembers,
81 | castMembers,
82 | })
83 | }
84 |
85 | /**
86 | * Handle form submission for the edit action
87 | */
88 | async update({ params, request, response }: HttpContext) {
89 | const { poster, crew, cast, ...data } = await request.validateUsing(movieValidator)
90 | const movie = await Movie.findOrFail(params.id)
91 |
92 | if (poster) {
93 | data.posterUrl = await MovieService.storePoster(poster)
94 | } else if (!data.posterUrl && movie.posterUrl) {
95 | await unlink(app.makePath('.', movie.posterUrl))
96 | data.posterUrl = ''
97 | }
98 |
99 | await db.transaction(async (trx) => {
100 | movie.useTransaction(trx)
101 | await movie.merge(data).save()
102 | await MovieService.syncCastAndCrew(movie, cast, crew)
103 | })
104 |
105 | return response.redirect().toRoute('admin.movies.index')
106 | }
107 |
108 | /**
109 | * Delete record
110 | */
111 | async destroy({ response, params }: HttpContext) {
112 | const movie = await Movie.findOrFail(params.id)
113 |
114 | await movie.delete()
115 |
116 | return response.redirect().back()
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/start/routes.ts:
--------------------------------------------------------------------------------
1 | /*
2 | |--------------------------------------------------------------------------
3 | | Routes file
4 | |--------------------------------------------------------------------------
5 | |
6 | | The routes file is used for defining the HTTP routes.
7 | |
8 | */
9 |
10 | import router from '@adonisjs/core/services/router'
11 | import { middleware } from './kernel.js'
12 | const AdminDashboardController = () => import('#controllers/admin/dashboard_controller')
13 | const AdminMoviesController = () => import('#controllers/admin/movies_controller')
14 | const StorageController = () => import('#controllers/storage_controller')
15 | const ProfilesController = () => import('#controllers/profiles_controller')
16 | const WatchlistsController = () => import('#controllers/watchlists_controller')
17 | const HomeController = () => import('#controllers/home_controller')
18 | const LogoutController = () => import('#controllers/auth/logout_controller')
19 | const WritersController = () => import('#controllers/writers_controller')
20 | const RegisterController = () => import('#controllers/auth/register_controller')
21 | const LoginController = () => import('#controllers/auth/login_controller')
22 | const DirectorsController = () => import('#controllers/directors_controller')
23 | const MoviesController = () => import('#controllers/movies_controller')
24 | const RedisController = () => import('#controllers/redis_controller')
25 |
26 | router.get('/', [HomeController, 'index']).as('home')
27 |
28 | router.get('/storage/*', [StorageController, 'show']).as('storage.show')
29 |
30 | router.get('/movies', [MoviesController, 'index']).as('movies.index')
31 |
32 | router
33 | .get('/movies/:slug', [MoviesController, 'show'])
34 | .as('movies.show')
35 | .where('slug', router.matchers.slug())
36 |
37 | router
38 | .group(() => {
39 | router.get('/watchlist', [WatchlistsController, 'index']).as('index')
40 | router.post('/watchlists/:movieId/toggle', [WatchlistsController, 'toggle']).as('toggle')
41 | router
42 | .post('/watchlists/:movieId/toggle-watched', [WatchlistsController, 'toggleWatched'])
43 | .as('toggle.watched')
44 | })
45 | .as('watchlists')
46 | .use(middleware.auth())
47 |
48 | router.get('/directors', [DirectorsController, 'index']).as('directors.index')
49 | router.get('/directors/:id', [DirectorsController, 'show']).as('directors.show')
50 |
51 | router.get('/writers', [WritersController, 'index']).as('writers.index')
52 | router.get('/writers/:id', [WritersController, 'show']).as('writers.show')
53 |
54 | router.delete('/redis/flush', [RedisController, 'flush']).as('redis.flush')
55 | router.delete('/redis/:slug', [RedisController, 'destroy']).as('redis.destroy')
56 |
57 | router.get('/profile/edit', [ProfilesController, 'edit']).as('profiles.edit').use(middleware.auth())
58 | router.put('/profiles', [ProfilesController, 'update']).as('profiles.update').use(middleware.auth())
59 | router.get('/profiles/:id', [ProfilesController, 'show']).as('profiles.show')
60 |
61 | router
62 | .group(() => {
63 | router
64 | .get('/register', [RegisterController, 'show'])
65 | .as('register.show')
66 | .use(middleware.guest())
67 |
68 | router
69 | .post('/register', [RegisterController, 'store'])
70 | .as('register.store')
71 | .use(middleware.guest())
72 |
73 | router.get('/login', [LoginController, 'show']).as('login.show').use(middleware.guest())
74 | router.post('/login', [LoginController, 'store']).as('login.store').use(middleware.guest())
75 |
76 | router.post('/logout', [LogoutController, 'handle']).as('logout').use(middleware.auth())
77 | })
78 | .prefix('/auth')
79 | .as('auth')
80 |
81 | router
82 | .group(() => {
83 | router.get('/', [AdminDashboardController, 'handle']).as('dashboard')
84 |
85 | router.resource('movies', AdminMoviesController)
86 | })
87 | .prefix('/admin')
88 | .as('admin')
89 | .use(middleware.admin())
90 |
--------------------------------------------------------------------------------
/app/models/movie.ts:
--------------------------------------------------------------------------------
1 | import MovieStatuses from '#enums/movie_statuses'
2 | import {
3 | BaseModel,
4 | beforeCreate,
5 | belongsTo,
6 | column,
7 | hasMany,
8 | manyToMany,
9 | scope,
10 | } from '@adonisjs/lucid/orm'
11 | import { DateTime } from 'luxon'
12 | import string from '@adonisjs/core/helpers/string'
13 | import MovieStatus from './movie_status.js'
14 | import type { BelongsTo, HasMany, ManyToMany } from '@adonisjs/lucid/types/relations'
15 | import Cineast from './cineast.js'
16 | import Watchlist from './watchlist.js'
17 |
18 | export default class Movie extends BaseModel {
19 | @column({ isPrimary: true })
20 | declare id: number
21 |
22 | @column()
23 | declare statusId: number
24 |
25 | @column()
26 | declare writerId: number
27 |
28 | @column()
29 | declare directorId: number
30 |
31 | @column()
32 | declare title: string
33 |
34 | @column()
35 | declare slug: string
36 |
37 | @column()
38 | declare summary: string
39 |
40 | @column()
41 | declare abstract: string
42 |
43 | @column()
44 | declare posterUrl: string
45 |
46 | @column.dateTime()
47 | declare releasedAt: DateTime | null
48 |
49 | @column.dateTime({ autoCreate: true })
50 | declare createdAt: DateTime
51 |
52 | @column.dateTime({ autoCreate: true, autoUpdate: true })
53 | declare updatedAt: DateTime
54 |
55 | @belongsTo(() => MovieStatus, {
56 | foreignKey: 'statusId',
57 | })
58 | declare status: BelongsTo
59 |
60 | @belongsTo(() => Cineast, {
61 | foreignKey: 'directorId',
62 | })
63 | declare director: BelongsTo
64 |
65 | @belongsTo(() => Cineast, {
66 | foreignKey: 'writerId',
67 | })
68 | declare writer: BelongsTo
69 |
70 | @hasMany(() => Watchlist)
71 | declare watchlist: HasMany
72 |
73 | @manyToMany(() => Cineast, {
74 | pivotTable: 'crew_movies',
75 | pivotTimestamps: true,
76 | })
77 | declare crewMembers: ManyToMany
78 |
79 | @manyToMany(() => Cineast, {
80 | pivotTable: 'cast_movies',
81 | pivotTimestamps: true,
82 | pivotColumns: ['character_name', 'sort_order'],
83 | })
84 | declare castMembers: ManyToMany
85 |
86 | static released = scope((query) => {
87 | query.where((group) =>
88 | group
89 | .where('statusId', MovieStatuses.RELEASED)
90 | .whereNotNull('releasedAt')
91 | .where('releasedAt', '<=', DateTime.now().toSQL())
92 | )
93 | })
94 |
95 | static notReleased = scope((query) => {
96 | query.where((group) =>
97 | group
98 | .whereNot('statusId', MovieStatuses.RELEASED)
99 | .orWhereNull('releasedAt')
100 | .orWhere('releasedAt', '>', DateTime.now().toSQL())
101 | )
102 | })
103 |
104 | @beforeCreate()
105 | static async slugify(movie: Movie) {
106 | if (movie.slug) return
107 |
108 | const slug = string.slug(movie.title, {
109 | replacement: '-',
110 | lower: true,
111 | strict: true,
112 | })
113 |
114 | const rows = await Movie.query()
115 | .select('slug')
116 | .whereRaw('lower(??) = ?', ['slug', slug])
117 | .orWhereRaw('lower(??) like ?', ['slug', `${slug}-%`])
118 |
119 | if (!rows.length) {
120 | movie.slug = slug
121 | return
122 | }
123 |
124 | const incrementors = rows.reduce((result, row) => {
125 | const tokens = row.slug.toLowerCase().split(`${slug}-`)
126 |
127 | if (tokens.length < 2) {
128 | return result
129 | }
130 |
131 | const increment = Number(tokens.at(1))
132 |
133 | if (!Number.isNaN(increment)) {
134 | result.push(increment)
135 | }
136 |
137 | return result
138 | }, [])
139 |
140 | const increment = incrementors.length ? Math.max(...incrementors) + 1 : 1
141 |
142 | movie.slug = `${slug}-${increment}`
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/database/seeders/01_fake_seeder.ts:
--------------------------------------------------------------------------------
1 | import { movies } from '#database/data/movies'
2 | import { CineastFactory } from '#database/factories/cineast_factory'
3 | import { MovieFactory } from '#database/factories/movie_factory'
4 | import { UserFactory } from '#database/factories/user_factory'
5 | import MovieStatuses from '#enums/movie_statuses'
6 | import Cineast from '#models/cineast'
7 | import Movie from '#models/movie'
8 | import { BaseSeeder } from '@adonisjs/lucid/seeders'
9 | import { ModelObject } from '@adonisjs/lucid/types/model'
10 | import { DateTime } from 'luxon'
11 |
12 | export default class extends BaseSeeder {
13 | static environment = ['development']
14 | titles: string[] = [
15 | 'Camera Operator',
16 | 'Art Director',
17 | 'Hair & Makeup',
18 | 'Production Manager',
19 | 'Wardrobe',
20 | 'Line Producer',
21 | 'Sound Mixer',
22 | 'Cinematographer',
23 | 'Gaffer',
24 | ]
25 |
26 | async run() {
27 | // Write your database queries inside the run method
28 | const cineasts = await CineastFactory.createMany(100)
29 | await UserFactory.with('profile').createMany(5)
30 | await this.#createMovies(cineasts)
31 | }
32 |
33 | async #createMovies(cineasts: Cineast[]) {
34 | let index = 0
35 | let movieRecords = await MovieFactory.tap((row, { faker }) => {
36 | const movie = movies[index]
37 | const released = DateTime.now().set({ year: movie.releaseYear })
38 |
39 | row.statusId = MovieStatuses.RELEASED
40 | row.directorId = cineasts.at(Math.floor(Math.random() * cineasts.length))!.id
41 | row.writerId = cineasts.at(Math.floor(Math.random() * cineasts.length))!.id
42 | row.title = movie.title
43 | row.releasedAt = DateTime.fromJSDate(
44 | faker.date.between({
45 | from: released.startOf('year').toJSDate(),
46 | to: released.endOf('year').toJSDate(),
47 | })
48 | )
49 |
50 | index++
51 | }).createMany(movies.length)
52 |
53 | movieRecords = movieRecords.concat(
54 | await MovieFactory.with('director').with('writer').createMany(3)
55 | )
56 |
57 | movieRecords = movieRecords.concat(
58 | await MovieFactory.with('director').with('writer').apply('released').createMany(2)
59 | )
60 |
61 | movieRecords = movieRecords.concat(
62 | await MovieFactory.with('director').with('writer').apply('releasingSoon').createMany(2)
63 | )
64 |
65 | movieRecords = movieRecords.concat(
66 | await MovieFactory.with('director').with('writer').apply('postProduction').createMany(2)
67 | )
68 |
69 | const promises = movieRecords.map(async (movie) => {
70 | await this.#attachRandomCastMembers(movie, cineasts, 4)
71 | return this.#attachRandomCrewMembers(movie, cineasts, 3)
72 | })
73 |
74 | await Promise.all(promises)
75 | }
76 |
77 | async #attachRandomCrewMembers(movie: Movie, cineasts: Cineast[], number: number) {
78 | const ids = this.#getRandom(cineasts, number).map(({ id }) => id)
79 |
80 | return movie.related('crewMembers').attach(
81 | ids.reduce>((obj, id, i) => {
82 | obj[id] = {
83 | title: this.#getRandom(this.titles, 1)[0],
84 | sort_order: i,
85 | }
86 |
87 | return obj
88 | }, {})
89 | )
90 | }
91 |
92 | async #attachRandomCastMembers(movie: Movie, cineasts: Cineast[], number: number) {
93 | const ids = this.#getRandom(cineasts, number).map(({ id }) => id)
94 | const records = await CineastFactory.makeStubbedMany(number)
95 |
96 | return movie.related('castMembers').attach(
97 | ids.reduce>((obj, id, i) => {
98 | obj[id] = {
99 | character_name: records[i].fullName,
100 | sort_order: i,
101 | }
102 |
103 | return obj
104 | }, {})
105 | )
106 | }
107 |
108 | #getRandom(array: T[], pluck: number) {
109 | const shuffle = array.sort(() => 0.5 - Math.random())
110 | return shuffle.slice(0, pluck)
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/resources/views/pages/admin/movies/createOrEdit.edge:
--------------------------------------------------------------------------------
1 | @let(title = movie ? `Update ${movie.title}` : 'Create A New Movie')
2 | @let(action = route('admin.movies.store'))
3 |
4 | @if (movie)
5 | @assign(action = route('admin.movies.update', { id: movie.id }, { qs: { _method: 'PUT' } }))
6 | @endif
7 |
8 | @layout.admin()
9 |
10 | {{ title }}
11 |
12 | {{ inspect(flashMessages.all()) }}
13 |
14 |
141 |
142 |
143 | @end
144 |
--------------------------------------------------------------------------------
/database/data/movies.ts:
--------------------------------------------------------------------------------
1 | export const movies = [
2 | {
3 | title: '3:10 to Yuma',
4 | releaseYear: 1957,
5 | },
6 | {
7 | title: 'The 7th Voyage of Sinbad',
8 | releaseYear: 1958,
9 | },
10 | {
11 | title: '12 Angry Men',
12 | releaseYear: 1957,
13 | },
14 | {
15 | title: '13 Lakes',
16 | releaseYear: 2004,
17 | },
18 | {
19 | title: '20,000 Leagues Under the Sea',
20 | releaseYear: 1916,
21 | },
22 | {
23 | title: '42nd Street',
24 | releaseYear: 1933,
25 | },
26 | {
27 | title: '2001: A Space Odyssey',
28 | releaseYear: 1968,
29 | },
30 | {
31 | title: 'Abbott and Costello Meet Frankenstein',
32 | releaseYear: 1948,
33 | },
34 | {
35 | title: 'Ace in the Hole (aka Big Carnival, The)',
36 | releaseYear: 1951,
37 | },
38 | {
39 | title: 'Adam’s Rib',
40 | releaseYear: 1949,
41 | },
42 | {
43 | title: 'The Adventures of Robin Hood',
44 | releaseYear: 1938,
45 | },
46 | {
47 | title: 'The African Queen',
48 | releaseYear: 1951,
49 | },
50 | {
51 | title: 'Airplane!',
52 | releaseYear: 1980,
53 | },
54 | {
55 | title: 'Alien',
56 | releaseYear: 1979,
57 | },
58 | {
59 | title: 'All About Eve',
60 | releaseYear: 1950,
61 | },
62 | {
63 | title: 'All My Babies',
64 | releaseYear: 1953,
65 | },
66 | {
67 | title: 'All Quiet on the Western Front',
68 | releaseYear: 1930,
69 | },
70 | {
71 | title: 'All That Heaven Allows',
72 | releaseYear: 1955,
73 | },
74 | {
75 | title: 'All That Jazz',
76 | releaseYear: 1979,
77 | },
78 | {
79 | title: 'All the King’s Men',
80 | releaseYear: 1949,
81 | },
82 | {
83 | title: 'All the President’s Men',
84 | releaseYear: 1976,
85 | },
86 | {
87 | title: 'Allures',
88 | releaseYear: 1961,
89 | },
90 | {
91 | title: 'America, America',
92 | releaseYear: 1963,
93 | },
94 | {
95 | title: 'American Graffiti',
96 | releaseYear: 1973,
97 | },
98 | {
99 | title: 'An American in Paris',
100 | releaseYear: 1951,
101 | },
102 | {
103 | title: 'Anatomy of a Murder',
104 | releaseYear: 1959,
105 | },
106 | {
107 | title: 'Annie Hall',
108 | releaseYear: 1977,
109 | },
110 | {
111 | title: 'Antonia: A Portrait of the Woman',
112 | releaseYear: 1974,
113 | },
114 | {
115 | title: 'The Apartment',
116 | releaseYear: 1960,
117 | },
118 | {
119 | title: 'Apocalypse Now',
120 | releaseYear: 1979,
121 | },
122 | {
123 | title: 'Applause',
124 | releaseYear: 1929,
125 | },
126 | {
127 | title: 'The Asphalt Jungle',
128 | releaseYear: 1950,
129 | },
130 | {
131 | title: 'Atlantic City',
132 | releaseYear: 1980,
133 | },
134 | {
135 | title: 'The Atomic Café',
136 | releaseYear: 1982,
137 | },
138 | {
139 | title: 'The Awful Truth',
140 | releaseYear: 1937,
141 | },
142 | {
143 | title: 'Baby Face',
144 | releaseYear: 1933,
145 | },
146 | {
147 | title: 'Back to the Future',
148 | releaseYear: 1985,
149 | },
150 | {
151 | title: 'The Bad and the Beautiful',
152 | releaseYear: 1952,
153 | },
154 | {
155 | title: 'Badlands',
156 | releaseYear: 1973,
157 | },
158 | {
159 | title: 'Ball of Fire',
160 | releaseYear: 1941,
161 | },
162 | {
163 | title: 'Bambi',
164 | releaseYear: 1942,
165 | },
166 | {
167 | title: 'The Band Wagon',
168 | releaseYear: 1953,
169 | },
170 | {
171 | title: 'The Bank Dick',
172 | releaseYear: 1940,
173 | },
174 | {
175 | title: 'The Bargain',
176 | releaseYear: 1914,
177 | },
178 | {
179 | title: 'The Battle of San Pietro',
180 | releaseYear: 1945,
181 | },
182 | {
183 | title: 'The Beau Brummels',
184 | releaseYear: 1928,
185 | },
186 | {
187 | title: 'Beauty and the Beast',
188 | releaseYear: 1991,
189 | },
190 | {
191 | title: 'Being There',
192 | releaseYear: 1979,
193 | },
194 | {
195 | title: 'Bert Williams Lime Kiln Club Field Day',
196 | releaseYear: 1913,
197 | },
198 | {
199 | title: 'The Best Years of Our Lives',
200 | releaseYear: 1946,
201 | },
202 | {
203 | title: 'Big Business',
204 | releaseYear: 1929,
205 | },
206 | {
207 | title: 'The Big Heat',
208 | releaseYear: 1953,
209 | },
210 | {
211 | title: 'The Big Lebowski',
212 | releaseYear: 1998,
213 | },
214 | {
215 | title: 'The Big Parade',
216 | releaseYear: 1925,
217 | },
218 | {
219 | title: 'The Big Sleep',
220 | releaseYear: 1946,
221 | },
222 | {
223 | title: 'The Big Trail',
224 | releaseYear: 1930,
225 | },
226 | {
227 | title: 'The Birds',
228 | releaseYear: 1963,
229 | },
230 | {
231 | title: 'The Birth of a Nation',
232 | releaseYear: 1915,
233 | },
234 | {
235 | title: 'Black and Tan',
236 | releaseYear: 1929,
237 | },
238 | {
239 | title: 'The Black Pirate',
240 | releaseYear: 1926,
241 | },
242 | {
243 | title: 'The Black Stallion',
244 | releaseYear: 1979,
245 | },
246 | {
247 | title: 'Blackboard Jungle',
248 | releaseYear: 1955,
249 | },
250 | {
251 | title: 'Blacksmith Scene',
252 | releaseYear: 1893,
253 | },
254 | {
255 | title: 'Blade Runner',
256 | releaseYear: 1982,
257 | },
258 | {
259 | title: 'Blazing Saddles',
260 | releaseYear: 1974,
261 | },
262 | {
263 | title: 'Bless Their Little Hearts',
264 | releaseYear: 1984,
265 | },
266 | {
267 | title: 'The Blood of Jesus',
268 | releaseYear: 1941,
269 | },
270 | {
271 | title: 'The Blue Bird',
272 | releaseYear: 1918,
273 | },
274 | {
275 | title: 'Bonnie and Clyde',
276 | releaseYear: 1967,
277 | },
278 | {
279 | title: 'Born Yesterday',
280 | releaseYear: 1950,
281 | },
282 | {
283 | title: 'Boulevard Nights',
284 | releaseYear: 1979,
285 | },
286 | {
287 | title: 'Boyz N the Hood',
288 | releaseYear: 1991,
289 | },
290 | {
291 | title: 'Brandy in the Wilderness',
292 | releaseYear: 1969,
293 | },
294 | {
295 | title: 'Breakfast at Tiffany’s',
296 | releaseYear: 1961,
297 | },
298 | {
299 | title: 'The Breakfast Club',
300 | releaseYear: 1985,
301 | },
302 | {
303 | title: 'The Bride of Frankenstein',
304 | releaseYear: 1935,
305 | },
306 | {
307 | title: 'The Bridge on the River Kwai',
308 | releaseYear: 1957,
309 | },
310 | {
311 | title: 'Bringing Up Baby',
312 | releaseYear: 1938,
313 | },
314 | {
315 | title: 'Broken Blossoms',
316 | releaseYear: 1919,
317 | },
318 | {
319 | title: 'A Bronx Morning',
320 | releaseYear: 1931,
321 | },
322 | {
323 | title: 'The Buffalo Creek Flood: An Act of Man',
324 | releaseYear: 1975,
325 | },
326 | {
327 | title: 'Bullitt',
328 | releaseYear: 1968,
329 | },
330 | {
331 | title: 'Butch Cassidy and the Sundance Kid',
332 | releaseYear: 1969,
333 | },
334 | {
335 | title: 'Cabaret',
336 | releaseYear: 1972,
337 | },
338 | {
339 | title: 'The Cameraman',
340 | releaseYear: 1928,
341 | },
342 | {
343 | title: 'Carmen Jones',
344 | releaseYear: 1954,
345 | },
346 | {
347 | title: 'Casablanca',
348 | releaseYear: 1942,
349 | },
350 | {
351 | title: 'Castro Street (The Coming of Consciousness)',
352 | releaseYear: 1966,
353 | },
354 | {
355 | title: 'Cat People',
356 | releaseYear: 1942,
357 | },
358 | {
359 | title: 'Chan Is Missing',
360 | releaseYear: 1982,
361 | },
362 | {
363 | title: 'The Cheat',
364 | releaseYear: 1915,
365 | },
366 | {
367 | title: 'The Chechahcos',
368 | releaseYear: 1924,
369 | },
370 | {
371 | title: 'Chinatown',
372 | releaseYear: 1974,
373 | },
374 | {
375 | title: 'A Christmas Story',
376 | releaseYear: 1983,
377 | },
378 | {
379 | title: 'Chulas Fronteras',
380 | releaseYear: 1976,
381 | },
382 | {
383 | title: 'Cicero March',
384 | releaseYear: 1966,
385 | },
386 | {
387 | title: 'Citizen Kane',
388 | releaseYear: 1941,
389 | },
390 | {
391 | title: 'The City',
392 | releaseYear: 1939,
393 | },
394 | {
395 | title: 'City Lights',
396 | releaseYear: 1931,
397 | },
398 | {
399 | title: 'Civilization',
400 | releaseYear: 1916,
401 | },
402 | {
403 | title: 'Clash of the Wolves',
404 | releaseYear: 1925,
405 | },
406 | {
407 | title: 'Close Encounters of the Third Kind',
408 | releaseYear: 1977,
409 | },
410 | {
411 | title: 'Cologne: From the Diary of Ray and Esther',
412 | releaseYear: 1939,
413 | },
414 | {
415 | title: 'A Computer Animated Hand',
416 | releaseYear: 1972,
417 | },
418 | {
419 | title: 'The Conversation',
420 | releaseYear: 1974,
421 | },
422 | {
423 | title: 'Cool Hand Luke',
424 | releaseYear: 1967,
425 | },
426 | {
427 | title: 'The Cool World',
428 | releaseYear: 1963,
429 | },
430 | {
431 | title: 'Cops',
432 | releaseYear: 1922,
433 | },
434 | {
435 | title: 'Corbett-Fitzsimmons Title Fight',
436 | releaseYear: 1897,
437 | },
438 | {
439 | title: 'A Corner in Wheat',
440 | releaseYear: 1909,
441 | },
442 | {
443 | title: 'The Court Jester',
444 | releaseYear: 1956,
445 | },
446 | {
447 | title: 'Crisis: Behind a Presidential Commitment',
448 | releaseYear: 1963,
449 | },
450 | {
451 | title: 'The Crowd',
452 | releaseYear: 1928,
453 | },
454 | {
455 | title: 'Cry of Jazz',
456 | releaseYear: 1959,
457 | },
458 | {
459 | title: 'The Cry of the Children',
460 | releaseYear: 1912,
461 | },
462 | {
463 | title: 'A Cure for Pokeritis',
464 | releaseYear: 1912,
465 | },
466 | {
467 | title: 'The Curse of Quon Gwon 1916',
468 | releaseYear: 1917,
469 | },
470 | {
471 | title: 'Czechoslovakia 1968',
472 | releaseYear: 1969,
473 | },
474 | {
475 | title: 'D.O.A.',
476 | releaseYear: 1950,
477 | },
478 | {
479 | title: 'Dance, Girl, Dance',
480 | releaseYear: 1940,
481 | },
482 | {
483 | title: 'Dances with Wolves',
484 | releaseYear: 1990,
485 | },
486 | {
487 | title: 'Daughter of Dawn',
488 | releaseYear: 1920,
489 | },
490 | {
491 | title: 'Daughter of Shanghai',
492 | releaseYear: 1937,
493 | },
494 | {
495 | title: 'Daughters of the Dust',
496 | releaseYear: 1991,
497 | },
498 | {
499 | title: 'David Holzman’s Diary',
500 | releaseYear: 1968,
501 | },
502 | {
503 | title: 'The Day the Earth Stood Still',
504 | releaseYear: 1951,
505 | },
506 | {
507 | title: 'Days of Heaven',
508 | releaseYear: 1978,
509 | },
510 | {
511 | title: 'Dead Birds',
512 | releaseYear: 1964,
513 | },
514 | {
515 | title: 'Decasia',
516 | releaseYear: 2002,
517 | },
518 | {
519 | title: 'The Decline of Western Civilization',
520 | releaseYear: 1981,
521 | },
522 | {
523 | title: 'The Deer Hunter',
524 | releaseYear: 1978,
525 | },
526 | {
527 | title: 'Deliverance',
528 | releaseYear: 1972,
529 | },
530 | {
531 | title: 'Demolishing and Building Up the Star Theatre',
532 | releaseYear: 1901,
533 | },
534 | {
535 | title: 'Destry Rides Again',
536 | releaseYear: 1939,
537 | },
538 | {
539 | title: 'Detour',
540 | releaseYear: 1945,
541 | },
542 | {
543 | title: 'Die Hard',
544 | releaseYear: 1988,
545 | },
546 | {
547 | title: 'Dirty Harry',
548 | releaseYear: 1971,
549 | },
550 | {
551 | title: 'Disneyland Dream',
552 | releaseYear: 1956,
553 | },
554 | {
555 | title: 'Do the Right Thing',
556 | releaseYear: 1989,
557 | },
558 | {
559 | title: 'The Docks of New York',
560 | releaseYear: 1928,
561 | },
562 | {
563 | title: 'Dodsworth',
564 | releaseYear: 1936,
565 | },
566 | {
567 | title: 'Dog Day Afternoon',
568 | releaseYear: 1975,
569 | },
570 | {
571 | title: 'Dog Star Man',
572 | releaseYear: 1964,
573 | },
574 | {
575 | title: 'Don’t Look Back',
576 | releaseYear: 1967,
577 | },
578 | {
579 | title: 'Double Indemnity',
580 | releaseYear: 1944,
581 | },
582 | {
583 | title: 'Down Argentine Way',
584 | releaseYear: 1940,
585 | },
586 | {
587 | title: 'Dr. Strangelove',
588 | releaseYear: 1964,
589 | },
590 | {
591 | title: 'Dracula',
592 | releaseYear: 1931,
593 | },
594 | {
595 | title: 'The Dragon Painter',
596 | releaseYear: 1919,
597 | },
598 | {
599 | title: 'Dream of a Rarebit Fiend',
600 | releaseYear: 1906,
601 | },
602 | {
603 | title: 'Drums of Winter (aka Uksuum Cauyai)',
604 | releaseYear: 1988,
605 | },
606 | {
607 | title: 'Duck Amuck',
608 | releaseYear: 1953,
609 | },
610 | {
611 | title: 'Duck and Cover',
612 | releaseYear: 1951,
613 | },
614 | {
615 | title: 'Duck Soup',
616 | releaseYear: 1933,
617 | },
618 | {
619 | title: 'Dumbo',
620 | releaseYear: 1941,
621 | },
622 | {
623 | title: 'E.T. The Extra-Terrestrial',
624 | releaseYear: 1982,
625 | },
626 | {
627 | title: 'Eadweard Muybridge, Zoopraxographer',
628 | releaseYear: 1974,
629 | },
630 | {
631 | title: 'East of Eden',
632 | releaseYear: 1955,
633 | },
634 | {
635 | title: 'Easy Rider',
636 | releaseYear: 1969,
637 | },
638 | {
639 | title: 'Eaux d’artifice',
640 | releaseYear: 1953,
641 | },
642 | {
643 | title: 'Edison Kinetoscopic Record of a Sneeze',
644 | releaseYear: 1894,
645 | },
646 | {
647 | title: 'El Mariachi',
648 | releaseYear: 1992,
649 | },
650 | {
651 | title: 'El Norte',
652 | releaseYear: 1983,
653 | },
654 | {
655 | title: 'Ella Cinders',
656 | releaseYear: 1926,
657 | },
658 | {
659 | title: 'The Emperor Jones',
660 | releaseYear: 1933,
661 | },
662 | {
663 | title: 'Empire',
664 | releaseYear: 1964,
665 | },
666 | {
667 | title: 'The Empire Strikes Back',
668 | releaseYear: 1980,
669 | },
670 | {
671 | title: 'The Endless Summer',
672 | releaseYear: 1966,
673 | },
674 | {
675 | title: 'Enter the Dragon',
676 | releaseYear: 1973,
677 | },
678 | {
679 | title: 'Eraserhead',
680 | releaseYear: 1977,
681 | },
682 | {
683 | title: 'The Evidence of the Film',
684 | releaseYear: 1913,
685 | },
686 | {
687 | title: 'The Exiles',
688 | releaseYear: 1961,
689 | },
690 | {
691 | title: 'The Exorcist',
692 | releaseYear: 1973,
693 | },
694 | {
695 | title: 'The Exploits of Elaine',
696 | releaseYear: 1914,
697 | },
698 | {
699 | title: 'A Face in the Crowd',
700 | releaseYear: 1957,
701 | },
702 | {
703 | title: 'Faces',
704 | releaseYear: 1968,
705 | },
706 | {
707 | title: 'In a Lonely Place',
708 | releaseYear: 1950,
709 | },
710 | {
711 | title: 'In Cold Blood',
712 | releaseYear: 1967,
713 | },
714 | {
715 | title: 'In the Heat of the Night',
716 | releaseYear: 1967,
717 | },
718 | {
719 | title: 'In the Land of the Head Hunters',
720 | releaseYear: 1914,
721 | },
722 | {
723 | title: 'In the Street',
724 | releaseYear: 1948,
725 | },
726 | {
727 | title: 'The Incredible Shrinking Man',
728 | releaseYear: 1957,
729 | },
730 | {
731 | title: 'The Inner World of Aphasia',
732 | releaseYear: 1968,
733 | },
734 | {
735 | title: 'Interior New York Subway, 14th Street to 42nd Street',
736 | releaseYear: 1905,
737 | },
738 | {
739 | title: 'Into the Arms of Strangers: Stories of the Kindertransport',
740 | releaseYear: 2000,
741 | },
742 | {
743 | title: 'Intolerance',
744 | releaseYear: 1916,
745 | },
746 | {
747 | title: 'Invasion of the Body Snatchers',
748 | releaseYear: 1956,
749 | },
750 | {
751 | title: 'The Invisible Man',
752 | releaseYear: 1933,
753 | },
754 | {
755 | title: 'The Iron Horse',
756 | releaseYear: 1924,
757 | },
758 | {
759 | title: 'It',
760 | releaseYear: 1927,
761 | },
762 | {
763 | title: 'It Happened One Night',
764 | releaseYear: 1934,
765 | },
766 | {
767 | title: "It's a Gift",
768 | releaseYear: 1934,
769 | },
770 | {
771 | title: "It's a Wonderful Life",
772 | releaseYear: 1946,
773 | },
774 | {
775 | title: 'The Italian',
776 | releaseYear: 1915,
777 | },
778 | {
779 | title: 'Jailhouse Rock',
780 | releaseYear: 1957,
781 | },
782 | {
783 | title: 'Jam Session',
784 | releaseYear: 1942,
785 | },
786 | {
787 | title: 'Jammin’ the Blues',
788 | releaseYear: 1944,
789 | },
790 | {
791 | title: 'Jaws',
792 | releaseYear: 1975,
793 | },
794 | {
795 | title: 'Jazz on a Summer’s Day',
796 | releaseYear: 1959,
797 | },
798 | {
799 | title: 'The Jazz Singer',
800 | releaseYear: 1927,
801 | },
802 | {
803 | title: 'Jeffries-Johnson World’s Championship Boxing Contest',
804 | releaseYear: 1910,
805 | },
806 | {
807 | title: 'Jezebel',
808 | releaseYear: 1938,
809 | },
810 | {
811 | title: 'John Henry and the Inky-Poo',
812 | releaseYear: 1946,
813 | },
814 | {
815 | title: 'Johnny Guitar',
816 | releaseYear: 1954,
817 | },
818 | {
819 | title: 'Judgment at Nuremberg',
820 | releaseYear: 1961,
821 | },
822 | {
823 | title: 'The Jungle',
824 | releaseYear: 1967,
825 | },
826 | {
827 | title: 'Kannapolis, N.C.',
828 | releaseYear: 1941,
829 | },
830 | {
831 | title: 'The Kid',
832 | releaseYear: 1921,
833 | },
834 | {
835 | title: 'Killer of Sheep',
836 | releaseYear: 1977,
837 | },
838 | {
839 | title: 'The Killers',
840 | releaseYear: 1946,
841 | },
842 | {
843 | title: 'King Kong',
844 | releaseYear: 1933,
845 | },
846 | {
847 | title: 'King of Jazz',
848 | releaseYear: 1930,
849 | },
850 | {
851 | title: 'Memento',
852 | releaseYear: 2000,
853 | },
854 | {
855 | title: 'Memphis Belle',
856 | releaseYear: 1944,
857 | },
858 | {
859 | title: 'Men and Dust',
860 | releaseYear: 1940,
861 | },
862 | {
863 | title: 'Meshes of the Afternoon',
864 | releaseYear: 1943,
865 | },
866 | {
867 | title: 'Michael Jackson’s Thriller',
868 | releaseYear: 1983,
869 | },
870 | {
871 | title: 'The Middleton Family at the New York World’s Fair',
872 | releaseYear: 1939,
873 | },
874 | {
875 | title: 'Midnight',
876 | releaseYear: 1939,
877 | },
878 | {
879 | title: 'Midnight Cowboy',
880 | releaseYear: 1969,
881 | },
882 | {
883 | title: 'Mighty Like a Moose',
884 | releaseYear: 1926,
885 | },
886 | {
887 | title: 'Mildred Pierce',
888 | releaseYear: 1945,
889 | },
890 | {
891 | title: 'The Miracle of Morgan’s Creek',
892 | releaseYear: 1944,
893 | },
894 | {
895 | title: 'Miracle on 34th Street',
896 | releaseYear: 1947,
897 | },
898 | {
899 | title: 'Miss Lulu Bett',
900 | releaseYear: 1922,
901 | },
902 | {
903 | title: 'Modern Times',
904 | releaseYear: 1936,
905 | },
906 | {
907 | title: 'Modesta',
908 | releaseYear: 1956,
909 | },
910 | {
911 | title: 'Mom and Dad',
912 | releaseYear: 1944,
913 | },
914 | {
915 | title: 'Moon Breath Beat',
916 | releaseYear: 1980,
917 | },
918 | {
919 | title: 'Morocco',
920 | releaseYear: 1930,
921 | },
922 | {
923 | title: 'Motion Painting No. 1',
924 | releaseYear: 1947,
925 | },
926 | {
927 | title: 'A MOVIE',
928 | releaseYear: 1958,
929 | },
930 | {
931 | title: 'Mr. Smith Goes to Washington',
932 | releaseYear: 1939,
933 | },
934 | {
935 | title: 'Mrs. Miniver',
936 | releaseYear: 1942,
937 | },
938 | {
939 | title: 'Multiple SIDosis',
940 | releaseYear: 1970,
941 | },
942 | {
943 | title: 'The Muppet Movie',
944 | releaseYear: 1979,
945 | },
946 | {
947 | title: 'The Music Box',
948 | releaseYear: 1932,
949 | },
950 | {
951 | title: 'The Music Man',
952 | releaseYear: 1962,
953 | },
954 | {
955 | title: 'Musketeers of Pig Alley',
956 | releaseYear: 1912,
957 | },
958 | {
959 | title: 'My Darling Clementine',
960 | releaseYear: 1946,
961 | },
962 | {
963 | title: 'My Man Godfrey',
964 | releaseYear: 1936,
965 | },
966 | {
967 | title: 'The Naked City',
968 | releaseYear: 1948,
969 | },
970 | {
971 | title: 'The Naked Spur',
972 | releaseYear: 1953,
973 | },
974 | {
975 | title: 'Nanook of the North',
976 | releaseYear: 1922,
977 | },
978 | {
979 | title: 'Nashville',
980 | releaseYear: 1975,
981 | },
982 | {
983 | title: 'National Lampoon’s Animal House',
984 | releaseYear: 1978,
985 | },
986 | {
987 | title: 'National Velvet',
988 | releaseYear: 1944,
989 | },
990 | {
991 | title: 'Naughty Marietta',
992 | releaseYear: 1935,
993 | },
994 | {
995 | title: 'Navajo Film Themselves (Through Navajo Eyes)',
996 | releaseYear: 1966,
997 | },
998 | {
999 | title: 'Night of the Living Dead',
1000 | releaseYear: 1968,
1001 | },
1002 | {
1003 | title: 'Ninotchka',
1004 | releaseYear: 1939,
1005 | },
1006 | {
1007 | title: 'No Lies',
1008 | releaseYear: 1973,
1009 | },
1010 | {
1011 | title: 'Norma Rae',
1012 | releaseYear: 1979,
1013 | },
1014 | {
1015 | title: 'North By Northwest',
1016 | releaseYear: 1959,
1017 | },
1018 | {
1019 | title: 'Nostalgia',
1020 | releaseYear: 1971,
1021 | },
1022 | {
1023 | title: 'Notes on the Port of St. Francis',
1024 | releaseYear: 1951,
1025 | },
1026 | {
1027 | title: 'Nothing But a Man',
1028 | releaseYear: 1964,
1029 | },
1030 | {
1031 | title: 'Notorious',
1032 | releaseYear: 1946,
1033 | },
1034 | {
1035 | title: 'Now, Voyager',
1036 | releaseYear: 1942,
1037 | },
1038 | {
1039 | title: 'The Nutty Professor',
1040 | releaseYear: 1963,
1041 | },
1042 | {
1043 | title: 'OffOn',
1044 | releaseYear: 1968,
1045 | },
1046 | {
1047 | title: 'Oklahoma!',
1048 | releaseYear: 1955,
1049 | },
1050 | {
1051 | title: 'The Old Mill',
1052 | releaseYear: 1937,
1053 | },
1054 | {
1055 | title: 'On the Bowery',
1056 | releaseYear: 1957,
1057 | },
1058 | {
1059 | title: 'On the Waterfront',
1060 | releaseYear: 1954,
1061 | },
1062 | {
1063 | title: 'Once Upon a Time in the West',
1064 | releaseYear: 1968,
1065 | },
1066 | {
1067 | title: 'One Flew Over the Cuckoo’s Nest',
1068 | releaseYear: 1975,
1069 | },
1070 | {
1071 | title: 'One Froggy Evening',
1072 | releaseYear: 1956,
1073 | },
1074 | {
1075 | title: 'One Survivor Remembers',
1076 | releaseYear: 1995,
1077 | },
1078 | {
1079 | title: 'One Week',
1080 | releaseYear: 1920,
1081 | },
1082 | {
1083 | title: 'Only Angels Have Wings',
1084 | releaseYear: 1939,
1085 | },
1086 | {
1087 | title: 'Our Daily Bread',
1088 | releaseYear: 1934,
1089 | },
1090 | {
1091 | title: 'Our Day',
1092 | releaseYear: 1938,
1093 | },
1094 | {
1095 | title: 'Our Lady of the Sphere',
1096 | releaseYear: 1969,
1097 | },
1098 | {
1099 | title: 'Out of the Past',
1100 | releaseYear: 1947,
1101 | },
1102 | {
1103 | title: 'The Outlaw Josey Wales',
1104 | releaseYear: 1976,
1105 | },
1106 | {
1107 | title: 'The Ox-Bow Incident',
1108 | releaseYear: 1943,
1109 | },
1110 | {
1111 | title: 'Parable',
1112 | releaseYear: 1964,
1113 | },
1114 | {
1115 | title: 'Paris Is Burning',
1116 | releaseYear: 1990,
1117 | },
1118 | {
1119 | title: 'Pass the Gravy',
1120 | releaseYear: 1928,
1121 | },
1122 | {
1123 | title: 'The Phantom of the Opera',
1124 | releaseYear: 1925,
1125 | },
1126 | {
1127 | title: 'The Philadelphia Story',
1128 | releaseYear: 1940,
1129 | },
1130 | {
1131 | title: 'Pillow Talk',
1132 | releaseYear: 1959,
1133 | },
1134 | {
1135 | title: 'The Pink Panther',
1136 | releaseYear: 1963,
1137 | },
1138 | {
1139 | title: 'Pinocchio',
1140 | releaseYear: 1940,
1141 | },
1142 | {
1143 | title: 'A Place in the Sun',
1144 | releaseYear: 1951,
1145 | },
1146 | {
1147 | title: 'Planet of the Apes',
1148 | releaseYear: 1968,
1149 | },
1150 | {
1151 | title: "Please Don't Bury Me Alive!",
1152 | releaseYear: 1976,
1153 | },
1154 | {
1155 | title: 'The Plow That Broke the Plains',
1156 | releaseYear: 1936,
1157 | },
1158 | {
1159 | title: 'Point Blank',
1160 | releaseYear: 1967,
1161 | },
1162 | {
1163 | title: 'Point of Order',
1164 | releaseYear: 1964,
1165 | },
1166 | {
1167 | title: 'The Poor Little Rich Girl',
1168 | releaseYear: 1917,
1169 | },
1170 | {
1171 | title: 'Popeye the Sailor Meets Sindbad the Sailor',
1172 | releaseYear: 1936,
1173 | },
1174 | {
1175 | title: 'Porgy and Bess',
1176 | releaseYear: 1959,
1177 | },
1178 | {
1179 | title: 'Porky in Wackyland',
1180 | releaseYear: 1938,
1181 | },
1182 | {
1183 | title: 'Portrait of Jason',
1184 | releaseYear: 1967,
1185 | },
1186 | {
1187 | title: 'The Power and the Glory',
1188 | releaseYear: 1933,
1189 | },
1190 | {
1191 | title: 'The Power of the Press',
1192 | releaseYear: 1928,
1193 | },
1194 | {
1195 | title: 'Powers of Ten',
1196 | releaseYear: 1978,
1197 | },
1198 | {
1199 | title: 'Precious Images',
1200 | releaseYear: 1986,
1201 | },
1202 | {
1203 | title: 'Preservation of the Sign Language',
1204 | releaseYear: 1913,
1205 | },
1206 | {
1207 | title: 'President McKinley Inauguration Footage',
1208 | releaseYear: 1901,
1209 | },
1210 | {
1211 | title: 'Primary',
1212 | releaseYear: 1960,
1213 | },
1214 | {
1215 | title: 'The Princess Bride',
1216 | releaseYear: 1987,
1217 | },
1218 | {
1219 | title: 'Princess Nicotine; or, The Smoke Fairy',
1220 | releaseYear: 1909,
1221 | },
1222 | {
1223 | title: 'The Prisoner of Zenda',
1224 | releaseYear: 1937,
1225 | },
1226 | {
1227 | title: 'The Producers',
1228 | releaseYear: 1968,
1229 | },
1230 | {
1231 | title: 'Psycho',
1232 | releaseYear: 1960,
1233 | },
1234 | {
1235 | title: 'The Public Enemy',
1236 | releaseYear: 1931,
1237 | },
1238 | {
1239 | title: 'Pull My Daisy',
1240 | releaseYear: 1959,
1241 | },
1242 | {
1243 | title: 'Ride the High Country',
1244 | releaseYear: 1962,
1245 | },
1246 | {
1247 | title: 'The Right Stuff',
1248 | releaseYear: 1983,
1249 | },
1250 | {
1251 | title: 'Rio Bravo',
1252 | releaseYear: 1959,
1253 | },
1254 | {
1255 | title: 'Rip Van Winkle',
1256 | releaseYear: 1896,
1257 | },
1258 | {
1259 | title: 'The River',
1260 | releaseYear: 1938,
1261 | },
1262 | {
1263 | title: 'Road to Morocco',
1264 | releaseYear: 1942,
1265 | },
1266 | {
1267 | title: 'Rocky',
1268 | releaseYear: 1976,
1269 | },
1270 | {
1271 | title: 'The Rocky Horror Picture Show',
1272 | releaseYear: 1975,
1273 | },
1274 | {
1275 | title: 'Roger & Me',
1276 | releaseYear: 1989,
1277 | },
1278 | {
1279 | title: 'Roman Holiday',
1280 | releaseYear: 1953,
1281 | },
1282 | {
1283 | title: 'Rose Hobart',
1284 | releaseYear: 1936,
1285 | },
1286 | {
1287 | title: "Rosemary's Baby",
1288 | releaseYear: 1968,
1289 | },
1290 | {
1291 | title: 'Ruggles of Red Gap',
1292 | releaseYear: 1935,
1293 | },
1294 | {
1295 | title: 'Rushmore',
1296 | releaseYear: 1998,
1297 | },
1298 | {
1299 | title: 'Sabrina',
1300 | releaseYear: 1954,
1301 | },
1302 | {
1303 | title: 'Safety Last!',
1304 | releaseYear: 1923,
1305 | },
1306 | {
1307 | title: 'Salesman',
1308 | releaseYear: 1968,
1309 | },
1310 | {
1311 | title: 'Saving Private Ryan',
1312 | releaseYear: 1998,
1313 | },
1314 | {
1315 | title: 'Scarface',
1316 | releaseYear: 1932,
1317 | },
1318 | {
1319 | title: 'Schindler’s List',
1320 | releaseYear: 1993,
1321 | },
1322 | {
1323 | title: 'Scratch and Crow',
1324 | releaseYear: 1995,
1325 | },
1326 | {
1327 | title: 'The Searchers',
1328 | releaseYear: 1956,
1329 | },
1330 | {
1331 | title: 'Seconds',
1332 | releaseYear: 1966,
1333 | },
1334 | {
1335 | title: 'Serene Velocity',
1336 | releaseYear: 1970,
1337 | },
1338 | {
1339 | title: 'Sergeant York',
1340 | releaseYear: 1941,
1341 | },
1342 | {
1343 | title: 'Seven Brides for Seven Brothers',
1344 | releaseYear: 1954,
1345 | },
1346 | {
1347 | title: 'Seventh Heaven',
1348 | releaseYear: 1927,
1349 | },
1350 | {
1351 | title: 'The Sex Life of the Polyp',
1352 | releaseYear: 1928,
1353 | },
1354 | {
1355 | title: 'sex, lies, and videotape',
1356 | releaseYear: 1989,
1357 | },
1358 | {
1359 | title: 'Shadow of a Doubt',
1360 | releaseYear: 1943,
1361 | },
1362 | {
1363 | title: 'Shadows',
1364 | releaseYear: 1959,
1365 | },
1366 | {
1367 | title: 'Shaft',
1368 | releaseYear: 1971,
1369 | },
1370 | {
1371 | title: 'Shane',
1372 | releaseYear: 1953,
1373 | },
1374 | {
1375 | title: 'The Shawshank Redemption',
1376 | releaseYear: 1994,
1377 | },
1378 | {
1379 | title: 'She Done Him Wrong',
1380 | releaseYear: 1933,
1381 | },
1382 | {
1383 | title: 'Sherlock, Jr.',
1384 | releaseYear: 1924,
1385 | },
1386 | {
1387 | title: 'Sherman’s March',
1388 | releaseYear: 1986,
1389 | },
1390 | {
1391 | title: 'Shock Corridor',
1392 | releaseYear: 1963,
1393 | },
1394 | {
1395 | title: 'Shoes',
1396 | releaseYear: 1916,
1397 | },
1398 | {
1399 | title: 'The Shop Around the Corner',
1400 | releaseYear: 1940,
1401 | },
1402 | {
1403 | title: 'Show Boat',
1404 | releaseYear: 1936,
1405 | },
1406 | {
1407 | title: 'Show People',
1408 | releaseYear: 1928,
1409 | },
1410 | {
1411 | title: 'Siege',
1412 | releaseYear: 1940,
1413 | },
1414 | {
1415 | title: 'The Silence of the Lambs',
1416 | releaseYear: 1991,
1417 | },
1418 | {
1419 | title: "Singin' in the Rain",
1420 | releaseYear: 1952,
1421 | },
1422 | {
1423 | title: 'Sink or Swim',
1424 | releaseYear: 1990,
1425 | },
1426 | {
1427 | title: 'Sinking of the Lusitania',
1428 | releaseYear: 1918,
1429 | },
1430 | {
1431 | title: 'Sky High',
1432 | releaseYear: 1922,
1433 | },
1434 | {
1435 | title: 'Slacker',
1436 | releaseYear: 1991,
1437 | },
1438 | {
1439 | title: 'Sons of the Desert',
1440 | releaseYear: 1933,
1441 | },
1442 | {
1443 | title: 'The Sound of Music',
1444 | releaseYear: 1965,
1445 | },
1446 | {
1447 | title: 'So’s Your Old Man',
1448 | releaseYear: 1926,
1449 | },
1450 | {
1451 | title: 'Spartacus',
1452 | releaseYear: 1960,
1453 | },
1454 | {
1455 | title: 'The Spook Who Sat by the Door',
1456 | releaseYear: 1973,
1457 | },
1458 | {
1459 | title: 'St. Louis Blues',
1460 | releaseYear: 1929,
1461 | },
1462 | {
1463 | title: 'Stagecoach',
1464 | releaseYear: 1939,
1465 | },
1466 | {
1467 | title: 'Stand and Deliver',
1468 | releaseYear: 1988,
1469 | },
1470 | {
1471 | title: 'A Star Is Born',
1472 | releaseYear: 1954,
1473 | },
1474 | {
1475 | title: 'Star Wars',
1476 | releaseYear: 1977,
1477 | },
1478 | {
1479 | title: 'Stark Love',
1480 | releaseYear: 1927,
1481 | },
1482 | {
1483 | title: 'State Fair',
1484 | releaseYear: 1933,
1485 | },
1486 | {
1487 | title: 'Steamboat Bill, Jr.',
1488 | releaseYear: 1928,
1489 | },
1490 | {
1491 | title: 'Steamboat Willie',
1492 | releaseYear: 1928,
1493 | },
1494 | {
1495 | title: 'The Sting',
1496 | releaseYear: 1973,
1497 | },
1498 | {
1499 | title: 'Stormy Weather',
1500 | releaseYear: 1943,
1501 | },
1502 | {
1503 | title: 'The Story of G.I. Joe',
1504 | releaseYear: 1945,
1505 | },
1506 | {
1507 | title: 'The Story of Menstruation',
1508 | releaseYear: 1946,
1509 | },
1510 | {
1511 | title: 'Stranger Than Paradise',
1512 | releaseYear: 1984,
1513 | },
1514 | {
1515 | title: 'A Streetcar Named Desire',
1516 | releaseYear: 1951,
1517 | },
1518 | {
1519 | title: 'The Strong Man',
1520 | releaseYear: 1926,
1521 | },
1522 | {
1523 | title: 'A Study in Reds',
1524 | releaseYear: 1932,
1525 | },
1526 | {
1527 | title: 'This Is Cinerama',
1528 | releaseYear: 1952,
1529 | },
1530 | {
1531 | title: 'This Is Spinal Tap',
1532 | releaseYear: 1984,
1533 | },
1534 | {
1535 | title: 'The Three Little Pigs',
1536 | releaseYear: 1933,
1537 | },
1538 | {
1539 | title: 'Time and Dreams',
1540 | releaseYear: 1976,
1541 | },
1542 | {
1543 | title: 'A Time for Burning',
1544 | releaseYear: 1966,
1545 | },
1546 | {
1547 | title: 'A Time Out of War',
1548 | releaseYear: 1954,
1549 | },
1550 | {
1551 | title: 'The Times of Harvey Milk',
1552 | releaseYear: 1984,
1553 | },
1554 | {
1555 | title: 'Tin Toy',
1556 | releaseYear: 1988,
1557 | },
1558 | {
1559 | title: 'Titanic',
1560 | releaseYear: 1997,
1561 | },
1562 | {
1563 | title: 'To Be or Not to Be',
1564 | releaseYear: 1942,
1565 | },
1566 | {
1567 | title: 'To Fly!',
1568 | releaseYear: 1976,
1569 | },
1570 | {
1571 | title: 'To Kill a Mockingbird',
1572 | releaseYear: 1962,
1573 | },
1574 | {
1575 | title: 'To Sleep with Anger',
1576 | releaseYear: 1990,
1577 | },
1578 | {
1579 | title: 'Tol’able David',
1580 | releaseYear: 1921,
1581 | },
1582 | {
1583 | title: 'Vertigo',
1584 | releaseYear: 1958,
1585 | },
1586 | {
1587 | title: 'A Virtuous Vamp',
1588 | releaseYear: 1919,
1589 | },
1590 | {
1591 | title: 'A Walk in the Sun',
1592 | releaseYear: 1945,
1593 | },
1594 | {
1595 | title: 'Wanda',
1596 | releaseYear: 1970,
1597 | },
1598 | {
1599 | title: 'War of the Worlds',
1600 | releaseYear: 1953,
1601 | },
1602 | {
1603 | title: 'Water and Power',
1604 | releaseYear: 1989,
1605 | },
1606 | {
1607 | title: 'The Way of Peace',
1608 | releaseYear: 1947,
1609 | },
1610 | {
1611 | title: 'The Wedding March',
1612 | releaseYear: 1928,
1613 | },
1614 | {
1615 | title: 'West Side Story',
1616 | releaseYear: 1961,
1617 | },
1618 | {
1619 | title: 'Westinghouse Works 1904',
1620 | releaseYear: 1904,
1621 | },
1622 | {
1623 | title: 'What’s Opera, Doc?',
1624 | releaseYear: 1957,
1625 | },
1626 | {
1627 | title: 'Where Are My Children?',
1628 | releaseYear: 1916,
1629 | },
1630 | {
1631 | title: 'White Fawn’s Devotion',
1632 | releaseYear: 1910,
1633 | },
1634 | {
1635 | title: 'White Heat',
1636 | releaseYear: 1949,
1637 | },
1638 | {
1639 | title: 'Who Framed Roger Rabbit?',
1640 | releaseYear: 1988,
1641 | },
1642 | {
1643 | title: 'Who’s Afraid of Virginia Woolf?',
1644 | releaseYear: 1966,
1645 | },
1646 | ]
1647 |
--------------------------------------------------------------------------------