2025-02-23

2025-02-24
This commit is contained in:
gcch 2025-02-23 16:09:48 +01:00
commit 0f52ff0cef
40 changed files with 846 additions and 75 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1 @@
export const TMDB_ROUTE_SEARCH_MOVIE = "https://api.themoviedb.org/3/search/movie";

View 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",
};

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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 }> {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,3 +10,9 @@ h2 {
font-size: var(--s2);
letter-spacing: 1px;
}
h3 {
font-family: Banquise, monospace;
font-size: var(--s1);
letter-spacing: 1px;
}