mirror of
https://github.com/mmahdium/TBW.git
synced 2025-12-21 05:03:55 +01:00
first commit
This commit is contained in:
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# TBW
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
2
env.d.ts
vendored
Normal file
2
env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module '*.vue'
|
||||||
22
eslint.config.ts
Normal file
22
eslint.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="A simple and beautiful movie list app built with Vue 3 and Tailwind CSS (daisyUI).">
|
||||||
|
<title>To Be Watched</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
package.json
Normal file
44
package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "tbw",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "eslint . --fix",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
"@types/node": "^22.18.6",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"daisyui": "^5.3.6",
|
||||||
|
"eslint": "^9.33.0",
|
||||||
|
"eslint-plugin-vue": "~10.4.0",
|
||||||
|
"jiti": "^2.5.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"typescript": "~5.9.0",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.2",
|
||||||
|
"vue-tsc": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3751
pnpm-lock.yaml
generated
Normal file
3751
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
12
src/App.vue
Normal file
12
src/App.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import '@/style.css'
|
||||||
|
import NavBar from './components/NavBar.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NavBar />
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
52
src/components/MovieCard.vue
Normal file
52
src/components/MovieCard.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMoviesStore } from '@/stores/movies'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MovieType } from '@/types/Movie'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
movie: MovieType
|
||||||
|
loading: boolean | undefined
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'loaded', id: string): void }>()
|
||||||
|
|
||||||
|
const store = useMoviesStore()
|
||||||
|
|
||||||
|
const alreadyAdded = computed(() =>
|
||||||
|
store.movieList.some((movie) => movie.imdbID === props.movie.imdbID),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card card-side bg-base-100 shadow-md hover:shadow-lg transition rounded-sm">
|
||||||
|
<router-link :to="{ name: 'details', params: { id: props.movie.imdbID } }">
|
||||||
|
<figure
|
||||||
|
class="overflow-hidden flex items-center justify-center bg-gray-100 aspect-[2/3] w-32 sm:w-40 md:w-48 rounded-l-sm"
|
||||||
|
>
|
||||||
|
<span v-if="props.loading" class="loading loading-ring loading-xl text-primary"></span>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-show="!props.loading"
|
||||||
|
:src="props.movie.Poster"
|
||||||
|
:alt="props.movie.Title"
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
@load="emit('loaded', props.movie.imdbID)"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</router-link>
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<router-link :to="{ name: 'details', params: { id: props.movie.imdbID } }">
|
||||||
|
<h2 class="card-title text-lg">{{ props.movie.Title }}</h2>
|
||||||
|
<p class="text-sm text-gray-500">{{ props.movie.Year }}</p>
|
||||||
|
</router-link>
|
||||||
|
<div v-if="!alreadyAdded" class="card-actions justify-end mt-auto">
|
||||||
|
<button class="btn btn-sm btn-primary" @click="store.addMovie(props.movie)">Add</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card-actions justify-end mt-auto">
|
||||||
|
<button class="btn btn-sm btn-error" @click="store.removeMovie(props.movie.imdbID)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
74
src/components/MovieDetails.vue
Normal file
74
src/components/MovieDetails.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MovieDetailsType } from '@/types/Movie'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import ErrorAlert from './alerts/ErrorAlert.vue'
|
||||||
|
import { useMoviesStore } from '@/stores/movies'
|
||||||
|
|
||||||
|
const props = defineProps<{ movie: MovieDetailsType | undefined }>()
|
||||||
|
|
||||||
|
const imageLoaded = ref(false)
|
||||||
|
|
||||||
|
const store = useMoviesStore()
|
||||||
|
|
||||||
|
const alreadyAdded = computed(() =>
|
||||||
|
props.movie ? store.movieList.some((movie) => movie.imdbID === props.movie!.imdbID) : false,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ErrorAlert class="" v-if="props.movie?.Error" :message="props.movie.ErrorMessage" />
|
||||||
|
|
||||||
|
<div v-else class="hero bg-base-200 min-h-screen">
|
||||||
|
<!-- Loading state for hero content -->
|
||||||
|
<div v-if="props.movie === undefined" class="hero-content">
|
||||||
|
<span class="loading loading-ring loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actual hero content -->
|
||||||
|
<div v-else class="hero bg-base-200 min-h-screen">
|
||||||
|
<div class="hero-content flex-col lg:flex-row gap-12">
|
||||||
|
<!-- Poster -->
|
||||||
|
<figure class="flex-shrink-0">
|
||||||
|
<span v-if="!imageLoaded" class="loading loading-ring loading-lg text-primary"></span>
|
||||||
|
<img
|
||||||
|
v-show="imageLoaded"
|
||||||
|
:src="props.movie.Poster"
|
||||||
|
alt="Poster"
|
||||||
|
class="w-full max-w-lg rounded-lg shadow-2xl"
|
||||||
|
@load="imageLoaded = true"
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<!-- Text -->
|
||||||
|
<div class="max-w-2xl">
|
||||||
|
<h1 class="text-5xl font-bold">
|
||||||
|
{{ props.movie.Title }}
|
||||||
|
<span class="text-gray-500 text-sm">({{ props.movie.Year }})</span>
|
||||||
|
</h1>
|
||||||
|
<p class="py-6">{{ props.movie.Plot }}</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="badge badge-dash badge-primary">
|
||||||
|
Language: {{ props.movie.Language }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-dash badge-secondary">
|
||||||
|
Country: {{ props.movie.Country }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-dash badge-info">
|
||||||
|
IMDB Rating: {{ props.movie.imdbRating }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!alreadyAdded" class="card-actions mt-4">
|
||||||
|
<button class="btn -sm btn-primary" @click="store.addMovie(props.movie)">
|
||||||
|
Add to list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card-actions mt-4">
|
||||||
|
<button class="btn btn-error" @click="store.removeMovie(props.movie.imdbID)">
|
||||||
|
Remove from list
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/components/MovieList.vue
Normal file
38
src/components/MovieList.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MovieCard from './MovieCard.vue'
|
||||||
|
import type { MovieType } from '@/types/Movie'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
movies: MovieType[]
|
||||||
|
loadingImages: Record<string, boolean>
|
||||||
|
loadingMore: boolean
|
||||||
|
isSearch: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'loaded', id: string): void; (e: 'loadMore'): void }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p v-if="props.movies.length === 0" class="text-center text-gray-500 py-12">
|
||||||
|
No movies found.<br />
|
||||||
|
<RouterLink to="/add" class="text-black font-bold">Add a movie</RouterLink>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul v-else class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<li v-for="movie in props.movies" :key="movie.imdbID">
|
||||||
|
<MovieCard
|
||||||
|
:movie="movie"
|
||||||
|
:loading="props.loadingImages[movie.imdbID]"
|
||||||
|
@loaded="emit('loaded', $event)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="props.isSearch && props.movies.length > 0" class="flex justify-center mt-8">
|
||||||
|
<button class="btn btn-outline btn-secondary" @click="emit('loadMore')" :disabled="props.loadingMore">
|
||||||
|
<span v-if="props.loadingMore" class="loading loading-spinner loading-xs mr-2"></span>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
43
src/components/NavBar.vue
Normal file
43
src/components/NavBar.vue
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-10">
|
||||||
|
<div class="navbar-start">
|
||||||
|
<div class="dropdown">
|
||||||
|
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden" aria-label="Menu">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h8m-8 6h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="-1"
|
||||||
|
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow"
|
||||||
|
>
|
||||||
|
<li><RouterLink class="text-xl" to="/">Home</RouterLink></li>
|
||||||
|
<li><RouterLink class="text-xl" to="/list">List</RouterLink></li>
|
||||||
|
<li><RouterLink class="text-xl" to="/add">Add</RouterLink></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="btn btn-ghost text-xl" to="/">To Vue</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-center hidden lg:flex">
|
||||||
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
<li><RouterLink class="text-xl" to="/">Home</RouterLink></li>
|
||||||
|
<li><RouterLink class="text-xl" to="/list">List</RouterLink></li>
|
||||||
|
<li><RouterLink class="text-xl" to="/add">Add</RouterLink></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-end">
|
||||||
|
<RouterLink class="btn" to="/add">Get Started</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
42
src/components/SearchBar.vue
Normal file
42
src/components/SearchBar.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: string }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void; (e: 'submit'): void }>()
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const localQuery = ref(props.modelValue)
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
emit('submit')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<div class="join flex justify-center mb-8">
|
||||||
|
<label class="input join-item">
|
||||||
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search for a movie"
|
||||||
|
required
|
||||||
|
:value="props.modelValue"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-neutral join-item">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
21
src/components/alerts/ErrorAlert.vue
Normal file
21
src/components/alerts/ErrorAlert.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div role="alert" class="alert alert-error">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6 shrink-0 stroke-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Error! {{ props.message }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
63
src/lib/api.ts
Normal file
63
src/lib/api.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { MovieResponseType, MovieType } from '@/types/Movie'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_KEY = '595695c3' // I know this should not be here and I dont care
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseURL: 'https://www.omdbapi.com/',
|
||||||
|
timeout: 6969,
|
||||||
|
params: {
|
||||||
|
apikey: API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const searchMovies = async (query: string): Promise<MovieResponseType> => {
|
||||||
|
const response = await instance.get(``, {
|
||||||
|
params: {
|
||||||
|
s: query,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// if (response.data.Response === 'False') {
|
||||||
|
// throw new Error(response.data.Error)
|
||||||
|
// }
|
||||||
|
|
||||||
|
const data: MovieResponseType = {
|
||||||
|
Search: response.data.Search as MovieType[],
|
||||||
|
totalResults: response.data.totalResults,
|
||||||
|
Response: response.data.Response === 'True',
|
||||||
|
ErrorMessage: response.data.Error || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadMoreMovies = async (query: string, page: number) => {
|
||||||
|
const response = await instance.get(``, {
|
||||||
|
params: {
|
||||||
|
s: query,
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.Response === 'False') {
|
||||||
|
throw new Error(response.data.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMovie = async (id: string) => {
|
||||||
|
const response = await instance.get(``, {
|
||||||
|
params: {
|
||||||
|
i: id,
|
||||||
|
plot: 'full',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.Response === 'False') {
|
||||||
|
throw new Error(response.data.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
37
src/router/index.ts
Normal file
37
src/router/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
import NotFoundView from '@/views/NotFoundView.vue'
|
||||||
|
import ListView from '@/views/ListView.vue'
|
||||||
|
import DetailsView from '@/views/DetailsView.vue'
|
||||||
|
import AddView from '@/views/AddView.vue'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFoundView },
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/list',
|
||||||
|
name: 'list',
|
||||||
|
component: ListView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/details/:id',
|
||||||
|
name: 'details',
|
||||||
|
component: DetailsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/add',
|
||||||
|
name: 'add',
|
||||||
|
component: AddView,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
45
src/stores/movies.ts
Normal file
45
src/stores/movies.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { MovieType } from '@/types/Movie'
|
||||||
|
|
||||||
|
function saveMovies(movies: MovieType[]) {
|
||||||
|
localStorage.setItem('movies', JSON.stringify(movies))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMovies(): MovieType[] {
|
||||||
|
const movies = localStorage.getItem('movies')
|
||||||
|
return movies ? JSON.parse(movies) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMoviesStore = defineStore('movies', () => {
|
||||||
|
const movieList = ref<MovieType[]>(loadMovies())
|
||||||
|
|
||||||
|
function addMovie(movie: MovieType) {
|
||||||
|
if (!movieList.value.find((m) => m.imdbID === movie.imdbID)) {
|
||||||
|
movieList.value.push(movie)
|
||||||
|
saveMovies(movieList.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMovie(movieId: string) {
|
||||||
|
movieList.value = movieList.value.filter((m) => m.imdbID !== movieId)
|
||||||
|
saveMovies(movieList.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { movieList, addMovie, removeMovie }
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: add state for search page
|
||||||
|
|
||||||
|
export const useSearchPageStore = defineStore('searchPage', () => {
|
||||||
|
const movieList = ref<MovieType[]>()
|
||||||
|
const searchPage = ref(0)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
function setState(page: number, query: string, movies: MovieType[]) {
|
||||||
|
searchPage.value = page
|
||||||
|
searchQuery.value = query
|
||||||
|
movieList.value = movies
|
||||||
|
}
|
||||||
|
return { searchPage, searchQuery, movieList, setState }
|
||||||
|
})
|
||||||
2
src/style.css
Normal file
2
src/style.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@plugin "daisyui";
|
||||||
28
src/types/Movie.ts
Normal file
28
src/types/Movie.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export type MovieType = {
|
||||||
|
Title: string
|
||||||
|
Year: string
|
||||||
|
imdbID: string
|
||||||
|
Type: string
|
||||||
|
Poster: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MovieResponseType = {
|
||||||
|
Search: MovieType[]
|
||||||
|
totalResults: string
|
||||||
|
Response: boolean
|
||||||
|
ErrorMessage: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MovieDetailsType = {
|
||||||
|
Title: string
|
||||||
|
Year: string
|
||||||
|
imdbID: string
|
||||||
|
Type: string
|
||||||
|
Poster: string
|
||||||
|
Plot: string
|
||||||
|
Language: string
|
||||||
|
Country: string
|
||||||
|
imdbRating: string
|
||||||
|
Error: boolean
|
||||||
|
ErrorMessage: string
|
||||||
|
}
|
||||||
110
src/views/AddView.vue
Normal file
110
src/views/AddView.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { loadMoreMovies, searchMovies } from '@/lib/api'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import SearchBar from '@/components/SearchBar.vue'
|
||||||
|
import MovieList from '@/components/MovieList.vue'
|
||||||
|
import type { MovieType } from '@/types/Movie'
|
||||||
|
import ErrorAlert from '@/components/alerts/ErrorAlert.vue'
|
||||||
|
import { useSearchPageStore } from '@/stores/movies'
|
||||||
|
|
||||||
|
const movies = ref<MovieType[]>()
|
||||||
|
const seachError = ref<string>('')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const loadingImages = ref<Record<string, boolean>>({})
|
||||||
|
const searchPage = ref(1)
|
||||||
|
const isSearching = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
|
const state = useSearchPageStore()
|
||||||
|
|
||||||
|
async function searchMovie() {
|
||||||
|
try {
|
||||||
|
isSearching.value = true
|
||||||
|
searchPage.value = 1
|
||||||
|
const result = await searchMovies(searchQuery.value)
|
||||||
|
if (!result.Response) {
|
||||||
|
seachError.value = result.ErrorMessage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seachError.value = ''
|
||||||
|
const previouslyLoaded = Object.keys(loadingImages.value).filter((id) =>
|
||||||
|
result.Search.some((m: MovieType) => m.imdbID === id),
|
||||||
|
)
|
||||||
|
movies.value = result.Search
|
||||||
|
|
||||||
|
state.setState(searchPage.value, searchQuery.value, result.Search)
|
||||||
|
|
||||||
|
for (const m of result.Search) {
|
||||||
|
if (previouslyLoaded.includes(m.imdbID)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loadingImages.value[m.imdbID] = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
seachError.value = (error as Error).message
|
||||||
|
} finally {
|
||||||
|
isSearching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
try {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
searchPage.value++
|
||||||
|
const result = await loadMoreMovies(searchQuery.value, searchPage.value)
|
||||||
|
movies.value.push(...result.Search)
|
||||||
|
state.setState(searchPage.value, searchQuery.value, movies.value)
|
||||||
|
for (const m of result.Search) {
|
||||||
|
loadingImages.value[m.imdbID] = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
searchPage.value = 1
|
||||||
|
seachError.value = (error as Error).message
|
||||||
|
} finally {
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoaded(id: string) {
|
||||||
|
loadingImages.value[id] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (state.searchQuery !== '' && state.searchPage !== 0 && state.movieList.length > 0) {
|
||||||
|
searchQuery.value = state.searchQuery
|
||||||
|
searchPage.value = state.searchPage
|
||||||
|
movies.value = state.movieList
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-3xl font-bold text-center mb-8">Add a Movie</h1>
|
||||||
|
|
||||||
|
<SearchBar v-model="searchQuery" @submit="searchMovie" />
|
||||||
|
|
||||||
|
<div v-if="isSearching" class="flex justify-center my-12">
|
||||||
|
<span class="loading loading-ring loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MovieList
|
||||||
|
v-else-if="movies && movies.length > 0"
|
||||||
|
:movies="movies"
|
||||||
|
:loading-images="loadingImages"
|
||||||
|
:loading-more="isLoadingMore"
|
||||||
|
@loaded="handleLoaded"
|
||||||
|
@loadMore="loadMore"
|
||||||
|
:is-search="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p v-else class="text-center text-gray-500">Search for a movie to add it to your list</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<ErrorAlert v-if="seachError" :message="seachError" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
41
src/views/DetailsView.vue
Normal file
41
src/views/DetailsView.vue
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import MovieDetails from '@/components/MovieDetails.vue'
|
||||||
|
import { getMovie } from '@/lib/api'
|
||||||
|
import type { MovieDetailsType } from '@/types/Movie'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const movie = ref<MovieDetailsType>()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await getMovie(route.params.id as string)
|
||||||
|
movie.value = response
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
movie.value = {
|
||||||
|
Title: '',
|
||||||
|
Year: '',
|
||||||
|
imdbID: '',
|
||||||
|
Type: '',
|
||||||
|
Poster: '',
|
||||||
|
Plot: '',
|
||||||
|
Language: '',
|
||||||
|
Country: '',
|
||||||
|
imdbRating: '',
|
||||||
|
Error: true,
|
||||||
|
ErrorMessage: (error as Error).message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<MovieDetails :movie="movie" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
40
src/views/HomeView.vue
Normal file
40
src/views/HomeView.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<h1 class="text-5xl font-bold text-center mb-12">
|
||||||
|
Welcome to <br /><span class="text-6xl font-bold text-center gradient-text"
|
||||||
|
>To Be Watched</span
|
||||||
|
>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-xl text-center mb-12">
|
||||||
|
A simple and beautiful movie list app built with Vue 3 and Tailwind CSS (daisyUI).
|
||||||
|
</p>
|
||||||
|
<RouterLink to="/add" class="btn btn-primary btn-lg">Get Started</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(to right, black 0%, red 50%, blue 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-size: 200% auto;
|
||||||
|
animation: gradient 3s ease infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient {
|
||||||
|
0% {
|
||||||
|
background-position: left center;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: right center;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
32
src/views/ListView.vue
Normal file
32
src/views/ListView.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMoviesStore } from '@/stores/movies'
|
||||||
|
import MovieList from '@/components/MovieList.vue'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const loadingImages = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const store = useMoviesStore()
|
||||||
|
|
||||||
|
function handleLoaded(id: string) {
|
||||||
|
loadingImages.value[id] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadingImages.value = Object.fromEntries(store.movieList.map((movie) => [movie.imdbID, true]))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<MovieList
|
||||||
|
v-if="store.movieList"
|
||||||
|
:movies="store.movieList"
|
||||||
|
:loading-images="loadingImages"
|
||||||
|
:loading-more="false"
|
||||||
|
@loaded="handleLoaded"
|
||||||
|
:is-search="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
11
src/views/NotFoundView.vue
Normal file
11
src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<h1 class="text-center">Not found</h1>
|
||||||
|
<router-link to="/">Home</router-link>
|
||||||
|
<div class="container mx-auto px-4 py-8"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user