2025-03-03
Some checks failed
ci/woodpecker/push/publish_instable Pipeline failed

This commit is contained in:
gcch 2025-03-03 17:28:18 +01:00
commit 11fa3d1558
38 changed files with 819 additions and 148 deletions

View file

@ -1,10 +0,0 @@
<script setup lang="ts">
import type { TmdbMovieSearchResponseResult } from "@/libs/apis/tmdb/schemas";
defineProps<{
entryId: number;
tmdbSearchData: TmdbMovieSearchResponseResult;
}>();
</script>
<template></template>

View file

@ -1,35 +1,58 @@
<script setup lang="ts">
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import type { Values } from "@/libs/utils/types";
import type { MergedTmdbLocalData } from "@/libs/search/schemas.ts";
import type { AriaSortValues } from "@/libs/search/types.ts";
import type { Values } from "@/libs/utils/types.ts";
import { tupleByTitle } from "@/libs/apis/tmdb/orders.ts";
import TableHeadingSortableColumn from "@/components/tables/TableHeadingSortableColumn.vue";
import {
getTmdbSortFunction,
TMDB_SORT_VALUES,
type TmdbSortData,
type TmdbSortValues,
toggleSortOrder,
} from "@/libs/apis/tmdb/orders.ts";
import { ARIA_SORT_VALUES } from "@/libs/search/constants";
import { Array as Arr, Match, pipe } from "effect";
import { computed, ref } from "vue";
const SORT_ORDERS = {
ORIGINAL: "original",
POPULARITY: "popularity",
RELEASE_DATE: "release_date",
TITLE: "title",
} as const;
const emit = defineEmits<(e: "entry-dialog-wanted", tmdbId: number) => void>();
const { searchData } = defineProps<{ searchData: Map<number, MergedTmdbLocalData> }>();
const sortOrder = ref<Values<typeof SORT_ORDERS>>(SORT_ORDERS.TITLE);
const sort = ref<TmdbSortData>({
sortOrder: ARIA_SORT_VALUES.NONE,
sortValue: TMDB_SORT_VALUES.ORIGINAL,
});
const sortedData = computed(() =>
pipe(
Array.from(searchData.entries()),
(result: [number, MergedTmdbLocalData][]) =>
Match.value(sortOrder.value).pipe(
Match.when(SORT_ORDERS.ORIGINAL, () => result),
Match.when(SORT_ORDERS.POPULARITY, () => Arr.sort(result, tupleByTitle)),
Match.when(SORT_ORDERS.RELEASE_DATE, () => Arr.sort(result, tupleByTitle)),
Match.when(SORT_ORDERS.TITLE, () => Arr.sort(result, tupleByTitle)),
Match.exhaustive,
),
(result: [number, MergedTmdbLocalData][]) => Arr.sort(result, getTmdbSortFunction(sort.value)),
)
);
console.debug(sortedData.value);
const updateSort = (newSortValue: Values<typeof TMDB_SORT_VALUES>): void => {
const oldSort = sort.value;
const isNewSortValue = oldSort.sortValue !== newSortValue;
const newSortOrder: AriaSortValues = Match.value(isNewSortValue).pipe(
Match.when(false, () => toggleSortOrder(oldSort.sortOrder)),
Match.orElse(() => ARIA_SORT_VALUES.ASCENDING),
);
sort.value = { sortOrder: newSortOrder, sortValue: newSortValue };
};
const getSortData = (sortValue: TmdbSortValues): TmdbSortData => {
return {
sortOrder: sortValue === sort.value.sortValue ? sort.value.sortOrder : ARIA_SORT_VALUES.NONE,
sortValue,
};
};
// Gestionnaire d'événements
const onRowClicked = (tmdbId: number) => {
emit("entry-dialog-wanted", tmdbId);
};
</script>
<template>
@ -37,18 +60,38 @@
<table v-show="sortedData?.length">
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">Année</th>
<th scope="col">Popularité</th>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.ORIGINAL)" @click="updateSort(TMDB_SORT_VALUES.ORIGINAL)"
>
Index
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.TITLE)" @click="updateSort(TMDB_SORT_VALUES.TITLE)"
>
Nom
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.RELEASE_DATE)" @click="updateSort(TMDB_SORT_VALUES.RELEASE_DATE)"
>
Date
</TableHeadingSortableColumn>
<TableHeadingSortableColumn
:sort-data="getSortData(TMDB_SORT_VALUES.POPULARITY)" @click="updateSort(TMDB_SORT_VALUES.POPULARITY)"
>
Popularité
</TableHeadingSortableColumn>
</tr>
</thead>
<tbody>
<tr
v-for="result in sortedData" :key="result[0]" class="row-link"
v-for="result of sortedData" :key="result[0]" class="row-link"
:data-artwork-id="result[1].artWorkId" :data-entry-id="result[1].entryId" :data-tmdb-id="result[0]"
tabindex="0"
tabindex="0" @click="onRowClicked(result[0])" @keypress="onRowClicked(result[0])"
>
<th class="name" scope="row">
{{ result[1].original_result_index }}
</th>
<th class="name" scope="row">
{{ result[1].original_title }}
</th>
@ -80,13 +123,6 @@
}
}
thead th {
font-weight: 120;
font-size: var(--s0);
text-transform: uppercase;
letter-spacing: 1px;
}
tbody tr > * + * {
padding-inline-start: var(--s-2);
}
@ -100,8 +136,8 @@
cursor: pointer;
&:hover {
background: var(--root-text-color);
color: var(--root-background-color);
background: var(--root-text-color);
}
&:active {

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import ImposterBox from "@/components/dialogs/ImposterBox.vue";
import { Images } from "@/services/images.ts";
import { RuntimeClient } from "@/services/runtime-client";
import { Url } from "@effect/platform";
import { Effect } from "effect";
import { onMounted } from "vue";
import { ref } from "vue";
import { watchEffect } from "vue";
import { useTemplateRef } from "vue";
const emit = defineEmits(["dialog-hidden"]);
const { entryData } = defineProps<{ entryData: MergedTmdbLocalData }>();
const ditheredPoster = ref<HTMLCanvasElement>();
const imageContainer = useTemplateRef("imageContainer");
const closeDialog = () => {
emit("dialog-hidden");
};
watchEffect(async () => {
ditheredPoster.value = await RuntimeClient.runPromise(Effect.gen(function*() {
if (!entryData.artWorkCoverPath || !imageContainer.value) return undefined;
const imageService: Images = yield* Images;
const originalUrl = yield* Url.fromString(`https://image.tmdb.org/t/p/w500/${entryData.artWorkCoverPath}`);
const originalImage = yield* imageService.imageFromUrl(originalUrl);
const ditheredImage = yield* imageService.ditherImage(originalImage, imageContainer.value);
return ditheredImage;
}));
});
onMounted(() => {
console.debug("EditEntryDialog mounted");
});
</script>
<template>
<ImposterBox dialog-id="edit-entry" :is-toggled="true" @dialog-hidden="closeDialog">
<template #title>Éditer une entrée</template>
<template #content>
<section aria-labelledby="media-title" class="switcher">
<div ref="imageContainer" class="canvas-container"> </div>
<div class="stack">
<h3 id="media-title">{{ entryData.original_title }}</h3>
<p class="center">{{ entryData.release_date }} | {{ entryData.original_language }} </p>
<p class="overview">{{ entryData.overview }}</p>
</div>
</section>
</template>
</ImposterBox>
</template>
<style scoped lang="css">
.canvas-container {
aspect-ratio: 0.6;
width: 400px;
max-width: 400px;
height: 600px;
max-height: 600px;
background: var(--bg25-secondary);
> * {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.overview {
max-inline-size: 40rem;
}
</style>

View file

@ -3,6 +3,7 @@
import A11yDialog from "a11y-dialog";
import { computed, ref, useTemplateRef, watchEffect } from "vue";
import { onMounted, onUnmounted } from "vue";
const { dialogId, isToggled } = defineProps<{
/** ID de la modale. */
@ -26,6 +27,14 @@
dialog.value?.show();
}
});
onMounted(() => {
console.debug("ImposterBox mounted");
});
onUnmounted(() => {
console.debug("ImposterBox unmounted");
});
</script>
<template>

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import Search from "@/libs/search/search.ts";
import { formDataToRecord } from "@/libs/search/search.ts";
import { Effect, pipe } from "effect";
import { useTemplateRef } from "vue";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import ImposterBox from "./ImposterBox.vue";
@ -29,11 +30,15 @@
await pipe(
Effect.fromNullable(form.value),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((formData: FormData) => Search.formDataToRecord(formData)),
Effect.andThen((formData: FormData) => formDataToRecord(formData)),
Effect.tap(query => router.push({ path: "/search", query })),
Effect.runPromise,
);
};
onMounted(() => {
console.debug("SearchMediaDialog mounted");
});
</script>
<template>
@ -66,8 +71,8 @@
<div class="field stack">
<label for="query">Titre</label>
<input
id="query" for="add-media-form" name="query"
required type="text"
id="query" autofocus for="add-media-form"
name="query" required type="text"
>
</div>
<div class="field stack">

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import type { TmdbSortData } from "@/libs/apis/tmdb/orders";
const emit = defineEmits(["click"]);
const { sortData } = defineProps<{ sortData: TmdbSortData }>();
const onButtonClicked = (event: Event): void => {
event.preventDefault();
emit("click");
};
</script>
<template>
<th :aria-sort="sortData.sortOrder" scope="col">
<button
class="button-invisible" :data-sort-value="sortData.sortValue" role="button"
@click="onButtonClicked"
>
<slot></slot>
<span aria-hidden="true" class="sort-indicator"></span>
</button>
</th>
</template>
<style lang="css" scoped>
th {
position: relative;
}
button {
display: inline-block;
align-content: center;
font-size: var(--s-1);
font-weight: var(--brkly-font-weight-semibold);
text-align: left;
text-transform: uppercase;
letter-spacing: var(--letter-spacing-small);
background: inherit;
.sort-indicator {
opacity: 0.5;
&::after {
content: "♢";
display: inline-block;
min-inline-size: var(--s-1);
border-inline-end-style: var(--s3);
color: currentcolor;
text-align: center;
table th[aria-sort="descending"] & {
content: "▼";
color: currentcolor;
}
table th[aria-sort="ascending"] & {
content: "▲";
color: currentcolor;
}
}
}
&:hover {
.sort-indicator {
opacity: 1;
}
}
}
</style>

View file

@ -1,7 +1,7 @@
CREATE TABLE `diary_entries` (
`art_work_id` integer NOT NULL,
`date_created` text(10) NOT NULL,
`date_modified` text(10) NOT NULL,
`date_created` integer NOT NULL,
`date_modified` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`state_id` integer NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action,
@ -24,13 +24,16 @@ CREATE TABLE `diary_entries_states` (
CREATE UNIQUE INDEX `diary_entries_states_state_unique` ON `diary_entries_states` (`state`);--> statement-breakpoint
CREATE TABLE `viewings` (
`art_work_id` integer NOT NULL,
`date` text(10) NOT NULL,
`date` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `art_works` (
`cover_path` text,
`date_created` integer NOT NULL,
`date_metadata_updated` integer NOT NULL,
`date_updated` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medium_type_id` integer NOT NULL,
`name` text NOT NULL,

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "da9e1cf6-aba7-4b5a-a839-3b0fe3dce876",
"id": "8b217318-7662-4f81-bc55-92d5c6ddf2b2",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"diary_entries": {
@ -16,14 +16,14 @@
},
"date_created": {
"name": "date_created",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_modified": {
"name": "date_modified",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@ -178,7 +178,7 @@
},
"date": {
"name": "date",
"type": "text(10)",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@ -221,6 +221,27 @@
"notNull": false,
"autoincrement": false
},
"date_created": {
"name": "date_created",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_metadata_updated": {
"name": "date_metadata_updated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_updated": {
"name": "date_updated",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "integer",

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1740666437095,
"tag": "0000_open_the_twelve",
"when": 1740814587298,
"tag": "0000_unusual_karen_page",
"breakpoints": true
}
]

View file

@ -12,8 +12,8 @@ import { ArtWorks, Genres } from "./works";
export const DiaryEntries = table("diary_entries", {
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(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),
dateModified: t.integer("date_modified", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
stateId: t.integer("state_id").references((): AnySQLiteColumn => DiaryEntriesStates.id).notNull(),
});
@ -31,7 +31,7 @@ export const DiaryEntriesStates = table("diary_entries_states", {
export const Viewings = table("viewings", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
date: t.text("date", { length: 10 }).notNull(),
date: t.integer("date", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
});
@ -39,8 +39,8 @@ export const Viewings = table("viewings", {
export const DiaryEntrySchema = Schema.Struct({
artWorkId: Schema.NonNegativeInt,
dateCreated: Schema.String,
dateModified: Schema.String,
dateCreated: Schema.Number,
dateModified: Schema.Number,
id: Schema.NonNegativeInt,
stateId: Schema.NonNegativeInt,
});
@ -61,7 +61,7 @@ export type DiaryEntryState = Schema.Schema.Type<typeof DiaryEntryStateSchema>;
export const ViewingSchema = Schema.Struct({
artWorkId: Schema.NonNegativeInt,
date: Schema.String,
date: Schema.Number,
id: Schema.NonNegativeInt,
});
export type Viewing = Schema.Schema.Type<typeof ViewingSchema>;

View file

@ -17,6 +17,9 @@ export const MediaTypes = table("media_types", {
export const ArtWorks = table("art_works", {
coverPath: t.text("cover_path").unique(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),
dateMetadataUpdated: t.integer("date_metadata_updated", { mode: "timestamp" }).notNull(),
dateUpdated: t.integer("date_updated", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
mediumTypeId: t.integer("medium_type_id").references((): AnySQLiteColumn => MediaTypes.id).notNull(),
name: t.text("name").notNull(),
@ -45,6 +48,12 @@ export type MediaType = Schema.Schema.Type<typeof MediaTypeSchema>;
export const ArtWorkSchema = Schema.Struct({
/** Le chemin de l'image de la pochette de l'oeuvre d'art. */
coverPath: Schema.Union(Schema.String, Schema.Null),
/** La date de création de l'entrée. */
dateCreated: Schema.Number,
/** La date de dernière mise à jour des métadonnées de l'entrée depuis l'API TMDB. */
dateMetadataUpdated: Schema.Number,
/** La date de dernière mise à jour de l'entrée. */
dateUpdated: Schema.Number,
/** L'ID numérique de l'ouvre d'art. */
id: Schema.NonNegativeInt,
/** L'ID numérique du type de l'oeuvre d'art. */

View file

@ -1,22 +1,63 @@
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import type { AriaSortValues } from "@/libs/search/types";
import type { Values } from "@/libs/utils/types";
import { Order } from "effect";
import { ARIA_SORT_VALUES } from "@/libs/search/constants";
import { Match, Order, pipe } from "effect";
import type { TmdbMovieSearchResponseResult } from "./schemas";
export const TMDB_SORT_VALUES = {
ORIGINAL: "original",
POPULARITY: "popularity",
RELEASE_DATE: "release_date",
TITLE: "title",
} as const;
export const byTitle = Order.mapInput(
Order.string,
(tmdbEntry: TmdbMovieSearchResponseResult) => tmdbEntry.original_title,
export type TmdbSortValues = Values<typeof TMDB_SORT_VALUES>;
export interface TmdbSortData {
sortOrder: AriaSortValues;
sortValue: TmdbSortValues;
}
export const toggleSortOrder = (order: AriaSortValues): AriaSortValues =>
Match.value(order).pipe(
Match.when(ARIA_SORT_VALUES.ASCENDING, () => ARIA_SORT_VALUES.DESCENDING),
Match.when(ARIA_SORT_VALUES.DESCENDING, () => ARIA_SORT_VALUES.ASCENDING),
Match.when(ARIA_SORT_VALUES.NONE, () => ARIA_SORT_VALUES.ASCENDING),
Match.exhaustive,
);
export const getTmdbSortFunction = (sortData: TmdbSortData) =>
pipe(
// Récupère la fonction de tri correspondant à la propriété demandé.
Match.value(sortData.sortValue).pipe(
Match.when(TMDB_SORT_VALUES.ORIGINAL, () => byOriginalIndexAscending),
Match.when(TMDB_SORT_VALUES.POPULARITY, () => byPopularityAscending),
Match.when(TMDB_SORT_VALUES.RELEASE_DATE, () => byReleaseDateAscending),
Match.when(TMDB_SORT_VALUES.TITLE, () => byTitleAscending),
Match.orElse(() => byTitleAscending),
),
// Applique le bon sens (ascendant/descendant).
(sortFunction: Order.Order<[number, MergedTmdbLocalData]>) =>
Match.value(sortData.sortOrder).pipe(
Match.when(ARIA_SORT_VALUES.DESCENDING, () => Order.reverse(sortFunction)),
Match.orElse(() => sortFunction),
),
);
export const byOriginalIndexAscending = Order.mapInput(
Order.number,
(data: [number, MergedTmdbLocalData]) => data[1].original_result_index,
);
export const byReleaseDate = Order.mapInput(
Order.string,
(tmdbEntry: TmdbMovieSearchResponseResult) => tmdbEntry.release_date,
export const byPopularityAscending = Order.mapInput(
Order.number,
(data: [number, MergedTmdbLocalData]) => data[1].popularity,
);
// Tuples
export const tupleByTitle = Order.mapInput(
export const byReleaseDateAscending = Order.mapInput(
Order.string,
(data: [number, MergedTmdbLocalData]) => data[1].release_date,
);
export const byTitleAscending = Order.mapInput(
Order.string,
(data: [number, MergedTmdbLocalData]) => data[1].title,
);

View file

@ -0,0 +1,5 @@
export const ARIA_SORT_VALUES = {
ASCENDING: "ascending",
DESCENDING: "descending",
NONE: "none",
} as const;

View file

@ -11,12 +11,13 @@ export class MergedTmdbLocalData extends Schema.Class<MergedTmdbLocalData>("Merg
artWorkCoverPath: Schema.Union(Schema.String, Schema.Null),
artWorkId: Schema.NonNegativeInt.pipe(Schema.optional),
artWorkMediumTypeId: Schema.NonNegativeInt.pipe(Schema.optional),
entryDateCreated: Schema.String.pipe(Schema.optional),
entryDateModified: Schema.String.pipe(Schema.optional),
entryDateCreated: Schema.Date.pipe(Schema.optional),
entryDateModified: Schema.Date.pipe(Schema.optional),
entryId: Schema.NonNegativeInt.pipe(Schema.optional),
entryStateId: Schema.NonNegativeInt.pipe(Schema.optional),
genre_ids: Schema.Array(Schema.NonNegativeInt),
original_language: Schema.String,
original_result_index: Schema.Int,
original_title: Schema.String,
overview: Schema.String,
popularity: Schema.Number,

View file

@ -1,7 +1,13 @@
import type { NonEmptyArray } from "effect/Array";
import type { Router } from "vue-router";
import { PrettyLogger } from "@/services/logger";
import { UrlParams } from "@effect/platform";
import { Effect, pipe } from "effect";
import { Effect, Match, pipe } from "effect";
import type { AriaSortValues } from "./types";
import { ARIA_SORT_VALUES } from "./constants";
/**
* Transforme les valeurs d'un `FormData` en `Record` trié.
@ -9,7 +15,7 @@ import { Effect, pipe } from "effect";
* @param formData Les valeurs d'un formulaire.
* @returns Un `Effect` des valeurs.
*/
const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonEmptyArray<string> | string>> =>
export const formDataToRecord = (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.
@ -23,4 +29,30 @@ const formDataToRecord = (formData: FormData): Effect.Effect<Record<string, NonE
Effect.andThen((urlParams: UrlParams.UrlParams) => UrlParams.toRecord(urlParams)),
);
export default { formDataToRecord };
export const updateSortOrder = (sortOrder: AriaSortValues) =>
Match.value(sortOrder).pipe(
Match.when(ARIA_SORT_VALUES.NONE, () => ARIA_SORT_VALUES.ASCENDING),
Match.when(ARIA_SORT_VALUES.ASCENDING, () => ARIA_SORT_VALUES.DESCENDING),
Match.when(ARIA_SORT_VALUES.DESCENDING, () => ARIA_SORT_VALUES.ASCENDING),
Match.exhaustive,
);
export const updateUrlQueryFromFormData =
(router: Router, form: HTMLFormElement | null) => async (event?: Event): Promise<void> => {
event?.preventDefault();
await pipe(
// Garantis que l'Élément soit bien présent.
Effect.fromNullable(form),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((searchFormData: FormData) => formDataToRecord(searchFormData)),
// Met à jour les paramètres de l'URL.
Effect.tap((routeQueryParams: Record<string, NonEmptyArray<string> | string>) =>
router.push({ force: true, query: routeQueryParams })
),
Effect.tapError(Effect.logError),
Effect.ignore,
Effect.provide(PrettyLogger),
Effect.runPromise,
);
};

View file

@ -1,10 +0,0 @@
import type { ArtWork, DiaryEntry } from "@/db/schemas";
import type { TmdbMovieSearchResponseResult } from "../apis/tmdb/schemas";
/** Page de réponse de l'API TMDB avec les données locales correspondantes. */
export interface TmdbDataWithLocalData {
artWork?: ArtWork;
entry?: DiaryEntry;
tmdbData: TmdbMovieSearchResponseResult;
}

4
src/libs/search/types.ts Normal file
View file

@ -0,0 +1,4 @@
import type { Values } from "../utils/types";
import type { ARIA_SORT_VALUES } from "./constants";
export type AriaSortValues = Values<typeof ARIA_SORT_VALUES>;

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import type { Ref } from "vue";
import SearchMediaDialog from "@/components/dialogs/SearchMediaDialog.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 +25,7 @@
<section id="last-watched-media" class="stack">
<h2>Derniers médias regardés</h2>
<Suspense>
<LastAddedEntry> </LastAddedEntry>
<template #fallback>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { NonEmptyArray } from "effect/Array";
import type { Ref } from "vue";
import EditEntryDialog from "@/components/dialogs/EditEntryDialog.vue";
import ErrorMessage from "@/components/ErrorMessage.vue";
import LoadingMessage from "@/components/LoadingMessage.vue";
import TmdbSearchResults from "@/components/TmdbSearchResults.vue";
@ -13,35 +13,36 @@
TmdbMovieSearchResponseResult,
} from "@/libs/apis/tmdb/schemas.ts";
import { MergedTmdbLocalData, SearchPageQueryParams } from "@/libs/search/schemas.ts";
import Search from "@/libs/search/search.ts";
import { updateUrlQueryFromFormData } from "@/libs/search/search.ts";
import { getCurrentYear } from "@/libs/utils/dates.ts";
import { getOrUndefined } from "@/libs/utils/effects.ts";
import { PrettyLogger } from "@/services/logger.ts";
import { ReadApi } from "@/services/read-api.ts";
import { RuntimeClient } from "@/services/runtime-client.ts";
import { TmdbApi } from "@/services/tmdb-api.ts";
import { Array as Arr, Effect, pipe, Schema } from "effect";
import { Effect, pipe, Schema } from "effect";
import { computed, onMounted, ref, useTemplateRef, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
// États
/** L'année courante pour la limite supérieure du champs Année de la recherché. */
/** Année courante pour la limite supérieure du champs « Année » de la recherche. */
const currentYear: number = getCurrentYear();
const route = useRoute();
const router = useRouter();
/** Effet des paramètres validés de la route. */
/** Effet dérivé des paramètres validés de la route. */
const routeQueryParams = computed(() => Schema.decodeUnknown(SearchPageQueryParams)(route.query));
/** Le formulaire de recherche. */
/** L'Élément DOM du formulaire de recherche. */
const form = useTemplateRef("form");
/** Les valeurs du formulaire de recherche. */
/** Valeurs du formulaire de recherche. */
const searchFormData: Ref<SearchPageQueryParams | undefined> = ref<SearchPageQueryParams>();
/** Le retour de la requête de recherche de films auprès de l'API TMDB. */
/** Retour de la requête de recherche de films auprès de l'API TMDB. */
const search: Ref<TmdbMovieSearchResponse | undefined> = ref<TmdbMovieSearchResponse>();
/** Données complètes de la recherche avec les données TMDB et locales. */
const searchData: Ref<Map<number, MergedTmdbLocalData>> = ref(new Map<number, MergedTmdbLocalData>());
/** État du chargement de la requête auprès de l'API TMDB. */
@ -51,25 +52,11 @@
/** Message affiché à l'Utilisateur. */
const message: Ref<string> = ref("");
const editedEntry: Ref<MergedTmdbLocalData | undefined> = ref();
// Fonctions
const updateUrlQuery = async (event?: Event): Promise<void> => {
event?.preventDefault();
await pipe(
Effect.fromNullable(form.value),
Effect.andThen((form: HTMLFormElement) => new FormData(form)),
Effect.andThen((searchFormData: FormData) => Search.formDataToRecord(searchFormData)),
// Met à jour les paramètres de l'URL.
Effect.tap((routeQueryParams: Record<string, NonEmptyArray<string> | string>) =>
router.push({ force: true, query: routeQueryParams })
),
Effect.tapError(Effect.logError),
Effect.ignore,
Effect.provide(PrettyLogger),
Effect.runPromise,
);
};
let updateUrlQuery = updateUrlQueryFromFormData(router, form.value);
const resetInitialState = async (event: Event): Promise<void> => {
event.preventDefault();
@ -132,6 +119,15 @@
Effect.runPromise,
);
const toggleEntryDialog = (tmdbId?: number) => {
if (!tmdbId) {
editedEntry.value = undefined;
return;
}
editedEntry.value = searchData.value.get(tmdbId);
};
// Cycles
watch(search, async (): Promise<void> => {
@ -140,7 +136,7 @@
const results = search.value?.results ?? [];
const readApi = yield* ReadApi;
void results.map((result: TmdbMovieSearchResponseResult) =>
void results.map((result: TmdbMovieSearchResponseResult, index: number) =>
Effect.gen(function*() {
const entry = yield* pipe(
readApi.getEntryByTmdbId(result.id),
@ -152,6 +148,7 @@
effect => getOrUndefined(effect),
);
// TODO: Uniformiser la casse des propriétés.
searchData.value.set(
result.id,
yield* Schema.decodeUnknown(MergedTmdbLocalData)(
@ -165,6 +162,7 @@
entryStateId: entry?.stateId,
genre_ids: result.genre_ids,
original_language: result.original_language,
original_result_index: index,
original_title: result.original_title,
overview: result.overview,
popularity: result.popularity,
@ -185,6 +183,7 @@
onMounted(() => {
console.debug("SearchPage.vue -- Mounted");
updateUrlQuery = updateUrlQueryFromFormData(router, form.value);
});
</script>
@ -251,9 +250,11 @@
<LoadingMessage v-if="isLoading">Récupération des résultats</LoadingMessage>
<ErrorMessage v-if="isErrored">{{ message }}</ErrorMessage>
<TmdbSearchResults v-else :search-data="searchData"></TmdbSearchResults>
<TmdbSearchResults v-else :search-data="searchData" @entry-dialog-wanted="toggleEntryDialog"></TmdbSearchResults>
</section>
</div>
<EditEntryDialog v-if="editedEntry" :entry-data="editedEntry" @dialog-hidden="toggleEntryDialog()"></EditEntryDialog>
</template>
<style scoped lang="css">

27
src/services/images.ts Normal file
View file

@ -0,0 +1,27 @@
import { asInt } from "@thi.ng/color-palettes";
import { ARGB8888, canvasFromPixelBuffer, defIndexed, imageFromURL, intBufferFromImage } from "@thi.ng/pixel";
import { ATKINSON, ditherWith } from "@thi.ng/pixel-dither";
import { Data, Effect, pipe } from "effect";
class ImagesError extends Data.TaggedError("ImagesError")<{ cause: unknown }> {}
export class Images extends Effect.Service<Images>()("Images", {
effect: Effect.gen(function*() {
return {
ditherImage: (image: HTMLImageElement, parent?: HTMLElement) =>
Effect.gen(function*() {
const buf = intBufferFromImage(image, ARGB8888).scale(0.8, "cubic");
const theme = defIndexed(asInt(["salmon", "black"]));
const ditheredBuf = ditherWith(ATKINSON, buf.copy(), {}).as(theme);
const canvas = canvasFromPixelBuffer(ditheredBuf, parent, { pixelated: true });
return canvas;
}),
imageFromUrl: (url: URL) =>
pipe(
Effect.tryPromise(() => imageFromURL(url.toString())),
Effect.mapError(e => new ImagesError({ cause: e.message })),
),
};
}),
}) {}

View file

@ -1,6 +1,6 @@
import type { SQLocalDrizzle } from "sqlocal/drizzle";
import v0000 from "@/db/drizzle/0000_open_the_twelve.sql?raw";
import v0000 from "@/db/drizzle/0000_unusual_karen_page.sql?raw";
import { Data, Effect } from "effect";
import { LocalSqlite } from "./db";

View file

@ -1,6 +1,7 @@
import { Layer, ManagedRuntime } from "effect";
import { LocalSqlite } from "./db";
import { Images } from "./images";
import { PrettyLogger } from "./logger";
import { Migrations } from "./migrations";
import { ReadApi } from "./read-api";
@ -12,6 +13,7 @@ const MainLayer = Layer.mergeAll(
Migrations.Default,
ReadApi.Default,
TmdbApi.Default,
Images.Default,
).pipe(Layer.provide(PrettyLogger));
export const RuntimeClient = ManagedRuntime.make(MainLayer);

View file

@ -2,8 +2,6 @@ html {
box-sizing: border-box;
block-size: 100%;
text-size-adjust: none;
text-size-adjust: none;
text-size-adjust: none;
tab-size: 2;
color-scheme: dark light;
interpolate-size: allow-keywords;
@ -48,7 +46,7 @@ body {
clip-path: inset(50%);
}
:where([hidden]), :where([aria-hidden="true"]) {
:where([hidden]) {
display: none;
}

View file

@ -14,11 +14,11 @@
inline-size: fit-content;
}
:where(a) {
:where(a:not([class])) {
text-decoration: underline dashed;
text-decoration-skip-ink: all;
.external {
&.external {
text-decoration: underline solid;
}
}

View file

@ -1,4 +1,4 @@
button {
button:not(.button-invisible) {
--button-background-color: var(--root-background-color);
--button-border-color: var(--root-text-color);
--button-font-weight: 100;