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

This commit is contained in:
gcch 2025-03-05 09:10:16 +01:00
commit ad01868a9f
23 changed files with 328 additions and 97 deletions

View file

@ -90,12 +90,12 @@
tabindex="0" @click="onRowClicked(result[0])" @keypress="onRowClicked(result[0])"
>
<th class="name" scope="row">
{{ result[1].original_result_index }}
{{ result[1].originalResultIndex }}
</th>
<th class="name" scope="row">
{{ result[1].original_title }}
{{ result[1].originalTitle }}
</th>
<td class="release-date">{{ result[1].release_date }}</td>
<td class="release-date">{{ result[1].releaseDate }}</td>
<td class="popularite">{{ result[1].popularity }}</td>
</tr>
</tbody>

View file

@ -1,37 +1,62 @@
<script setup lang="ts">
import type { MergedTmdbLocalData } from "@/libs/search/schemas";
import type { ComputedRef, Ref, ShallowRef } from "vue";
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";
import { Effect, pipe } from "effect";
import { isTruthy } from "effect/Predicate";
import { onMounted, ref, useTemplateRef, watch } from "vue";
import { computed } from "vue";
// Émissions et props
const emit = defineEmits(["dialog-hidden"]);
const { entryData } = defineProps<{ entryData: MergedTmdbLocalData }>();
const ditheredPoster = ref<HTMLCanvasElement>();
const imageContainer = useTemplateRef("imageContainer");
const imageContainer: Readonly<ShallowRef<HTMLDivElement | null>> = useTemplateRef("imageContainer");
const ditheredPoster: Ref<HTMLCanvasElement | undefined> = ref<HTMLCanvasElement>();
const isFirstTimeEntryEditing: Ref<boolean> = ref<boolean>(false);
const closeDialog = () => {
const hasEntry: ComputedRef<boolean> = computed(() => isTruthy(entryData.entryId));
const hasUniqueOriginalTitle: ComputedRef<boolean> = computed(() =>
entryData.originalTitle.toLowerCase() !== entryData.title.toLowerCase()
);
const firstTimeEditingButtonText: ComputedRef<"Ajouter" | "Annuler"> = computed(() =>
isFirstTimeEntryEditing.value ? "Annuler" : "Ajouter"
);
// Gestionnaires d'événements
const closeDialog = (event?: Event): void => {
event?.preventDefault();
emit("dialog-hidden");
};
const toggleFirstTimeEditing = (event?: Event): void => {
event?.preventDefault();
isFirstTimeEntryEditing.value = !isFirstTimeEntryEditing.value;
};
watchEffect(async () => {
// Cycles
watch(() => entryData, async (): Promise<void> => {
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);
if (!entryData.posterUrl) return undefined;
console.debug("dithering");
return ditheredImage;
const imageService: Images = yield* Images;
return yield* pipe(
Url.fromString(`https://image.tmdb.org/t/p/w500/${entryData.posterUrl}`),
Effect.andThen((url: URL) => imageService.imageFromUrl(url)),
Effect.andThen((img: HTMLImageElement) => imageService.ditherImage(img, imageContainer.value ?? undefined)),
Effect.andThen(dithered => dithered.canvas),
);
// const base64 = encodeBase64Url(ditheredImage.buffer.data);
// console.debug(base64.length);
}));
});
}, { immediate: true });
onMounted(() => {
console.debug("EditEntryDialog mounted");
@ -43,13 +68,47 @@
<template #title>Éditer une entrée</template>
<template #content>
<section aria-labelledby="media-title" class="switcher">
<section aria-labelledby="media-title" class="switcher container">
<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>
<h3 id="media-title">{{ entryData.originalTitle }}</h3>
<p class="original-metadata">
<span v-if="hasUniqueOriginalTitle">{{ entryData.title }} | </span>
{{ entryData.releaseDate }} | {{ entryData.originalLanguage }} | {{ entryData.popularity }}
</p>
<p class="overview">{{ entryData.overview }}</p>
<h3>Journal</h3>
<div class="cluster entry-state">
<p>
<strong>État : </strong>
<span v-if="hasEntry"></span>
<span v-else>Pas encore dans le journal.</span>
</p>
<button v-if="!hasEntry" :class="{ invert: isFirstTimeEntryEditing }" @click="toggleFirstTimeEditing">
{{ firstTimeEditingButtonText }}
</button>
</div>
<form v-if="hasEntry || isFirstTimeEntryEditing" class="cluster entry-metadata">
<div class="field stack">
<label for="date-created">Date de création</label>
<input id="date-created" type="datetime-local">
</div>
<div class="field stack">
<label for="date-created">Date de modification</label>
<input id="date-created" type="datetime-local">
</div>
<div class="field stack">
<label for="date-created">Date d'obtention</label>
<input id="date-created" type="datetime-local">
</div>
</form>
</div>
</section>
</template>
@ -72,7 +131,17 @@
}
}
.container {
inline-size: 85vi;
max-inline-size: 74rem;
}
.overview {
max-inline-size: 40rem;
}
.entry-metadata {
--layout-cluster-gap: var(--s0);
margin-block-start: var(--s2);
}
</style>

View file

@ -0,0 +1,4 @@
<script lang="ts">
</script>
<template></template>

View file

@ -1,7 +1,10 @@
CREATE TABLE `diary_entries` (
`appreciation` text NOT NULL,
`art_work_id` integer NOT NULL,
`commentary` text,
`date_created` integer NOT NULL,
`date_modified` integer NOT NULL,
`date_obtained` 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,
@ -59,4 +62,10 @@ CREATE TABLE `media_types` (
);
--> statement-breakpoint
CREATE UNIQUE INDEX `media_types_name_unique` ON `media_types` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `media_types_slug_unique` ON `media_types` (`slug`);
CREATE UNIQUE INDEX `media_types_slug_unique` ON `media_types` (`slug`);--> statement-breakpoint
CREATE TABLE `posters` (
`art_work_id` integer NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`image` blob NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action
);

View file

@ -1,12 +1,19 @@
{
"version": "6",
"dialect": "sqlite",
"id": "8b217318-7662-4f81-bc55-92d5c6ddf2b2",
"id": "01d72a44-6bdd-4f2a-b1dd-34546aa1b734",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"diary_entries": {
"name": "diary_entries",
"columns": {
"appreciation": {
"name": "appreciation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"art_work_id": {
"name": "art_work_id",
"type": "integer",
@ -14,6 +21,13 @@
"notNull": true,
"autoincrement": false
},
"commentary": {
"name": "commentary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date_created": {
"name": "date_created",
"type": "integer",
@ -28,6 +42,13 @@
"notNull": true,
"autoincrement": false
},
"date_obtained": {
"name": "date_obtained",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "integer",
@ -404,6 +425,51 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posters": {
"name": "posters",
"columns": {
"art_work_id": {
"name": "art_work_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"image": {
"name": "image",
"type": "blob",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"posters_art_work_id_art_works_id_fk": {
"name": "posters_art_work_id_art_works_id_fk",
"tableFrom": "posters",
"tableTo": "art_works",
"columnsFrom": [
"art_work_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},

View file

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

View file

@ -20,3 +20,14 @@ export const DIARY_ENTRY_STATES = {
/** Un média ayant été regardé au mois une fois. */
WATCHED: "watched",
} as const;
export const APPRECIATION_STATES = {
/** Oeuvre appréciée. */
APPRECIATED: "appreciated",
/** Oeuvre non appréciée (détestée). */
DISLIKED: "disliked",
/** Oeuvre laissant de marbre. */
NEUTRAL: "neutral",
/** Appréciation inconnue. */
UNKNOWN: "unknown",
} as const;

View file

@ -5,15 +5,18 @@ import * as t from "drizzle-orm/sqlite-core";
import { sqliteTable as table } from "drizzle-orm/sqlite-core";
import { Schema } from "effect";
import { DIARY_ENTRY_STATES } from "./constants";
import { APPRECIATION_STATES, DIARY_ENTRY_STATES } from "./constants";
import { ArtWorks, Genres } from "./works";
// Tables
export const DiaryEntries = table("diary_entries", {
appreciation: t.text("appreciation").$type<Values<typeof APPRECIATION_STATES>>().notNull(),
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
commentary: t.text("commentary").notNull(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),
dateModified: t.integer("date_modified", { mode: "timestamp" }).notNull(),
dateObtained: t.integer("date_obtained", { mode: "timestamp" }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
stateId: t.integer("state_id").references((): AnySQLiteColumn => DiaryEntriesStates.id).notNull(),
});
@ -38,9 +41,12 @@ export const Viewings = table("viewings", {
// Schémas
export const DiaryEntrySchema = Schema.Struct({
appreciation: Schema.Enums(APPRECIATION_STATES),
artWorkId: Schema.NonNegativeInt,
commentary: Schema.String.pipe(Schema.optional),
dateCreated: Schema.Number,
dateModified: Schema.Number,
dateObtained: Schema.Number,
id: Schema.NonNegativeInt,
stateId: Schema.NonNegativeInt,
});

View file

@ -15,6 +15,12 @@ export const MediaTypes = table("media_types", {
slug: t.text("slug").$type<Values<typeof MEDIA_TYPES>>().notNull().unique(),
});
export const Posters = table("posters", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
image: t.blob("image", { mode: "buffer" }).notNull(),
});
export const ArtWorks = table("art_works", {
coverPath: t.text("cover_path").unique(),
dateCreated: t.integer("date_created", { mode: "timestamp" }).notNull(),

View file

@ -47,7 +47,7 @@ export const getTmdbSortFunction = (sortData: TmdbSortData) =>
export const byOriginalIndexAscending = Order.mapInput(
Order.number,
(data: [number, MergedTmdbLocalData]) => data[1].original_result_index,
(data: [number, MergedTmdbLocalData]) => data[1].originalResultIndex,
);
export const byPopularityAscending = Order.mapInput(
Order.number,
@ -55,7 +55,7 @@ export const byPopularityAscending = Order.mapInput(
);
export const byReleaseDateAscending = Order.mapInput(
Order.string,
(data: [number, MergedTmdbLocalData]) => data[1].release_date,
(data: [number, MergedTmdbLocalData]) => data[1].releaseDate,
);
export const byTitleAscending = Order.mapInput(
Order.string,

View file

@ -1,4 +1,4 @@
import { MEDIA_TYPES } from "@/db/schemas/constants";
import { APPRECIATION_STATES, MEDIA_TYPES } from "@/db/schemas/constants";
import { Schema } from "effect";
export class SearchPageQueryParams extends Schema.Class<SearchPageQueryParams>("SearchPageQueryParams")({
@ -8,20 +8,24 @@ export class SearchPageQueryParams extends Schema.Class<SearchPageQueryParams>("
}) {}
export class MergedTmdbLocalData extends Schema.Class<MergedTmdbLocalData>("MergedTmdbLocalData")({
artWorkCoverPath: Schema.Union(Schema.String, Schema.Null),
artWorkId: Schema.NonNegativeInt.pipe(Schema.optional),
artWorkMediumTypeId: Schema.NonNegativeInt.pipe(Schema.optional),
entryAppreciation: Schema.Enums(APPRECIATION_STATES).pipe(Schema.optional),
entryCommentary: Schema.String.pipe(Schema.optional),
entryDateCreated: Schema.Date.pipe(Schema.optional),
entryDateModified: Schema.Date.pipe(Schema.optional),
entryDateObtained: 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,
genreIds: Schema.Array(Schema.NonNegativeInt),
originalLanguage: Schema.String,
originalResultIndex: Schema.Int,
originalTitle: Schema.String,
overview: Schema.String,
popularity: Schema.Number,
release_date: Schema.String,
posterBlob: Schema.Unknown.pipe(Schema.optional),
posterUrl: Schema.Union(Schema.String, Schema.Null),
releaseDate: Schema.String,
title: Schema.String,
tmdbId: Schema.NonNegativeInt,
}) {}

View file

@ -153,20 +153,23 @@
result.id,
yield* Schema.decodeUnknown(MergedTmdbLocalData)(
{
artWorkCoverPath: result.poster_path,
artWorkId: artWork?.id,
artWorkMediumTypeId: artWork?.mediumTypeId,
entryAppreciation: entry?.appreciation,
entryCommentary: entry?.commentary,
entryDateCreated: entry?.dateCreated,
entryDateModified: entry?.dateModified,
entryDateObtained: entry?.dateObtained,
entryId: entry?.id,
entryStateId: entry?.stateId,
genre_ids: result.genre_ids,
original_language: result.original_language,
original_result_index: index,
original_title: result.original_title,
genreIds: result.genre_ids,
originalLanguage: result.original_language,
originalResultIndex: index,
originalTitle: result.original_title,
overview: result.overview,
popularity: result.popularity,
release_date: result.release_date,
posterUrl: result.poster_path,
releaseDate: result.release_date,
title: result.title,
tmdbId: result.id,
} satisfies MergedTmdbLocalData,

View file

@ -1,5 +1,13 @@
import { asInt } from "@thi.ng/color-palettes";
import { ARGB8888, canvasFromPixelBuffer, defIndexed, imageFromURL, intBufferFromImage } from "@thi.ng/pixel";
import {
ARGB8888,
canvasFromPixelBuffer,
defIndexed,
imageFromURL,
IntBuffer,
intBufferFromImage,
type IntFormat,
} from "@thi.ng/pixel";
import { ATKINSON, ditherWith } from "@thi.ng/pixel-dither";
import { Data, Effect, pipe } from "effect";
@ -10,12 +18,12 @@ export class Images extends Effect.Service<Images>()("Images", {
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 buf: IntBuffer = intBufferFromImage(image, ARGB8888).scale(0.8, "cubic");
const theme: IntFormat = defIndexed(asInt(["salmon", "black"]));
const ditheredBuffer: IntBuffer = ditherWith(ATKINSON, buf.copy(), {}).as(theme);
const canvas = canvasFromPixelBuffer(ditheredBuf, parent, { pixelated: true });
return canvas;
const canvas = canvasFromPixelBuffer(ditheredBuffer, parent, { pixelated: true });
return { buffer: ditheredBuffer, canvas: canvas };
}),
imageFromUrl: (url: URL) =>
pipe(

View file

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

View file

@ -48,6 +48,10 @@ body {
}
}
strong {
font-weight: var(--brkly-font-weight-semibold);
}
.container {
--layout-center-max-width: 100%;
--layout-center-inline-padding: var(--s1);