This commit is contained in:
parent
650f381148
commit
11fa3d1558
38 changed files with 819 additions and 148 deletions
|
|
@ -1,10 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { TmdbMovieSearchResponseResult } from "@/libs/apis/tmdb/schemas";
|
||||
|
||||
defineProps<{
|
||||
entryId: number;
|
||||
tmdbSearchData: TmdbMovieSearchResponseResult;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
78
src/components/dialogs/EditEntryDialog.vue
Normal file
78
src/components/dialogs/EditEntryDialog.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
70
src/components/tables/TableHeadingSortableColumn.vue
Normal file
70
src/components/tables/TableHeadingSortableColumn.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1740666437095,
|
||||
"tag": "0000_open_the_twelve",
|
||||
"when": 1740814587298,
|
||||
"tag": "0000_unusual_karen_page",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
5
src/libs/search/constants.ts
Normal file
5
src/libs/search/constants.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const ARIA_SORT_VALUES = {
|
||||
ASCENDING: "ascending",
|
||||
DESCENDING: "descending",
|
||||
NONE: "none",
|
||||
} as const;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
10
src/libs/search/types.d.ts
vendored
10
src/libs/search/types.d.ts
vendored
|
|
@ -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
4
src/libs/search/types.ts
Normal 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>;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
27
src/services/images.ts
Normal 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 })),
|
||||
),
|
||||
};
|
||||
}),
|
||||
}) {}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue