├── 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 | 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 | {{ user.fullName }} 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 |
8 | {{ csrfField() }} 9 | 10 | @!form.input({ 11 | label: 'Full Name', 12 | name: 'fullName' 13 | }) 14 | 15 | @!form.input({ 16 | label: 'Email', 17 | name: 'email', 18 | type: 'email' 19 | }) 20 | 21 | @!form.input({ 22 | label: 'Password', 23 | name: 'password', 24 | type: 'password' 25 | }) 26 | 27 | @button({ type: 'submit' }) 28 | Register 29 | @end 30 |
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 | 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 |
8 | {{ csrfField() }} 9 | 10 | @error('E_INVALID_CREDENTIALS') 11 | 14 | @end 15 | 16 | @!form.input({ 17 | label: 'Email', 18 | name: 'email', 19 | type: 'email' 20 | }) 21 | 22 | @!form.input({ 23 | label: 'Password', 24 | name: 'password', 25 | type: 'password' 26 | }) 27 | 28 | 32 | 33 | @button({ type: 'submit' }) 34 | Login 35 | @end 36 |
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 |
26 | {{ csrfField() }} 27 | 28 | @button({ type: 'submit', class: 'rounded-b-none' }) 29 | @svg('ph:trash-fill', { class: 'mr-2' }) 30 | Flush Redis Db 31 | @end 32 |
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 | ![Adocasts](https://github.com/adocasts/.github/blob/main/assets/brand-banner-rounded.png?raw=true) 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 | [![YouTube Badge](https://img.shields.io/youtube/channel/subscribers/UCTEKX3KQAJi7_0-_rSz0Edg?logo=YouTube&style=for-the-badge)](https://youtube.com/adocasts) 12 | [![Twitter Badge](https://img.shields.io/twitter/follow/adocasts?logo=twitter&logoColor=white&style=for-the-badge)](https://twitter.com/adocasts) 13 | [![Twitch Badge](https://img.shields.io/twitch/status/adocasts?logo=twitch&logoColor=white&style=for-the-badge)](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 | ![Database Diagram](https://github.com/adocasts/lets-learn-adonisjs-6/blob/main/public/db-schema.png?raw=true) 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 | 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 |
7 |
8 | @!form.input({ 9 | label: 'Title Search', 10 | name: 'search', 11 | type: 'search', 12 | value: filters.search 13 | }) 14 | 15 | @form.input({ type: 'select', label: 'Status', name: 'status' }) 16 | 17 | @each (status in movieStatuses) 18 | 21 | @endeach 22 | @end 23 | 24 | @form.input({ type: 'select', label: 'Sort', name: 'sort' }) 25 | @each (item in movieSortOptions) 26 | 29 | @endeach 30 | @end 31 | 32 | @button({ type: 'submit' }) 33 | Search 34 | @end 35 |
36 |
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 |
26 |
Writer:
27 |
28 | 29 | {{ movie.writer.fullName }} 30 | 31 |
32 |
33 |
34 |
Director:
35 |
36 | 37 | {{ movie.director.fullName }} 38 | 39 |
40 |
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 |
8 | {{ csrfField() }} 9 | 10 | @error('form') 11 | 14 | @end 15 | 16 | @if (auth.user.avatarUrl) 17 |
18 | 19 | 25 |
26 | @endif 27 | 28 | 29 | 30 | @!form.input({ 31 | type: 'file', 32 | label: 'Avatar', 33 | name: 'avatar' 34 | }) 35 | 36 | @!form.input({ 37 | label: 'Full Name', 38 | name: 'fullName', 39 | value: auth.user.fullName 40 | }) 41 | 42 | @!form.input({ 43 | label: 'Biography', 44 | name: 'description', 45 | type: 'textarea', 46 | value: profile.description 47 | }) 48 | 49 | @button({ type: 'submit' }) 50 | Update Profile 51 | @end 52 |
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 |
10 |
Writer:
11 |
12 | 13 | {{ movie.writer.fullName }} 14 | 15 |
16 |
17 |
18 |
Director:
19 |
20 | 21 | {{ movie.director.fullName }} 22 | 23 |
24 |
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 |
26 |
Writer:
27 |
28 | 29 | {{ movie.writer.fullName }} 30 | 31 |
32 |
33 |
34 |
Director:
35 |
36 | 37 | {{ movie.director.fullName }} 38 | 39 |
40 |
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 |
53 | {{ csrfField() }} 54 | 55 | @button({ type: 'submit' }) 56 | {{ movie.watchlist.length ? 'In Your Watchlist' : 'Add To Watchlist' }} 57 | @end 58 |
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 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | @each(movie in movies) 25 | 26 | 27 | 28 | 32 | 36 | 47 | 48 | @endeach 49 | 50 |
TitleStatusWriter/DirectorCount
{{ movie.title }}{{ movie.status.name }} 29 |
Writer: {{ movie.writer.fullName }}
30 |
Director: {{ movie.director.fullName }}
31 |
33 |
Cast Members: {{ movie.$extras.castMembers_count }}
34 |
Crew Members: {{ movie.$extras.crewMembers_count }}
35 |
37 | Edit 38 |
41 | {{ csrfField() }} 42 | 45 |
46 |
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 |
7 |
8 | @!form.input({ 9 | label: 'Title Search', 10 | name: 'search', 11 | type: 'search', 12 | value: filters.search 13 | }) 14 | 15 | @form.input({ type: 'select', label: 'Status', name: 'status' }) 16 | 17 | @each (status in movieStatuses) 18 | 21 | @endeach 22 | @end 23 | 24 | @form.input({ type: 'select', label: 'Watched', name: 'watched' }) 25 | 28 | 31 | 34 | @end 35 | 36 | @form.input({ type: 'select', label: 'Sort', name: 'sort' }) 37 | @each (item in movieSortOptions) 38 | 41 | @endeach 42 | @end 43 | 44 | @button({ type: 'submit' }) 45 | Search 46 | @end 47 |
48 |
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 |
58 | {{ csrfField() }} 59 | 60 | @button({ type: 'submit' }) 61 | {{ watchlist.watchedAt ? 'Mark As Unwatched' : 'Mark As Watched' }} 62 | @end 63 |
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 |
15 | {{ csrfField() }} 16 | 17 | @if (movie?.posterUrl) 18 |
19 | 20 | 26 |
27 | @endif 28 | 29 | 30 | 31 | @!form.input({ 32 | type: 'file', 33 | label: 'Poster', 34 | name: 'poster' 35 | }) 36 | 37 | @!form.input({ label: 'Title', name: 'title', value: movie?.title }) 38 | 39 | @!form.input({ 40 | type: 'date', 41 | label: 'Release Date', 42 | name: 'releasedAt', 43 | value: movie?.releasedAt.toFormat('yyyy-MM-dd') 44 | }) 45 | 46 | @form.input({ type: 'select', label: 'Status', name: 'statusId' }) 47 | @each(status in statuses) 48 | 51 | @endeach 52 | @end 53 | 54 | @form.input({ type: 'select', label: 'Writer', name: 'writerId' }) 55 | @each(cineast in cineasts) 56 | 59 | @endeach 60 | @end 61 | 62 | @form.input({ type: 'select', label: 'Director', name: 'directorId' }) 63 | @each(cineast in cineasts) 64 | 67 | @endeach 68 | @end 69 | 70 | @!form.input({ type: 'textarea', label: 'Summary', name: 'summary', value: movie?.summary }) 71 | 72 | @!form.input({ type: 'textarea', label: 'Abstract', name: 'abstract', value: movie?.abstract }) 73 | 74 |

Crew Members

75 | 76 |
77 |
78 | 100 |
101 | 102 | 103 |
104 | 105 | 106 |

Cast Members

107 | 108 |
109 |
110 | 132 |
133 | 134 | 135 |
136 | 137 | @button({ type: 'submit' }) 138 | {{ movie ? 'Update' : 'Create' }} Movie 139 | @end 140 |
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 | --------------------------------------------------------------------------------