2025-02-24
All checks were successful
ci/woodpecker/push/publish_instable Pipeline was successful

This commit is contained in:
gcch 2025-02-24 00:19:17 +01:00
commit a510899ff1
17 changed files with 309 additions and 103 deletions

View file

@ -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

View 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

View file

@ -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"
} }
} }
} }

View file

@ -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;"]

View file

@ -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,

View file

@ -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
View 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"],
);

View file

@ -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
View file

@ -0,0 +1,3 @@
[tools]
bun = "latest"
just = "latest"

View file

@ -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: "";
} }
} }

View 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>

View file

@ -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;

View file

@ -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,
})
{}

View file

@ -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 };

View file

@ -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>

View file

@ -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. */

View file

@ -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;
} }
} }