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