2025-02-23
2025-02-24
This commit is contained in:
parent
2212f4fc14
commit
0f52ff0cef
40 changed files with 846 additions and 75 deletions
15
src/App.vue
15
src/App.vue
|
|
@ -11,18 +11,21 @@
|
|||
<main class="box stack">
|
||||
<MainHeader></MainHeader>
|
||||
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition mode="out-in" name="fade">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<!--
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition mode="out-in" name="fade">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
-->
|
||||
<RouterView></RouterView>
|
||||
</main>
|
||||
|
||||
<SidebarView></SidebarView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
<style scoped lang="css">
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { type DiaryEntry } from "@/db/schemas/entries";
|
||||
import type { DiaryEntry } from "@/db/schemas";
|
||||
|
||||
import { ReadApi } from "@/services/read-api";
|
||||
import { RuntimeClient } from "@/services/runtime-client";
|
||||
import { Effect } from "effect";
|
||||
|
|
@ -17,9 +18,10 @@
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<p>hello</p>
|
||||
|
||||
<p v-if="lastAddedEntry">
|
||||
{{ lastAddedEntry?.id }}
|
||||
</p>
|
||||
<p v-else>
|
||||
Aucune entrée dans le journal.
|
||||
</p>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<h1>Journal Média</h1>
|
||||
<header class="">
|
||||
<h1 class="invert">Journal Média</h1>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
header > h1 {
|
||||
padding: var(--s-2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Effect, pipe } from "effect";
|
||||
import { useTemplateRef } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import ImposterBox from "./ImposterBox.vue";
|
||||
|
||||
|
|
@ -12,46 +14,72 @@
|
|||
defineEmits<(e: "dialog-hidden", dialogId: string) => void>();
|
||||
|
||||
const form = useTemplateRef<HTMLFormElement>("form");
|
||||
const router = useRouter();
|
||||
|
||||
const resetAndClose = () => {
|
||||
form.value?.reset();
|
||||
toggleDialog();
|
||||
};
|
||||
|
||||
const redirectToSearch = async (event: Event): void => {
|
||||
if (!form.value?.checkValidity()) return;
|
||||
event.preventDefault();
|
||||
|
||||
await pipe(
|
||||
Effect.fromNullable(form.value),
|
||||
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
|
||||
Effect.andThen((formData: FormData) => new URLSearchParams(formData)),
|
||||
Effect.andThen((searchParams: URLSearchParams) => Object.fromEntries(searchParams.entries())),
|
||||
Effect.tap(query => router.push({ path: "/search", query })),
|
||||
Effect.runPromise,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ImposterBox dialog-id="add-media" :is-toggled="isToggled" @dialog-hidden="resetAndClose">
|
||||
<template #title>Ajouter un média</template>
|
||||
<template #content>
|
||||
<form ref="form" class="stack">
|
||||
<form
|
||||
id="add-media-form" ref="form" action=""
|
||||
class="stack" method="dialog"
|
||||
>
|
||||
<fieldset class="cluster">
|
||||
<legend>Type du média</legend>
|
||||
<div class="field">
|
||||
<input
|
||||
id="film" checked name="media-type"
|
||||
type="radio" value="film"
|
||||
id="film" checked for="add-media-form"
|
||||
name="type" type="radio" value="film"
|
||||
>
|
||||
<label for="film">Film</label>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input
|
||||
id="series" name="media-type" type="radio"
|
||||
value="series"
|
||||
id="series" for="add-media-form" name="type"
|
||||
type="radio" value="series"
|
||||
>
|
||||
<label for="series">Série</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field stack">
|
||||
<label for="media-title">Titre</label> <input id="media-title" type="text">
|
||||
<label for="query">Titre</label>
|
||||
<input
|
||||
id="query" for="add-media-form" name="query"
|
||||
required type="text"
|
||||
>
|
||||
</div>
|
||||
<div class="field stack">
|
||||
<label for="media-release-year">Année de sortie</label> <input id="media-release-year" type="number">
|
||||
<label for="year">Année de sortie</label>
|
||||
<input
|
||||
id="year" for="add-media-form" name="year"
|
||||
type="number"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="cluster buttons">
|
||||
<button class="invert" type="submit">
|
||||
<button class="invert" type="submit" @click="redirectToSearch">
|
||||
Rechercher
|
||||
</button>
|
||||
<button type="reset">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import type { Values } from "@/libs/utils/types";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import * as t from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable as table } from "drizzle-orm/sqlite-core";
|
||||
import { Schema } from "effect";
|
||||
|
||||
import type { DIARY_ENTRY_STATES } from "./constants";
|
||||
|
||||
import { DIARY_ENTRY_STATES } from "./constants";
|
||||
import { ArtWorks, Genres } from "./works";
|
||||
|
||||
// Tables
|
||||
|
||||
export const DiaryEntries = table("diary_entries", {
|
||||
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id),
|
||||
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
|
||||
dateCreated: t.text("date_created", { length: 10 }).notNull(),
|
||||
dateModified: t.text("date_modified", { length: 10 }).notNull(),
|
||||
id: t.integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
|
@ -34,7 +35,33 @@ export const Viewings = table("viewings", {
|
|||
id: t.integer("id").primaryKey({ autoIncrement: true }),
|
||||
});
|
||||
|
||||
export type DiaryEntry = InferSelectModel<typeof DiaryEntries>;
|
||||
export type DiaryEntryGenre = InferSelectModel<typeof DiaryEntriesGenres>;
|
||||
export type DiaryEntryState = InferSelectModel<typeof DiaryEntriesStates>;
|
||||
export type Viewing = InferSelectModel<typeof Viewings>;
|
||||
// Schémas
|
||||
|
||||
export const DiaryEntrySchema = Schema.Struct({
|
||||
artWorkId: Schema.NonNegativeInt,
|
||||
dateCreated: Schema.String,
|
||||
dateModified: Schema.String,
|
||||
id: Schema.NonNegativeInt,
|
||||
stateId: Schema.NonNegativeInt,
|
||||
});
|
||||
export type DiaryEntry = Schema.Schema.Type<typeof DiaryEntrySchema>;
|
||||
|
||||
export const DiaryEntryGenreSchema = Schema.Struct({
|
||||
artWorkId: Schema.NonNegativeInt,
|
||||
genreId: Schema.NonNegativeInt,
|
||||
id: Schema.NonNegativeInt,
|
||||
});
|
||||
export type DiaryEntryGenre = Schema.Schema.Type<typeof DiaryEntryGenreSchema>;
|
||||
|
||||
export const DiaryEntryStateSchema = Schema.Struct({
|
||||
id: Schema.NonNegativeInt,
|
||||
state: Schema.Enums(DIARY_ENTRY_STATES),
|
||||
});
|
||||
export type DiaryEntryState = Schema.Schema.Type<typeof DiaryEntryStateSchema>;
|
||||
|
||||
export const ViewingSchema = Schema.Struct({
|
||||
artWorkId: Schema.NonNegativeInt,
|
||||
date: Schema.String,
|
||||
id: Schema.NonNegativeInt,
|
||||
});
|
||||
export type Viewing = Schema.Schema.Type<typeof ViewingSchema>;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { Values } from "@/libs/utils/types";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import * as t from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable as table } from "drizzle-orm/sqlite-core";
|
||||
import { Schema } from "effect";
|
||||
|
||||
import type { MEDIA_TYPES } from "./constants";
|
||||
import { MEDIA_TYPES } from "./constants";
|
||||
|
||||
// Tables
|
||||
|
||||
export const MediaTypes = table("media_types", {
|
||||
id: t.integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
|
@ -26,6 +28,26 @@ export const Genres = table("genres", {
|
|||
slug: t.text("slug").notNull().unique(),
|
||||
});
|
||||
|
||||
export type ArtWork = InferSelectModel<typeof ArtWorks>;
|
||||
export type Genre = InferSelectModel<typeof Genres>;
|
||||
export type MediaType = InferSelectModel<typeof MediaTypes>;
|
||||
// Schémas
|
||||
|
||||
export const MediaTypeSchema = Schema.Struct({
|
||||
id: Schema.NonNegativeInt,
|
||||
name: Schema.NonEmptyString,
|
||||
slug: Schema.Enums(MEDIA_TYPES),
|
||||
});
|
||||
export type MediaType = Schema.Schema.Type<typeof MediaTypeSchema>;
|
||||
|
||||
export const ArtWorkSchema = Schema.Struct({
|
||||
id: Schema.NonNegativeInt,
|
||||
mediumTypeId: Schema.NonNegativeInt,
|
||||
name: Schema.NonEmptyString,
|
||||
releaseDate: Schema.String,
|
||||
});
|
||||
export type ArtWork = Schema.Schema.Type<typeof ArtWorkSchema>;
|
||||
|
||||
export const GenreSchema = Schema.Struct({
|
||||
id: Schema.NonNegativeInt,
|
||||
name: Schema.NonEmptyString,
|
||||
slug: Schema.NonEmptyString,
|
||||
});
|
||||
export type Genre = Schema.Schema.Type<typeof GenreSchema>;
|
||||
|
|
|
|||
30
src/libs/apis/clients.ts
Normal file
30
src/libs/apis/clients.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Input } from "@effect/platform/UrlParams";
|
||||
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, Url, UrlParams } from "@effect/platform";
|
||||
import { Effect, pipe } from "effect";
|
||||
|
||||
export const DebugHttpClient = pipe(
|
||||
HttpClient.HttpClient,
|
||||
Effect.andThen(HttpClient.tapRequest(Effect.logDebug)),
|
||||
Effect.andThen(HttpClient.tap(Effect.logDebug)),
|
||||
Effect.andThen(HttpClient.filterStatusOk),
|
||||
HttpClient.withTracerPropagation(false),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
);
|
||||
|
||||
export const createUrlWithParams = (stringUrl: string) => (params: Input) =>
|
||||
Effect.gen(function*() {
|
||||
const url = yield* Url.fromString(stringUrl);
|
||||
const urlParams = UrlParams.fromInput(params);
|
||||
return Url.setUrlParams(url, urlParams);
|
||||
});
|
||||
|
||||
export const createGetHttpRequest = (url: URL): HttpClientRequest.HttpClientRequest =>
|
||||
pipe(
|
||||
HttpClientRequest.get(url),
|
||||
HttpClientRequest.bearerToken(import.meta.env["VITE_TMDB_API_KEY"]),
|
||||
HttpClientRequest.acceptJson,
|
||||
);
|
||||
|
||||
export const executeHttpRequest = (request: HttpClientRequest.HttpClientRequest) =>
|
||||
Effect.andThen(DebugHttpClient, client => client.execute(request));
|
||||
23
src/libs/apis/requests.ts
Normal file
23
src/libs/apis/requests.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { HttpClient, HttpClientResponse } from "@effect/platform";
|
||||
import { Effect, pipe } from "effect";
|
||||
|
||||
import { createGetHttpRequest, createUrlWithParams, DebugHttpClient } from "./clients";
|
||||
import { TMDB_ROUTE_SEARCH_MOVIE } from "./routes";
|
||||
import { TmdbMovieSearchQueryParams, TmdbMovieSearchResponse } from "./tmdb/schemas";
|
||||
|
||||
export const TmdbSearchMovie = (queryParams: TmdbMovieSearchQueryParams) =>
|
||||
pipe(
|
||||
Effect.gen(function*() {
|
||||
const { ...args } = queryParams;
|
||||
const client = yield* DebugHttpClient;
|
||||
|
||||
return pipe(
|
||||
createUrlWithParams(TMDB_ROUTE_SEARCH_MOVIE)(args),
|
||||
Effect.andThen(url => createGetHttpRequest(url)),
|
||||
Effect.andThen(request => client.execute(request)),
|
||||
// NOTE: Essentiel à désactiver pour des APIs externes
|
||||
HttpClient.withTracerPropagation(false),
|
||||
Effect.andThen(response => HttpClientResponse.schemaBodyJson(TmdbMovieSearchResponse)(response)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
1
src/libs/apis/routes.ts
Normal file
1
src/libs/apis/routes.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const TMDB_ROUTE_SEARCH_MOVIE = "https://api.themoviedb.org/3/search/movie";
|
||||
9
src/libs/apis/tmdb/constants.ts
Normal file
9
src/libs/apis/tmdb/constants.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { TmdbMovieSearchQueryParams } from "./schemas";
|
||||
|
||||
export const DEFAULT_SEARCH_MOVIE_PARAMS: TmdbMovieSearchQueryParams = {
|
||||
include_adult: false,
|
||||
language: "fr",
|
||||
page: 1,
|
||||
query: "",
|
||||
region: "fr-FR",
|
||||
};
|
||||
|
|
@ -13,13 +13,13 @@ export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQuer
|
|||
Schema.propertySignature,
|
||||
Schema.withConstructorDefault(() => 1),
|
||||
),
|
||||
primary_release_year: Schema.NonEmptyString.pipe(Schema.length(4), Schema.optional),
|
||||
primary_release_year: Schema.String.pipe(Schema.optional),
|
||||
query: Schema.NonEmptyString,
|
||||
region: Schema.NonEmptyString.pipe(
|
||||
Schema.propertySignature,
|
||||
Schema.withConstructorDefault(() => "fr"),
|
||||
),
|
||||
year: Schema.NonEmptyString.pipe(Schema.length(4), Schema.optional),
|
||||
year: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class TmdbMovieSearchResponse extends Schema.Class<TmdbMovieSearchResponse>("TmdbMovieSearchResponse")({
|
||||
|
|
@ -35,7 +35,7 @@ export class TmdbMovieSearchResponse extends Schema.Class<TmdbMovieSearchRespons
|
|||
overview: Schema.String,
|
||||
popularity: Schema.Number,
|
||||
poster_path: Schema.Union(Schema.String, Schema.Null),
|
||||
release_date: Schema.NonEmptyString.pipe(Schema.length(10)),
|
||||
release_date: Schema.String,
|
||||
title: Schema.String,
|
||||
video: Schema.Boolean,
|
||||
vote_average: Schema.Number,
|
||||
|
|
|
|||
8
src/libs/search/schemas.ts
Normal file
8
src/libs/search/schemas.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { MEDIA_TYPES } from "@/db/schemas/constants";
|
||||
import { Schema } from "effect";
|
||||
|
||||
export class SearchPageQueryParams extends Schema.Class<SearchPageQueryParams>("SearchPageQueryParams")({
|
||||
query: Schema.NonEmptyString,
|
||||
type: Schema.Enums(MEDIA_TYPES),
|
||||
year: Schema.String,
|
||||
}) {}
|
||||
25
src/libs/search/utils.ts
Normal file
25
src/libs/search/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { NonEmptyArray } from "effect/Array";
|
||||
|
||||
import { UrlParams } from "@effect/platform";
|
||||
import { Effect, pipe } from "effect";
|
||||
|
||||
/**
|
||||
* Transform les valeurs d'un `FormData` en `Record` trié.
|
||||
*
|
||||
* @param formData Les valeurs d'un formulaire.
|
||||
* @returns Un `Effect` des valeurs.
|
||||
*/
|
||||
export const transformFormDataToRecord = (
|
||||
formData: FormData,
|
||||
): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
|
||||
pipe(
|
||||
Effect.succeed(Array.from(formData.entries())),
|
||||
// @ts-expect-error -- Impossible de typer les valeurs de FormData comme string.
|
||||
Effect.andThen(formData => new URLSearchParams(formData)),
|
||||
Effect.andThen((urlSearchParams: URLSearchParams) => {
|
||||
urlSearchParams.sort();
|
||||
return urlSearchParams;
|
||||
}),
|
||||
Effect.andThen((urlSearchParams: URLSearchParams) => UrlParams.fromInput(urlSearchParams)),
|
||||
Effect.andThen((urlParams: UrlParams.UrlParams) => UrlParams.toRecord(urlParams)),
|
||||
);
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { DateTime, pipe } from "effect";
|
||||
|
||||
export const getTodayDate = (): string =>
|
||||
new Date(Date.now()).toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
|
|
@ -5,3 +7,9 @@ export const getTodayDate = (): string =>
|
|||
weekday: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export const getCurrentYear = (): number =>
|
||||
pipe(
|
||||
DateTime.unsafeNow(),
|
||||
DateTime.getPart("year"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import type { Ref } from "vue";
|
||||
|
||||
import LastAddedEntry from "@/components/LastAddedEntry.vue";
|
||||
import SearchMediaDialog from "@/components/SearchMediaDialog.vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const toggleDialogStateRef = (stateRef: Ref<boolean, boolean>) => () => {
|
||||
stateRef.value = !stateRef.value;
|
||||
};
|
||||
|
|
@ -24,6 +24,12 @@
|
|||
|
||||
<section id="last-watched-media" class="stack">
|
||||
<h2>Derniers médias regardés</h2>
|
||||
<Suspense>
|
||||
<LastAddedEntry> </LastAddedEntry>
|
||||
<template #fallback>
|
||||
<p>Récupération des entrées...</p>
|
||||
</template>
|
||||
</Suspense>
|
||||
</section>
|
||||
|
||||
<SearchMediaDialog :is-toggled="isAddMediaToggled" :toggle-dialog="toggleAddMediaDialog"></SearchMediaDialog>
|
||||
|
|
|
|||
182
src/pages/SearchPage.vue
Normal file
182
src/pages/SearchPage.vue
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
<script setup lang="ts">
|
||||
import { MEDIA_TYPES } from "@/db/schemas/constants";
|
||||
import { DEFAULT_SEARCH_MOVIE_PARAMS } from "@/libs/apis/tmdb/constants";
|
||||
import { TmdbMovieSearchQueryParams, TmdbMovieSearchResponse } from "@/libs/apis/tmdb/schemas";
|
||||
import { SearchPageQueryParams } from "@/libs/search/schemas";
|
||||
import { transformFormDataToRecord } from "@/libs/search/utils";
|
||||
import { getCurrentYear } from "@/libs/utils/dates";
|
||||
import { PrettyLogger } from "@/services/logger";
|
||||
import { RuntimeClient } from "@/services/runtime-client";
|
||||
import { TmdbApi } from "@/services/tmdb-api";
|
||||
import { Effect, pipe, Schema } from "effect";
|
||||
import { computed, onMounted } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { watch } from "vue";
|
||||
import { useTemplateRef } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const currentYear: number = getCurrentYear();
|
||||
|
||||
const parsedQueryParams = computed(() => Schema.decodeUnknown(SearchPageQueryParams)(route.query));
|
||||
const form = useTemplateRef("form");
|
||||
const formData = ref<SearchPageQueryParams>();
|
||||
const searchResults = ref<TmdbMovieSearchResponse>();
|
||||
|
||||
const updateQueryParams = async (event?: Event): Promise<void> => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
await pipe(
|
||||
Effect.fromNullable(form.value),
|
||||
Effect.andThen(form => new FormData(form)),
|
||||
Effect.andThen(formData => transformFormDataToRecord(formData)),
|
||||
Effect.tap(queryParams => router.push({ force: true, query: queryParams })),
|
||||
Effect.catchAll(error => Effect.succeed(error)),
|
||||
Effect.tap(Effect.logInfo),
|
||||
Effect.runPromise,
|
||||
);
|
||||
};
|
||||
const resetForm = async (event: Event): Promise<void> => {
|
||||
event.preventDefault();
|
||||
form.value?.reset();
|
||||
formData.value = {
|
||||
query: "",
|
||||
type: MEDIA_TYPES.FILM,
|
||||
year: "",
|
||||
};
|
||||
router.push({ force: true });
|
||||
};
|
||||
|
||||
const executeSearch = async (): Promise<TmdbMovieSearchResponse | undefined> =>
|
||||
await RuntimeClient.runPromise(
|
||||
Effect.gen(function*() {
|
||||
// NOTE: Ne gère que la recherche de films pour l'instant.
|
||||
const tmdbApi = yield* TmdbApi;
|
||||
const validQueryParams = yield* parsedQueryParams.value;
|
||||
const queryArgs = yield* Schema.decode(TmdbMovieSearchQueryParams)({
|
||||
...DEFAULT_SEARCH_MOVIE_PARAMS,
|
||||
primary_release_year: validQueryParams.year,
|
||||
query: validQueryParams.query,
|
||||
});
|
||||
|
||||
return yield* pipe(
|
||||
tmdbApi.searchMovie(queryArgs),
|
||||
Effect.tapError(Effect.logError),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
Effect.tap(Effect.logInfo),
|
||||
Effect.provide(PrettyLogger),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const updateResults = async (): Promise<void> =>
|
||||
pipe(
|
||||
parsedQueryParams.value,
|
||||
Effect.tap(async (args: SearchPageQueryParams) => {
|
||||
formData.value = { ...args };
|
||||
searchResults.value = await executeSearch();
|
||||
}),
|
||||
Effect.tapError(Effect.logError),
|
||||
Effect.ignore,
|
||||
Effect.provide(PrettyLogger),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
watch(route, async () => await updateResults(), { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
console.debug("SearchPage.vue -- Mounted");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper stack">
|
||||
<h2>Rechercher</h2>
|
||||
|
||||
<section id="search-terms" class="stack">
|
||||
<h3>Termes</h3>
|
||||
<form
|
||||
id="search-media-form" ref="form" class="cluster"
|
||||
@submit="updateQueryParams"
|
||||
>
|
||||
<fieldset class="stack">
|
||||
<legend>Type du média</legend>
|
||||
|
||||
<div class="fields cluster">
|
||||
<div class="field">
|
||||
<input
|
||||
id="film" checked for="add-media-form"
|
||||
name="type" type="radio" :v-model="formData?.type"
|
||||
value="film"
|
||||
>
|
||||
<label for="film">Film</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input
|
||||
id="series" for="add-media-form" name="type"
|
||||
type="radio" :v-model="formData?.type" value="series"
|
||||
>
|
||||
<label for="series">Série</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field stack">
|
||||
<label for="query">Titre</label>
|
||||
<input
|
||||
id="query" for="add-media-form" name="query"
|
||||
required type="text" :v-model="formData?.query"
|
||||
:value="formData?.query"
|
||||
>
|
||||
</div>
|
||||
<div class="field stack">
|
||||
<label for="year">Année de sortie</label>
|
||||
<input
|
||||
id="year" for="add-media-form" :max="currentYear"
|
||||
min="1900" name="year" type="number"
|
||||
:v-model="formData?.year" :value="formData?.year"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="cluster buttons">
|
||||
<button class="invert" type="submit">Rechercher</button>
|
||||
<button for="search-media-form" type="reset" @click="resetForm">Réinitialiser</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<template v-if="searchResults">
|
||||
<section id="results" class="stack">
|
||||
<h3>Résultats</h3>
|
||||
<p v-for="result in searchResults.results" :key="result.id">
|
||||
<span>{{ result.original_title }}</span>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.wrapper {
|
||||
> * {
|
||||
--space: var(--s3);
|
||||
|
||||
* {
|
||||
--space: var(--s0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#search-media-form {
|
||||
padding: var(--s0);
|
||||
border: 4px double var(--color-primary);
|
||||
}
|
||||
|
||||
form {
|
||||
:is(legend) {
|
||||
font-weight: 120;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -23,13 +23,19 @@ const router: Router = createRouter({
|
|||
name: "NotFound",
|
||||
path: "/:pathMatch(.*)*",
|
||||
},
|
||||
{
|
||||
component: () => import("@/pages/SearchPage.vue"),
|
||||
meta: { title: "Rechercher" },
|
||||
name: "Search",
|
||||
path: "/search",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to, _): void => {
|
||||
console.debug("router - to", to);
|
||||
pipe(
|
||||
Option.liftPredicate(Predicate.isString)(to.meta["title"]),
|
||||
Option.liftPredicate(Predicate.isString)(to.meta.title),
|
||||
Option.getOrElse((): string => "???"),
|
||||
(pageName: string): void => {
|
||||
document.title = generatePageTitle(siteName, separator, pageName);
|
||||
|
|
|
|||
13
src/router/typed-routes.d.ts
vendored
13
src/router/typed-routes.d.ts
vendored
|
|
@ -1,5 +1,7 @@
|
|||
import type { RouteRecordInfo } from "vue-router";
|
||||
|
||||
import "vue-router";
|
||||
|
||||
export interface RouteNamedMap {
|
||||
Home: RouteRecordInfo<
|
||||
"Home",
|
||||
|
|
@ -15,10 +17,21 @@ export interface RouteNamedMap {
|
|||
{ path: string },
|
||||
{ title: string }
|
||||
>;
|
||||
Search: RouteRecordInfo<
|
||||
"Search",
|
||||
"/search",
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
{ title: string }
|
||||
>;
|
||||
}
|
||||
|
||||
// Last, you will need to augment the Vue Router types with this map of routes
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
/** Le nom de la page de la route, utile par exemple pour <title>. */
|
||||
title: string;
|
||||
}
|
||||
interface TypesConfig {
|
||||
RouteNamedMap: RouteNamedMap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { LogLevel } from "effect/LogLevel";
|
|||
|
||||
import { Config, ConfigProvider, Effect, Layer, Logger, pipe } from "effect";
|
||||
|
||||
const EnvConfigProvider = Layer.setConfigProvider(ConfigProvider.fromMap(new Map([["LOG_LEVEL", "DEBUG"]])));
|
||||
const EnvConfigProvider = Layer.setConfigProvider(ConfigProvider.fromMap(new Map([["LOG_LEVEL", "INFO"]])));
|
||||
|
||||
const LogLevelLive = pipe(
|
||||
Config.logLevel("LOG_LEVEL"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DiaryEntries, Users } from "@/db/schemas";
|
||||
import { DiaryEntries } from "@/db/schemas";
|
||||
import { singleResultOrFail } from "@/libs/utils/effects";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { Data, Effect } from "effect";
|
||||
|
|
@ -20,7 +20,6 @@ export class ReadApi extends Effect.Service<ReadApi>()("ReadApi", {
|
|||
query(_ => _.select().from(DiaryEntries).limit(1).orderBy(desc(DiaryEntries.dateCreated))).pipe(
|
||||
singleResultOrFail(() => new ReadApiError({ cause: "Aucune entrée n'a encore été ajoutée." })),
|
||||
),
|
||||
getUsers: () => query(_ => _.select().from(Users)),
|
||||
};
|
||||
}),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { LocalSqlite } from "./db";
|
|||
import { PrettyLogger } from "./logger";
|
||||
import { Migrations } from "./migrations";
|
||||
import { ReadApi } from "./read-api";
|
||||
import { TmdbApi } from "./tmdb-api";
|
||||
|
||||
const MainLayer = Layer.mergeAll(
|
||||
// WriteApi.Default,
|
||||
LocalSqlite.Default,
|
||||
Migrations.Default,
|
||||
ReadApi.Default,
|
||||
TmdbApi.Default,
|
||||
).pipe(Layer.provide(PrettyLogger));
|
||||
|
||||
export const RuntimeClient = ManagedRuntime.make(MainLayer);
|
||||
|
|
|
|||
33
src/services/tmdb-api.ts
Normal file
33
src/services/tmdb-api.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { createGetHttpRequest, createUrlWithParams, DebugHttpClient as Dhc } from "@/libs/apis/clients";
|
||||
import { TMDB_ROUTE_SEARCH_MOVIE } from "@/libs/apis/routes";
|
||||
import { type TmdbMovieSearchQueryParams, TmdbMovieSearchResponse } from "@/libs/apis/tmdb/schemas";
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
|
||||
import { Data, Effect, pipe } from "effect";
|
||||
|
||||
export class DebugHttpClient extends Effect.Service<DebugHttpClient>()("DebugHttpClient", {
|
||||
effect: Dhc,
|
||||
}) {}
|
||||
|
||||
export class TmdbApi extends Effect.Service<TmdbApi>()("TmdbApi", {
|
||||
effect: Effect.gen(function*() {
|
||||
yield* Effect.logDebug("--- TMDB-API ---");
|
||||
const client = yield* Dhc;
|
||||
|
||||
return {
|
||||
searchMovie: (args: TmdbMovieSearchQueryParams) =>
|
||||
pipe(
|
||||
createUrlWithParams(TMDB_ROUTE_SEARCH_MOVIE)({ ...args }),
|
||||
Effect.andThen((url: URL) => createGetHttpRequest(url)),
|
||||
Effect.andThen((request: HttpClientRequest.HttpClientRequest) => client.execute(request)),
|
||||
// NOTE: Essentiel à désactiver pour des APIs externes
|
||||
HttpClient.withTracerPropagation(false),
|
||||
Effect.andThen((response: HttpClientResponse.HttpClientResponse) =>
|
||||
HttpClientResponse.schemaBodyJson(TmdbMovieSearchResponse)(response)
|
||||
),
|
||||
Effect.scoped,
|
||||
),
|
||||
};
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class TmdbApiError extends Data.TaggedError("TmdbApiError")<{ cause: unknown }> {}
|
||||
|
|
@ -21,12 +21,12 @@ html {
|
|||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
accent-color: var(--color-tertiary);
|
||||
background: var(--color-secondary);
|
||||
--webkit-font-smoothing: antialiased;
|
||||
font-family: monospace, system-ui, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-kerning: normal;
|
||||
font-optical-sizing: auto;
|
||||
font-variant-ligatures: common-ligatures no-discretionary-ligatures no-historical-ligatures
|
||||
contextual;
|
||||
line-height: var(--line-height-comfortable);
|
||||
|
|
@ -55,9 +55,11 @@ body {
|
|||
*::selection {
|
||||
color: var(--color-secondary);
|
||||
background: var(--color-primary);
|
||||
font-weight: 120;
|
||||
}
|
||||
|
||||
/* TODO: Prendre en compte a11y-dialog */
|
||||
|
||||
/* Empêche le défilement de la page quand une modale est ouverte. */
|
||||
:where(html:has(dialog:modal[open])) {
|
||||
overflow: clip;
|
||||
|
|
@ -65,9 +67,9 @@ body {
|
|||
|
||||
/* Retire les bordures et applique un modèle d'arrière-plan plus sain. */
|
||||
*:where(:not(progress, meter)) {
|
||||
border: 0 solid transparent;
|
||||
background-repeat: no-repeat;
|
||||
background-origin: border-box;
|
||||
border: 0 solid transparent;
|
||||
}
|
||||
|
||||
/* Classe pour cacher visuellement tout en restant accessible par les lecteurs d'écran. */
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
/* Réinitialise l'apparence d'éléments interactifs. */
|
||||
:where(button, fieldset, input, select, textarea) {
|
||||
:where(button, fieldset, input, select, legend, textarea) {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Désactive le comportement étrange des <legend> au sein de <fieldset>. */
|
||||
:where(fieldset > legend) {
|
||||
width: 100%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
/* Hauteur de ligne plus étroite pour les éléments interactifs. */
|
||||
:where(button, fieldset, input, label, select, textarea) {
|
||||
line-height: var(--line-height-compact);
|
||||
|
|
@ -19,7 +25,7 @@
|
|||
}
|
||||
|
||||
/* Curseur de main pour les éléments interactifs cliquables. */
|
||||
:where(button, label, select) {
|
||||
:where(button, input, label, select) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +38,10 @@
|
|||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3, h4, h5, h6) {
|
||||
inline-size: fit-content;
|
||||
}
|
||||
|
||||
/* Les médias doivent occuper toute la longueur disponible au sein de leur propre bloc. */
|
||||
:where(img, picture, video, canvas, svg) {
|
||||
display: block;
|
||||
|
|
@ -43,30 +53,34 @@
|
|||
list-style: none;
|
||||
}
|
||||
|
||||
:where(.fields) {
|
||||
padding: var(--s-4);
|
||||
}
|
||||
|
||||
:where(input[type="text"], input[type="number"]) {
|
||||
padding: var(--s-4);
|
||||
}
|
||||
|
||||
:where(input[type="radio"]) {
|
||||
position: relative;
|
||||
aspect-ratio: 1/1;
|
||||
border: 1px solid var(--color-primary);
|
||||
background: var(--color-secondary);
|
||||
border-radius: 50%;
|
||||
inline-size: var(--s-1);
|
||||
block-size: var(--s-1);
|
||||
position: relative;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
background: var(--color-secondary);
|
||||
|
||||
&::after {
|
||||
background: var(--color-primary);
|
||||
border-radius: inherit;
|
||||
content: "";
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
inline-size: var(--s-2);
|
||||
block-size: var(--s-2);
|
||||
border-radius: inherit;
|
||||
opacity: 0;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
+ label {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
z-index: 2; /* 1 */
|
||||
inset: 0; /* 1 */
|
||||
display: flex; /* 2 */
|
||||
background: var(--bg25-secondary);
|
||||
margin: 0;
|
||||
background: var(--bg25-secondary);
|
||||
|
||||
&[aria-hidden="true"] {
|
||||
display: none; /* 1 */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
flex-flow: column nowrap;
|
||||
align-items: center;
|
||||
box-sizing: content-box; /* 1 */
|
||||
max-inline-size: var(--longueur-max-texte, 80ch); /* 2 */
|
||||
max-inline-size: var(--max-width, 80rem); /* 2 */
|
||||
margin-inline: auto; /* 2 */
|
||||
padding-inline: var(--s0, 1rem) var(--s0, 1rem); /* 3 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ body {
|
|||
font-weight: 120;
|
||||
color: var(--color-secondary);
|
||||
background-color: var(--color-primary);
|
||||
|
||||
&::selection {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
|
@ -22,7 +27,8 @@ h1 {
|
|||
}
|
||||
|
||||
.container {
|
||||
--longueur-max-texte: 100ch;
|
||||
--max-width: 60rem;
|
||||
--space: var(--s1);
|
||||
|
||||
place-content: start;
|
||||
place-items: start;
|
||||
|
|
@ -35,14 +41,22 @@ h1 {
|
|||
margin-block-start: var(--s2);
|
||||
}
|
||||
|
||||
main > header {
|
||||
margin-block-end: var(--s2);
|
||||
main {
|
||||
inline-size: 100%;
|
||||
|
||||
> header {
|
||||
margin-block-end: var(--s2);
|
||||
}
|
||||
}
|
||||
|
||||
#last-watched-media {
|
||||
--space: var(--s2);
|
||||
}
|
||||
|
||||
:is(input[type="text"], input[type="number"]) {
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ button {
|
|||
--button-background-color: var(--color-secondary);
|
||||
--button-border-color: var(--color-primary);
|
||||
--button-font-weight: 100;
|
||||
--button-padding: var(--s-1);
|
||||
--button-padding: var(--s-2);
|
||||
--button-text-color: var(--color-primary);
|
||||
|
||||
padding: var(--button-padding);
|
||||
|
|
@ -38,6 +38,7 @@ button {
|
|||
}
|
||||
|
||||
/* TODO: Déplacer dans un Composant. */
|
||||
|
||||
/* Bouton intégré dans un ensemble. */
|
||||
&.integrated {
|
||||
border: initial;
|
||||
|
|
|
|||
|
|
@ -10,3 +10,9 @@ h2 {
|
|||
font-size: var(--s2);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: Banquise, monospace;
|
||||
font-size: var(--s1);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue