This commit is contained in:
parent
0f52ff0cef
commit
a510899ff1
17 changed files with 309 additions and 103 deletions
|
|
@ -5,6 +5,7 @@
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.jj
|
.jj
|
||||||
|
.woodpecker
|
||||||
.zed
|
.zed
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
README.md
|
README.md
|
||||||
|
|
@ -12,4 +13,5 @@ cspell.json
|
||||||
dist
|
dist
|
||||||
docker-compose*
|
docker-compose*
|
||||||
justfile
|
justfile
|
||||||
|
mise.toml
|
||||||
node_modules
|
node_modules
|
||||||
|
|
|
||||||
17
.woodpecker/publish_instable.yaml
Normal file
17
.woodpecker/publish_instable.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
branch: instable
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build_publish
|
||||||
|
image: woodpeckerci/plugin-kaniko:latest
|
||||||
|
pull: true
|
||||||
|
settings:
|
||||||
|
auto_tag: true
|
||||||
|
cache: true
|
||||||
|
registry: git.gcch.fr
|
||||||
|
repo: gcch/journal-media-vue
|
||||||
|
username:
|
||||||
|
from_secret: DOCKER_USER
|
||||||
|
password:
|
||||||
|
from_secret: DOCKER_PASSWORD
|
||||||
|
|
@ -20,15 +20,12 @@
|
||||||
"lsp": {
|
"lsp": {
|
||||||
"eslint": {
|
"eslint": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"configFile": "./cfg/eslint.config.mts",
|
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"useFlatConfig": true
|
"useFlatConfig": true
|
||||||
},
|
},
|
||||||
"options": {
|
"problems": {
|
||||||
"configFile": "./cfg/eslint.config.mts",
|
"shortenToSingleLine": true
|
||||||
"overrideConfigFile": "./cfg/eslint.config.mts"
|
}
|
||||||
},
|
|
||||||
"overrideConfigFile": "./cfg/eslint.config.mts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
Dockerfile
12
Dockerfile
|
|
@ -1,13 +1,12 @@
|
||||||
FROM oven/bun:slim AS base
|
FROM oven/bun:slim AS base
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Installe les dépendences.
|
# Installe les dépendances de développement.
|
||||||
FROM base AS install
|
FROM base AS install
|
||||||
RUN mkdir -p /temp/dev
|
RUN mkdir -p /temp/dev
|
||||||
COPY package.json bun.lock /temp/dev/
|
COPY package.json bun.lock /temp/dev/
|
||||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||||
|
# Installe les dépendances de production.
|
||||||
# Installe les dépendences de production.
|
|
||||||
RUN mkdir -p /temp/prod
|
RUN mkdir -p /temp/prod
|
||||||
COPY package.json bun.lock /temp/prod/
|
COPY package.json bun.lock /temp/prod/
|
||||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||||
|
|
@ -16,16 +15,15 @@ RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||||
FROM base AS prerelease
|
FROM base AS prerelease
|
||||||
COPY --from=install /temp/dev/node_modules/ node_modules
|
COPY --from=install /temp/dev/node_modules/ node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Compile le projet.
|
# Compile le projet.
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
RUN bun --bun vite build
|
RUN bun --bun vite build
|
||||||
|
|
||||||
# Créé le nécessaire pour Angie.
|
# Créé le nécessaire pour Angie, le proxy inversé servant l'application.
|
||||||
FROM docker.angie.software/angie:minimal AS release
|
FROM docker.angie.software/angie:minimal AS release
|
||||||
COPY --from=prerelease /usr/src/app/dist/ /usr/share/angie/html/
|
COPY --from=prerelease /usr/src/app/dist/ /usr/share/angie/html/
|
||||||
COPY ./docker/default.conf /etc/angie/http.d/default.conf
|
COPY ./docker/default.conf /etc/angie/http.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
# Démarre Angie.
|
# Démarre Angie.
|
||||||
|
EXPOSE 80
|
||||||
CMD ["angie", "-g", "daemon off;"]
|
CMD ["angie", "-g", "daemon off;"]
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"hexCase": "lower",
|
"hexCase": "lower",
|
||||||
"hexColorLength": "short",
|
"hexColorLength": "short",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
"keyframeSelectorNotation": "keyword",
|
"keyframeSelectorNotation": "percentage",
|
||||||
"lineBreak": "lf",
|
"lineBreak": "lf",
|
||||||
"linebreakInPseudoParens": true,
|
"linebreakInPseudoParens": true,
|
||||||
"omitNumberLeadingZero": false,
|
"omitNumberLeadingZero": false,
|
||||||
|
|
|
||||||
|
|
@ -65,5 +65,11 @@ export default defineConfigWithVueTs(
|
||||||
"vue/v-for-delimiter-style": "off",
|
"vue/v-for-delimiter-style": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ts/no-annoying-rules",
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-misused-spread": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
perfectionist.configs["recommended-natural"],
|
perfectionist.configs["recommended-natural"],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
75
eslint.config.mts
Normal file
75
eslint.config.mts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
|
||||||
|
import perfectionist from "eslint-plugin-perfectionist";
|
||||||
|
import vue from "eslint-plugin-vue";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,ts,mts,vue}"],
|
||||||
|
languageOptions: { ecmaVersion: "latest", globals: { ...globals.browser, ...globals.es2025 } },
|
||||||
|
name: "app/files-to-lint",
|
||||||
|
},
|
||||||
|
{ ignores: [".cache/", "dist/", "node_modules/"], name: "app/files-to-ignore" },
|
||||||
|
vueTsConfigs.strictTypeChecked,
|
||||||
|
vueTsConfigs.stylisticTypeChecked,
|
||||||
|
vue.configs["flat/recommended"],
|
||||||
|
{
|
||||||
|
name: "app/no-vue-formatting",
|
||||||
|
rules: {
|
||||||
|
"vue/array-bracket-newline": "off",
|
||||||
|
"vue/array-bracket-spacing": "off",
|
||||||
|
"vue/array-element-newline": "off",
|
||||||
|
"vue/arrow-spacing": "off",
|
||||||
|
"vue/attributes-order": ["error", { alphabetical: true }],
|
||||||
|
"vue/block-spacing": "off",
|
||||||
|
"vue/block-tag-newline": "off",
|
||||||
|
"vue/brace-style": "off",
|
||||||
|
"vue/comma-dangle": "off",
|
||||||
|
"vue/comma-spacing": "off",
|
||||||
|
"vue/comma-style": "off",
|
||||||
|
"vue/dot-location": "off",
|
||||||
|
"vue/first-attribute-linebreak": "off",
|
||||||
|
"vue/func-call-spacing": "off",
|
||||||
|
"vue/html-closing-bracket-newline": "off",
|
||||||
|
"vue/html-closing-bracket-spacing": "off",
|
||||||
|
"vue/html-comment-content-newline": "off",
|
||||||
|
"vue/html-comment-content-spacing": "off",
|
||||||
|
"vue/html-comment-indent": "off",
|
||||||
|
"vue/html-indent": "off",
|
||||||
|
"vue/html-quotes": "off",
|
||||||
|
"vue/html-self-closing": "off",
|
||||||
|
"vue/key-spacing": "off",
|
||||||
|
"vue/keyword-spacing": "off",
|
||||||
|
"vue/max-attributes-per-line": "off",
|
||||||
|
"vue/max-len": "off",
|
||||||
|
"vue/multiline-html-element-content-newline": "off",
|
||||||
|
"vue/multiline-ternary": "off",
|
||||||
|
"vue/new-line-between-multi-line-property": "off",
|
||||||
|
"vue/no-extra-parens": "off",
|
||||||
|
"vue/no-multi-spaces": "off",
|
||||||
|
"vue/no-spaces-around-equal-signs-in-attribute": "off",
|
||||||
|
"vue/object-curly-newline": "off",
|
||||||
|
"vue/object-curly-spacing": "off",
|
||||||
|
"vue/object-property-newline": "off",
|
||||||
|
"vue/operator-linebreak": "off",
|
||||||
|
"vue/padding-line-between-blocks": "off",
|
||||||
|
"vue/padding-line-between-tags": "off",
|
||||||
|
"vue/padding-lines-in-component-definition": "off",
|
||||||
|
"vue/quote-props": "off",
|
||||||
|
"vue/script-indent": "off",
|
||||||
|
"vue/singleline-html-element-content-newline": "off",
|
||||||
|
"vue/space-in-parens": "off",
|
||||||
|
"vue/space-infix-ops": "off",
|
||||||
|
"vue/space-unary-ops": "off",
|
||||||
|
"vue/template-curly-spacing": "off",
|
||||||
|
"vue/v-for-delimiter-style": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ts/no-annoying-rules",
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-misused-spread": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
perfectionist.configs["recommended-natural"],
|
||||||
|
);
|
||||||
6
justfile
6
justfile
|
|
@ -13,6 +13,7 @@ stylelintConfigFile := "cfg/stylelint.config.mjs"
|
||||||
# Variables de cache.
|
# Variables de cache.
|
||||||
|
|
||||||
cacheFolder := ".cache"
|
cacheFolder := ".cache"
|
||||||
|
esLintCacheFile := "eslintcache"
|
||||||
prettierCacheFile := "prettiercache"
|
prettierCacheFile := "prettiercache"
|
||||||
stylelintCacheFile := "stylelintcache"
|
stylelintCacheFile := "stylelintcache"
|
||||||
|
|
||||||
|
|
@ -80,7 +81,10 @@ lint-css:
|
||||||
|
|
||||||
# Analyse le code TypeScript et Vue.
|
# Analyse le code TypeScript et Vue.
|
||||||
lint-js fix="":
|
lint-js fix="":
|
||||||
bun --bun eslint --config "{{ esLintConfigFile }}" {{ fix }}
|
bun --bun eslint \
|
||||||
|
--cache --cache-location "{{ cacheFolder }}/{{ esLintCacheFile }}" \
|
||||||
|
--config "{{ esLintConfigFile }}" \
|
||||||
|
{{ fix }}
|
||||||
|
|
||||||
# Analyse le code CSS avec ESLint.
|
# Analyse le code CSS avec ESLint.
|
||||||
lint-css-eslint fix="":
|
lint-css-eslint fix="":
|
||||||
|
|
|
||||||
3
mise.toml
Normal file
3
mise.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[tools]
|
||||||
|
bun = "latest"
|
||||||
|
just = "latest"
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes loading {
|
@keyframes loading {
|
||||||
from {
|
0% {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
75% {
|
75% {
|
||||||
content: "...";
|
content: "...";
|
||||||
}
|
}
|
||||||
to {
|
100% {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
src/components/TmdbSearchResults.vue
Normal file
53
src/components/TmdbSearchResults.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TmdbMovieSearchResponse } from "@/libs/apis/tmdb/schemas";
|
||||||
|
|
||||||
|
const { searchResults } = defineProps<{
|
||||||
|
searchResults: typeof TmdbMovieSearchResponse.Type.results | undefined;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p v-show="!searchResults || searchResults?.length === 0">Aucun résultat.</p>
|
||||||
|
<table v-show="searchResults?.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Nom</th>
|
||||||
|
<th scope="col">Année</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="result in searchResults" :key="result.id">
|
||||||
|
<th class="name" scope="row">{{ result.original_title }}</th>
|
||||||
|
<td class="release-date">{{ result.release_date }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css" scoped>
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
:is(td, th) {
|
||||||
|
padding: var(--s-4);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
font-weight: 120;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-date {
|
||||||
|
min-inline-size: 6rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { TmdbMovieSearchQueryParams } from "./schemas";
|
import type { TmdbMovieSearchQueryParams } from "./schemas";
|
||||||
|
|
||||||
export const DEFAULT_SEARCH_MOVIE_PARAMS: TmdbMovieSearchQueryParams = {
|
export const DEFAULT_SEARCH_MOVIE_PARAMS = {
|
||||||
include_adult: false,
|
include_adult: false,
|
||||||
language: "fr",
|
language: "fr",
|
||||||
page: 1,
|
page: 1,
|
||||||
query: "",
|
query: "",
|
||||||
region: "fr-FR",
|
region: "fr-FR",
|
||||||
};
|
} satisfies TmdbMovieSearchQueryParams;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { Schema } from "effect";
|
import { Schema } from "effect";
|
||||||
|
|
||||||
|
// Requête
|
||||||
|
|
||||||
export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQueryParams>("TmdbMovieSearchArgs")({
|
export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQueryParams>("TmdbMovieSearchArgs")({
|
||||||
include_adult: Schema.Boolean.pipe(
|
include_adult: Schema.Boolean.pipe(
|
||||||
Schema.propertySignature,
|
Schema.propertySignature,
|
||||||
|
|
@ -22,26 +24,30 @@ export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQuer
|
||||||
year: Schema.String.pipe(Schema.optional),
|
year: Schema.String.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
// Réponse
|
||||||
|
|
||||||
export class TmdbMovieSearchResponse extends Schema.Class<TmdbMovieSearchResponse>("TmdbMovieSearchResponse")({
|
export class TmdbMovieSearchResponse extends Schema.Class<TmdbMovieSearchResponse>("TmdbMovieSearchResponse")({
|
||||||
page: Schema.NonNegativeInt,
|
page: Schema.NonNegativeInt,
|
||||||
results: Schema.Array(
|
results: Schema.Array(TmdbMovieSearchResponseResults),
|
||||||
Schema.Struct({
|
|
||||||
adult: Schema.Boolean,
|
|
||||||
backdrop_path: Schema.Union(Schema.String, Schema.Null),
|
|
||||||
genre_ids: Schema.Array(Schema.NonNegativeInt),
|
|
||||||
id: Schema.NonNegativeInt,
|
|
||||||
original_language: Schema.String,
|
|
||||||
original_title: Schema.String,
|
|
||||||
overview: Schema.String,
|
|
||||||
popularity: Schema.Number,
|
|
||||||
poster_path: Schema.Union(Schema.String, Schema.Null),
|
|
||||||
release_date: Schema.String,
|
|
||||||
title: Schema.String,
|
|
||||||
video: Schema.Boolean,
|
|
||||||
vote_average: Schema.Number,
|
|
||||||
vote_count: Schema.NonNegativeInt,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
total_pages: Schema.NonNegativeInt,
|
total_pages: Schema.NonNegativeInt,
|
||||||
total_results: Schema.NonNegativeInt,
|
total_results: Schema.NonNegativeInt,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class TmdbMovieSearchResponseResults
|
||||||
|
extends Schema.Class<TmdbMovieSearchResponseResults>("TmdbMovieSearchResponseResults")({
|
||||||
|
adult: Schema.Boolean,
|
||||||
|
backdrop_path: Schema.Union(Schema.String, Schema.Null),
|
||||||
|
genre_ids: Schema.Array(Schema.NonNegativeInt),
|
||||||
|
id: Schema.NonNegativeInt,
|
||||||
|
original_language: Schema.String,
|
||||||
|
original_title: Schema.String,
|
||||||
|
overview: Schema.String,
|
||||||
|
popularity: Schema.Number,
|
||||||
|
poster_path: Schema.Union(Schema.String, Schema.Null),
|
||||||
|
release_date: Schema.String,
|
||||||
|
title: Schema.String,
|
||||||
|
video: Schema.Boolean,
|
||||||
|
vote_average: Schema.Number,
|
||||||
|
vote_count: Schema.NonNegativeInt,
|
||||||
|
})
|
||||||
|
{}
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,17 @@ import { UrlParams } from "@effect/platform";
|
||||||
import { Effect, pipe } from "effect";
|
import { Effect, pipe } from "effect";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform les valeurs d'un `FormData` en `Record` trié.
|
* Transforme les valeurs d'un `FormData` en `Record` trié.
|
||||||
*
|
*
|
||||||
* @param formData Les valeurs d'un formulaire.
|
* @param formData Les valeurs d'un formulaire.
|
||||||
* @returns Un `Effect` des valeurs.
|
* @returns Un `Effect` des valeurs.
|
||||||
*/
|
*/
|
||||||
export const transformFormDataToRecord = (
|
const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
|
||||||
formData: FormData,
|
|
||||||
): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
|
|
||||||
pipe(
|
pipe(
|
||||||
Effect.succeed(Array.from(formData.entries())),
|
Effect.succeed(Array.from(formData.entries())),
|
||||||
// @ts-expect-error -- Impossible de typer les valeurs de FormData comme string.
|
// @ts-expect-error -- Impossible de typer les valeurs de FormData comme string.
|
||||||
Effect.andThen(formData => new URLSearchParams(formData)),
|
Effect.andThen(formData => new URLSearchParams(formData)),
|
||||||
|
// La conversion en URLSearchParams permet de trier les entrées.
|
||||||
Effect.andThen((urlSearchParams: URLSearchParams) => {
|
Effect.andThen((urlSearchParams: URLSearchParams) => {
|
||||||
urlSearchParams.sort();
|
urlSearchParams.sort();
|
||||||
return urlSearchParams;
|
return urlSearchParams;
|
||||||
|
|
@ -23,3 +22,5 @@ export const transformFormDataToRecord = (
|
||||||
Effect.andThen((urlSearchParams: URLSearchParams) => UrlParams.fromInput(urlSearchParams)),
|
Effect.andThen((urlSearchParams: URLSearchParams) => UrlParams.fromInput(urlSearchParams)),
|
||||||
Effect.andThen((urlParams: UrlParams.UrlParams) => UrlParams.toRecord(urlParams)),
|
Effect.andThen((urlParams: UrlParams.UrlParams) => UrlParams.toRecord(urlParams)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export default { formDataToRecord };
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { MEDIA_TYPES } from "@/db/schemas/constants";
|
import type { NonEmptyArray } from "effect/Array";
|
||||||
import { DEFAULT_SEARCH_MOVIE_PARAMS } from "@/libs/apis/tmdb/constants";
|
import type { Ref } from "vue";
|
||||||
import { TmdbMovieSearchQueryParams, TmdbMovieSearchResponse } from "@/libs/apis/tmdb/schemas";
|
|
||||||
import { SearchPageQueryParams } from "@/libs/search/schemas";
|
import TmdbSearchResults from "@/components/TmdbSearchResults.vue";
|
||||||
import { transformFormDataToRecord } from "@/libs/search/utils";
|
import { MEDIA_TYPES } from "@/db/schemas/constants.ts";
|
||||||
import { getCurrentYear } from "@/libs/utils/dates";
|
import { DEFAULT_SEARCH_MOVIE_PARAMS } from "@/libs/apis/tmdb/constants.ts";
|
||||||
import { PrettyLogger } from "@/services/logger";
|
import { TmdbMovieSearchQueryParams, TmdbMovieSearchResponse } from "@/libs/apis/tmdb/schemas.ts";
|
||||||
import { RuntimeClient } from "@/services/runtime-client";
|
import { SearchPageQueryParams } from "@/libs/search/schemas.ts";
|
||||||
import { TmdbApi } from "@/services/tmdb-api";
|
import Search from "@/libs/search/search.ts";
|
||||||
|
import { getCurrentYear } from "@/libs/utils/dates.ts";
|
||||||
|
import { PrettyLogger } from "@/services/logger.ts";
|
||||||
|
import { RuntimeClient } from "@/services/runtime-client.ts";
|
||||||
|
import { TmdbApi } from "@/services/tmdb-api.ts";
|
||||||
import { Effect, pipe, Schema } from "effect";
|
import { Effect, pipe, Schema } from "effect";
|
||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
@ -16,54 +20,82 @@
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Le formulaire reçoit en valeurs initiales les paramètres de l'URL.
|
||||||
|
* Quand le formulaire est soumis, les paramètres de l'URL sont mis à jour.
|
||||||
|
* Un effet est déclenché à chaque mise à jour des paramètres d'URL : une nouvelle recherche est déclenchée et les résultats sont mis à jour.
|
||||||
|
*/
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
/** L'année courante pour la limite supérieure du champs Année de la recherché. */
|
||||||
const currentYear: number = getCurrentYear();
|
const currentYear: number = getCurrentYear();
|
||||||
|
|
||||||
const parsedQueryParams = computed(() => Schema.decodeUnknown(SearchPageQueryParams)(route.query));
|
/** Effet des paramètres validés de la route. */
|
||||||
|
const routeQueryParams = computed(() => Schema.decodeUnknown(SearchPageQueryParams)(route.query));
|
||||||
|
/** Le formulaire de recherche. */
|
||||||
const form = useTemplateRef("form");
|
const form = useTemplateRef("form");
|
||||||
const formData = ref<SearchPageQueryParams>();
|
/** Les valeurs du formulaire de recherche. */
|
||||||
const searchResults = ref<TmdbMovieSearchResponse>();
|
const searchFormData = ref<SearchPageQueryParams>();
|
||||||
|
/** Le retour de la requête de recherche de films auprès de l'API TMDB. */
|
||||||
|
const search = ref<TmdbMovieSearchResponse>();
|
||||||
|
/** Les résultats de la requête de recherche de films auprès de l'API TMDB. */
|
||||||
|
const searchResults = computed(() => search.value?.results);
|
||||||
|
/** L'état de chargement de la requête auprès de l'API TMDB. */
|
||||||
|
const loading: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
const updateUrlQuery = async (event?: Event): Promise<void> => {
|
||||||
|
event?.preventDefault();
|
||||||
|
|
||||||
const updateQueryParams = async (event?: Event): Promise<void> => {
|
|
||||||
if (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
await pipe(
|
await pipe(
|
||||||
Effect.fromNullable(form.value),
|
Effect.fromNullable(form.value),
|
||||||
Effect.andThen(form => new FormData(form)),
|
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
|
||||||
Effect.andThen(formData => transformFormDataToRecord(formData)),
|
Effect.andThen((searchFormData: FormData) => Search.formDataToRecord(searchFormData)),
|
||||||
Effect.tap(queryParams => router.push({ force: true, query: queryParams })),
|
// Met à jour les paramètres de l'URL.
|
||||||
Effect.catchAll(error => Effect.succeed(error)),
|
Effect.tap((routeQueryParams: Record<string, NonEmptyArray<string> | string>) =>
|
||||||
Effect.tap(Effect.logInfo),
|
router.push({ force: true, query: routeQueryParams })
|
||||||
|
),
|
||||||
|
Effect.tapError(Effect.logError),
|
||||||
|
Effect.ignore,
|
||||||
|
Effect.provide(PrettyLogger),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const resetForm = async (event: Event): Promise<void> => {
|
|
||||||
|
const resetInitialState = async (event: Event): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
form.value?.reset();
|
form.value?.reset();
|
||||||
formData.value = {
|
searchFormData.value = {
|
||||||
query: "",
|
query: "",
|
||||||
type: MEDIA_TYPES.FILM,
|
type: MEDIA_TYPES.FILM,
|
||||||
year: "",
|
year: "",
|
||||||
};
|
};
|
||||||
router.push({ force: true });
|
search.value = undefined;
|
||||||
|
await router.push({ force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeSearch = async (): Promise<TmdbMovieSearchResponse | undefined> =>
|
const getSearchResultsFromApi = async (): Promise<TmdbMovieSearchResponse | undefined> =>
|
||||||
await RuntimeClient.runPromise(
|
await RuntimeClient.runPromise(
|
||||||
Effect.gen(function*() {
|
Effect.gen(function*() {
|
||||||
// NOTE: Ne gère que la recherche de films pour l'instant.
|
// NOTE: Ne gère que la recherche de films pour l'instant.
|
||||||
const tmdbApi = yield* TmdbApi;
|
const tmdbApi: TmdbApi = yield* TmdbApi;
|
||||||
const validQueryParams = yield* parsedQueryParams.value;
|
|
||||||
const queryArgs = yield* Schema.decode(TmdbMovieSearchQueryParams)({
|
const searchArgs: TmdbMovieSearchQueryParams = yield* Effect.gen(function*() {
|
||||||
...DEFAULT_SEARCH_MOVIE_PARAMS,
|
return yield* pipe(
|
||||||
primary_release_year: validQueryParams.year,
|
routeQueryParams.value,
|
||||||
query: validQueryParams.query,
|
Effect.andThen(params =>
|
||||||
|
Schema.decode(TmdbMovieSearchQueryParams)({
|
||||||
|
...DEFAULT_SEARCH_MOVIE_PARAMS,
|
||||||
|
primary_release_year: params.year,
|
||||||
|
query: params.query,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return yield* pipe(
|
return yield* pipe(
|
||||||
tmdbApi.searchMovie(queryArgs),
|
tmdbApi.searchMovie(searchArgs),
|
||||||
Effect.tapError(Effect.logError),
|
Effect.tapError(Effect.logError),
|
||||||
Effect.orElseSucceed(() => undefined),
|
Effect.orElseSucceed(() => undefined),
|
||||||
Effect.tap(Effect.logInfo),
|
Effect.tap(Effect.logInfo),
|
||||||
|
|
@ -71,12 +103,19 @@
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const updateResults = async (): Promise<void> =>
|
|
||||||
|
const updateSearchResults = async (): Promise<void> =>
|
||||||
pipe(
|
pipe(
|
||||||
parsedQueryParams.value,
|
routeQueryParams.value,
|
||||||
Effect.tap(async (args: SearchPageQueryParams) => {
|
Effect.tap(async (args: SearchPageQueryParams) => {
|
||||||
formData.value = { ...args };
|
loading.value = true;
|
||||||
searchResults.value = await executeSearch();
|
|
||||||
|
// Met à jour les valeurs du formulaire.
|
||||||
|
searchFormData.value = { ...args };
|
||||||
|
// Récupère les résultats d'une recherche avec les nouveaux termes.
|
||||||
|
search.value = await getSearchResultsFromApi();
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
}),
|
}),
|
||||||
Effect.tapError(Effect.logError),
|
Effect.tapError(Effect.logError),
|
||||||
Effect.ignore,
|
Effect.ignore,
|
||||||
|
|
@ -84,7 +123,10 @@
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(route, async () => await updateResults(), { immediate: true });
|
//
|
||||||
|
watch(route, async () => {
|
||||||
|
await updateSearchResults();
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
console.debug("SearchPage.vue -- Mounted");
|
console.debug("SearchPage.vue -- Mounted");
|
||||||
|
|
@ -99,7 +141,7 @@
|
||||||
<h3>Termes</h3>
|
<h3>Termes</h3>
|
||||||
<form
|
<form
|
||||||
id="search-media-form" ref="form" class="cluster"
|
id="search-media-form" ref="form" class="cluster"
|
||||||
@submit="updateQueryParams"
|
@submit="updateUrlQuery"
|
||||||
>
|
>
|
||||||
<fieldset class="stack">
|
<fieldset class="stack">
|
||||||
<legend>Type du média</legend>
|
<legend>Type du média</legend>
|
||||||
|
|
@ -108,7 +150,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input
|
<input
|
||||||
id="film" checked for="add-media-form"
|
id="film" checked for="add-media-form"
|
||||||
name="type" type="radio" :v-model="formData?.type"
|
name="type" type="radio" :v-model="searchFormData?.type"
|
||||||
value="film"
|
value="film"
|
||||||
>
|
>
|
||||||
<label for="film">Film</label>
|
<label for="film">Film</label>
|
||||||
|
|
@ -116,7 +158,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input
|
<input
|
||||||
id="series" for="add-media-form" name="type"
|
id="series" for="add-media-form" name="type"
|
||||||
type="radio" :v-model="formData?.type" value="series"
|
type="radio" :v-model="searchFormData?.type" value="series"
|
||||||
>
|
>
|
||||||
<label for="series">Série</label>
|
<label for="series">Série</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -127,8 +169,8 @@
|
||||||
<label for="query">Titre</label>
|
<label for="query">Titre</label>
|
||||||
<input
|
<input
|
||||||
id="query" for="add-media-form" name="query"
|
id="query" for="add-media-form" name="query"
|
||||||
required type="text" :v-model="formData?.query"
|
required type="text" :v-model="searchFormData?.query"
|
||||||
:value="formData?.query"
|
:value="searchFormData?.query"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="field stack">
|
<div class="field stack">
|
||||||
|
|
@ -136,25 +178,23 @@
|
||||||
<input
|
<input
|
||||||
id="year" for="add-media-form" :max="currentYear"
|
id="year" for="add-media-form" :max="currentYear"
|
||||||
min="1900" name="year" type="number"
|
min="1900" name="year" type="number"
|
||||||
:v-model="formData?.year" :value="formData?.year"
|
:v-model="searchFormData?.year" :value="searchFormData?.year"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cluster buttons">
|
<div class="cluster buttons">
|
||||||
<button class="invert" type="submit">Rechercher</button>
|
<button class="invert" type="submit">Rechercher</button>
|
||||||
<button for="search-media-form" type="reset" @click="resetForm">Réinitialiser</button>
|
<button for="search-media-form" type="reset" @click="resetInitialState">Réinitialiser</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-if="searchResults">
|
<section id="results" class="stack">
|
||||||
<section id="results" class="stack">
|
<h3>Résultats</h3>
|
||||||
<h3>Résultats</h3>
|
|
||||||
<p v-for="result in searchResults.results" :key="result.id">
|
<p v-if="loading" class="loading">Recherche en cours</p>
|
||||||
<span>{{ result.original_title }}</span>
|
<TmdbSearchResults v-else :search-results="searchResults"></TmdbSearchResults>
|
||||||
</p>
|
</section>
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -170,13 +210,17 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#search-media-form {
|
#search-media-form {
|
||||||
padding: var(--s0);
|
padding: var(--s1);
|
||||||
border: 4px double var(--color-primary);
|
border: 4px double var(--color-primary);
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
.buttons {
|
||||||
:is(legend) {
|
flex-basis: 100%;
|
||||||
font-weight: 120;
|
margin-block-start: var(--s1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: "";
|
||||||
|
animation: loading 2s both infinite;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
/* Désactive le comportement étrange des <legend> au sein de <fieldset>. */
|
/* Désactive le comportement étrange des <legend> au sein de <fieldset>. */
|
||||||
:where(fieldset > legend) {
|
:where(fieldset > legend) {
|
||||||
width: 100%;
|
|
||||||
float: left;
|
float: left;
|
||||||
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hauteur de ligne plus étroite pour les éléments interactifs. */
|
/* Hauteur de ligne plus étroite pour les éléments interactifs. */
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ h1 {
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
--max-width: 60rem;
|
--max-width: 90rem;
|
||||||
--space: var(--s1);
|
--space: var(--s1);
|
||||||
|
|
||||||
place-content: start;
|
place-content: start;
|
||||||
|
|
@ -58,17 +58,17 @@ main {
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
to {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes flicker {
|
@keyframes flicker {
|
||||||
from, 49% {
|
0%, 49% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
50%, to {
|
50%, 100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue