2025-02-21

This commit is contained in:
gcch 2025-02-21 23:23:22 +01:00
commit 5d5918f0d7
69 changed files with 1481 additions and 305 deletions

View file

@ -1,7 +1,33 @@
<script setup lang="ts">
import "@/styles/main.css";
import { Transition } from "vue";
import { RouterView } from "vue-router";
import SidebarView from "@/views/SidebarView.vue";
import MainHeader from "./components/MainHeader.vue";
</script>
<template>
<RouterView></RouterView>
<div class="container center with-sidebar">
<main class="box stack">
<MainHeader></MainHeader>
<RouterView v-slot="{ Component, route }">
<Transition name="fade" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</main>
<SidebarView></SidebarView>
</div>
</template>
<style lang="css">
.fade-enter-active, .fade-leave-active {
transition: opacity 0.2s linear;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

View file

@ -1,3 +0,0 @@
<template>
<p>Error</p>
</template>

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import A11yDialog from "a11y-dialog";
import { useTemplateRef } from "vue";
import { watchEffect } from "vue";
import { ref } from "vue";
import { computed } from "vue";
import type { ComputedRef } from "vue";
const props = defineProps<{
dialogId: string;
toggled: boolean;
}>();
defineEmits<{
(e: "dialog-hidden", dialogId: string): void;
}>();
const dialogTitleId: ComputedRef<string> = computed(() => `${props.dialogId}-title`);
const dialogContainer = useTemplateRef<HTMLDivElement>("dialog");
let dialog = ref<A11yDialog>();
const closeDialog = () => dialog.value?.hide();
watchEffect(() => {
if (dialogContainer.value) {
dialog.value = new A11yDialog(dialogContainer.value);
}
if (props.toggled) {
dialog.value?.show();
}
});
</script>
<template>
<div
aria-hidden="true" :aria-labelledby="dialogTitleId" class="dialog"
:id="dialogId" ref="dialog" @hide="$emit('dialog-hidden', dialogId)"
>
<div class="dialog-content box" role="document">
<header class="invert">
<h2 :id="dialogTitleId">
<slot name="title"></slot>
</h2>
<button @click="closeDialog" class="integrated" type="button">X</button>
</header>
<main class="box">
<slot name="content"></slot>
</main>
</div>
</div>
</template>
<style scoped src="@/styles/components/imposter-box.css"></style>

View file

@ -1,19 +1,25 @@
<script setup lang="ts">
import { type DiaryEntry } from "@/db/schemas/entries";
import { ReadApi } from "@/services/read-api";
import { Console, Effect, pipe } from "effect";
import { RuntimeClient } from "@/services/runtime-client";
import { Effect } from "effect";
const lastAddedEntry = await pipe(
Effect.andThen(ReadApi, (api: ReadApi) =>
pipe(
api.getLastAddedEntry(),
Effect.tapError(Console.warn),
const lastAddedEntry: DiaryEntry | null = await RuntimeClient.runPromise(
Effect.gen(function*() {
const readApi = yield* ReadApi;
return yield* readApi.getLastAddedEntry().pipe(
Effect.tapErrorCause(cause => Effect.logError(cause.toJSON())),
Effect.orElseSucceed(() => null),
)),
Effect.provide(ReadApi.Default),
Effect.runPromise,
);
}),
);
</script>
<template>
<p>hello</p>
<p v-if="lastAddedEntry">
{{ lastAddedEntry?.id }}
</p>
</template>

View file

@ -1,3 +0,0 @@
<template>
<p>loading...</p>
</template>

View file

@ -0,0 +1,8 @@
<script setup lang="ts">
</script>
<template>
<header>
<h1>Journal Média</h1>
</header>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { RouterLink } from "vue-router";
</script>
<template>
<aside class="box sidebar">
<nav id="primary-navigation">
<ul>
<li><RouterLink to="/">Accueil</RouterLink></li>
</ul>
</nav>
</aside>
</template>
<style scoped src="@/styles/components/navigation-menu.css">
</style>

View file

@ -1,20 +1,5 @@
import type { Config } from "drizzle-kit";
import { loadEnv } from "vite";
const env = loadEnv("development", process.cwd(), "");
const DATABASE_URL = env["DATABASE_URL"];
// L'URL de la BDD doit être valide.
if (!DATABASE_URL || DATABASE_URL === "") {
throw new Error("L'URL de la base de données doit être renseignée dans le fichier de variables d'environnement.");
}
const DrizzleKitConfig: Config = {
dbCredentials: { url: DATABASE_URL },
dialect: "sqlite",
out: "./src/db/drizzle",
schema: "./src/db/schema.ts",
};
const DrizzleKitConfig: Config = { dialect: "sqlite", out: "./src/db/drizzle", schema: "./src/db/schemas.ts" };
export default DrizzleKitConfig;

View file

@ -3,7 +3,7 @@ CREATE TABLE `art_works` (
`medium_type_id` integer,
`name` text NOT NULL,
`release_date` text(10) NOT NULL,
FOREIGN KEY (`medium_type_id`) REFERENCES `medium_types`(`id`) ON UPDATE no action ON DELETE no action
FOREIGN KEY (`medium_type_id`) REFERENCES `media_types`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `diary_entries` (
@ -40,14 +40,14 @@ CREATE TABLE `genres` (
--> statement-breakpoint
CREATE UNIQUE INDEX `genres_name_unique` ON `genres` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `genres_slug_unique` ON `genres` (`slug`);--> statement-breakpoint
CREATE TABLE `medium_types` (
CREATE TABLE `media_types` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `medium_types_name_unique` ON `medium_types` (`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `medium_types_slug_unique` ON `medium_types` (`slug`);--> 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`);--> statement-breakpoint
CREATE TABLE `users` (
`email` text NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,

View file

@ -26,10 +26,10 @@
},
"indexes": {},
"foreignKeys": {
"art_works_medium_type_id_medium_types_id_fk": {
"name": "art_works_medium_type_id_medium_types_id_fk",
"art_works_medium_type_id_media_types_id_fk": {
"name": "art_works_medium_type_id_media_types_id_fk",
"tableFrom": "art_works",
"tableTo": "medium_types",
"tableTo": "media_types",
"columnsFrom": ["medium_type_id"],
"columnsTo": ["id"],
"onDelete": "no action",
@ -192,16 +192,16 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"medium_types": {
"name": "medium_types",
"media_types": {
"name": "media_types",
"columns": {
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false },
"slug": { "name": "slug", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }
},
"indexes": {
"medium_types_name_unique": { "name": "medium_types_name_unique", "columns": ["name"], "isUnique": true },
"medium_types_slug_unique": { "name": "medium_types_slug_unique", "columns": ["slug"], "isUnique": true }
"media_types_name_unique": { "name": "media_types_name_unique", "columns": ["name"], "isUnique": true },
"media_types_slug_unique": { "name": "media_types_slug_unique", "columns": ["slug"], "isUnique": true }
},
"foreignKeys": {},
"compositePrimaryKeys": {},

View file

@ -1,76 +0,0 @@
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 type { DIARY_ENTRY_STATES, MEDIUM_TYPES } from "./constants";
/**
* Configuration de la BDD SQLite de l'application.
*
* Assumer ici que toute colonne :
* - non explicitement définie comme optionnelle est obligatoire ;
* - non explicitement définie comme unique peut contenir plusieurs fois la même valeur ;
*/
export const Users = table("users", {
email: t.text("email").notNull().unique(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
name: t.text("name").notNull().unique(),
});
export type User = InferSelectModel<typeof Users>;
export const MediumTypes = table("medium_types", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
name: t.text("name").notNull().unique(),
slug: t.text("slug").$type<Values<typeof MEDIUM_TYPES>>().notNull().unique(),
});
export type MediumType = InferSelectModel<typeof MediumTypes>;
export const ArtWorks = table("art_works", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
mediumTypeId: t.integer("medium_type_id").references((): AnySQLiteColumn => MediumTypes.id),
name: t.text("name").notNull(),
releaseDate: t.text("release_date", { length: 10 }).notNull(),
});
export type ArtWork = InferSelectModel<typeof ArtWorks>;
export const Genres = table("genres", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
name: t.text("name").notNull().unique(),
slug: t.text("slug").notNull().unique(),
});
export type Genre = InferSelectModel<typeof Genres>;
export const DiaryEntries = table("diary_entries", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id),
dateCreated: t.text("date_created", { length: 10 }).notNull(),
dateModified: t.text("date_modified", { length: 10 }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
stateId: t.integer("state_id").references((): AnySQLiteColumn => DiaryEntriesStates.id),
userId: t.integer("user_id").references((): AnySQLiteColumn => Users.id),
});
export type DiaryEntry = InferSelectModel<typeof DiaryEntries>;
export const Viewings = table("viewings", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id),
date: t.text("date", { length: 10 }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
userId: t.integer("user_id").references((): AnySQLiteColumn => Users.id),
});
export type Viewing = InferSelectModel<typeof Viewings>;
export const DiaryEntriesGenres = table("diary_entries_genres", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id),
genreId: t.integer("genre_id").references((): AnySQLiteColumn => Genres.id),
id: t.integer("id").primaryKey({ autoIncrement: true }),
});
export type DiaryEntryGenre = InferSelectModel<typeof DiaryEntriesGenres>;
export const DiaryEntriesStates = table("diary_entries_states", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
state: t.text("state").$type<Values<typeof DIARY_ENTRY_STATES>>().notNull().unique(),
});
export type DiaryEntryState = InferSelectModel<typeof DiaryEntriesStates>;

View file

@ -1,4 +1,4 @@
export const MEDIUM_TYPES = {
export const MEDIA_TYPES = {
DOCUMENTARY: "documentary",
FILM: "film",
SERIES: "series",

40
src/db/schemas/entries.ts Normal file
View file

@ -0,0 +1,40 @@
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 type { DIARY_ENTRY_STATES } from "./constants";
import { ArtWorks, Genres } from "./works";
export const DiaryEntries = table("diary_entries", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id),
dateCreated: t.text("date_created", { length: 10 }).notNull(),
dateModified: t.text("date_modified", { length: 10 }).notNull(),
id: t.integer("id").primaryKey({ autoIncrement: true }),
stateId: t.integer("state_id").references((): AnySQLiteColumn => DiaryEntriesStates.id).notNull(),
});
export const DiaryEntriesGenres = table("diary_entries_genres", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
genreId: t.integer("genre_id").references((): AnySQLiteColumn => Genres.id),
id: t.integer("id").primaryKey({ autoIncrement: true }),
});
export const DiaryEntriesStates = table("diary_entries_states", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
state: t.text("state").$type<Values<typeof DIARY_ENTRY_STATES>>().notNull().unique(),
});
export const Viewings = table("viewings", {
artWorkId: t.integer("art_work_id").references((): AnySQLiteColumn => ArtWorks.id).notNull(),
date: t.text("date", { length: 10 }).notNull(),
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>;

31
src/db/schemas/works.ts Normal file
View file

@ -0,0 +1,31 @@
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 type { MEDIA_TYPES } from "./constants";
export const MediaTypes = table("media_types", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
name: t.text("name").notNull().unique(),
slug: t.text("slug").$type<Values<typeof MEDIA_TYPES>>().notNull().unique(),
});
export const ArtWorks = table("art_works", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
mediumTypeId: t.integer("medium_type_id").references((): AnySQLiteColumn => MediaTypes.id).notNull(),
name: t.text("name").notNull(),
releaseDate: t.text("release_date", { length: 10 }).notNull(),
});
export const Genres = table("genres", {
id: t.integer("id").primaryKey({ autoIncrement: true }),
name: t.text("name").notNull().unique(),
slug: t.text("slug").notNull().unique(),
});
export type ArtWork = InferSelectModel<typeof ArtWorks>;
export type Genre = InferSelectModel<typeof Genres>;
export type MediaType = InferSelectModel<typeof MediaTypes>;

View file

@ -15,28 +15,33 @@ export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQuer
),
primary_release_year: Schema.NonEmptyString.pipe(Schema.length(4), Schema.optional),
query: Schema.NonEmptyString,
region: Schema.NonEmptyString.pipe(Schema.propertySignature, Schema.withConstructorDefault(() => "fr")),
region: Schema.NonEmptyString.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => "fr"),
),
year: Schema.NonEmptyString.pipe(Schema.length(4), Schema.optional),
}) {}
export class TmdbMovieSearchResponse extends Schema.Class<TmdbMovieSearchResponse>("TmdbMovieSearchResponse")({
page: Schema.NonNegativeInt,
results: Schema.Array(Schema.Struct({
adult: Schema.Boolean,
backdrop_path: Schema.Union(Schema.String, Schema.Null),
genre_ids: Schema.Array(Schema.NonNegativeInt),
id: Schema.NonNegativeInt,
original_language: Schema.String,
original_title: Schema.String,
overview: Schema.String,
popularity: Schema.Number,
poster_path: Schema.Union(Schema.String, Schema.Null),
release_date: Schema.NonEmptyString.pipe(Schema.length(10)),
title: Schema.String,
video: Schema.Boolean,
vote_average: Schema.Number,
vote_count: Schema.NonNegativeInt,
})),
results: Schema.Array(
Schema.Struct({
adult: Schema.Boolean,
backdrop_path: Schema.Union(Schema.String, Schema.Null),
genre_ids: Schema.Array(Schema.NonNegativeInt),
id: Schema.NonNegativeInt,
original_language: Schema.String,
original_title: Schema.String,
overview: Schema.String,
popularity: Schema.Number,
poster_path: Schema.Union(Schema.String, Schema.Null),
release_date: Schema.NonEmptyString.pipe(Schema.length(10)),
title: Schema.String,
video: Schema.Boolean,
vote_average: Schema.Number,
vote_count: Schema.NonNegativeInt,
}),
),
total_pages: Schema.NonNegativeInt,
total_results: Schema.NonNegativeInt,
}) {}

16
src/libs/types/events.ts Normal file
View file

@ -0,0 +1,16 @@
/** Événement émis au clic d'un Élément demandant l'ouverture d'une modale particulière. */
export class DialogWantedEvent extends Event {
/** L'ID de la modale demandée. */
dialogId: string;
constructor(dialogId: string) {
super("dialog-wanted", { bubbles: true, composed: true });
this.dialogId = dialogId;
}
}
declare global {
interface GlobalEventHandlersEventMap {
"dialog-wanted": DialogWantedEvent;
}
}

7
src/libs/utils/dates.ts Normal file
View file

@ -0,0 +1,7 @@
export const getTodayDate = (): string =>
new Date(Date.now()).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
weekday: "long",
year: "numeric",
});

69
src/pages/HomePage.vue Normal file
View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import ImposterBox from "@/components/ImposterBox.vue";
import type { Ref } from "vue";
import { onMounted, ref } from "vue";
const toggleDialogStateRef = (stateRef: Ref<boolean, boolean>) => () => {
stateRef.value = !stateRef.value;
};
const isAddMediaToggled = ref(false);
const toggleAddMediaDialog = toggleDialogStateRef(isAddMediaToggled);
onMounted(() => {
console.debug("HomePage.vue -- Mounted");
});
</script>
<template>
<section class="cluster" id="buttons">
<button @click="toggleAddMediaDialog" id="add-media-button" type="button">🬤 Ajouter un média</button>
<button id="" type="button">🬗 Rechercher une entrée</button>
</section>
<section class="stack" id="last-watched-media">
<h2>Derniers médias regardés</h2>
</section>
<ImposterBox @dialog-hidden="toggleAddMediaDialog" :toggled="isAddMediaToggled" dialog-id="add-media">
<template v-slot:title>Ajouter un média</template>
<template v-slot:content>
<form class="stack">
<fieldset class="cluster">
<legend>Type du média</legend>
<div class="field">
<input
id="film" checked name="media-type"
type="radio" value="film"
>
<label for="film">Film</label>
</div>
<div class="field">
<input
id="series" name="media-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">
</div>
<div class="field stack">
<label for="media-release-year">Année de sortie</label> <input id="media-release-year" type="number">
</div>
<div class="cluster buttons">
<button class="invert" type="submit">
Rechercher
</button>
<button type="reset">
Réinitialiser
</button>
</div>
</form>
</template>
</ImposterBox>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { onMounted } from "vue";
// DEBUG
onMounted(() => {
console.debug("NotFoundPage.vue -- Mounted");
});
</script>
<template>
<section class="stack" id="404">
<h2>404</h2>
</section>
</template>

View file

@ -1,15 +1,40 @@
import HomeView from "@/views/HomeView.vue";
import { createRouter, createWebHistory } from "vue-router";
import HomePage from "@/pages/HomePage.vue";
import { Option, pipe, Predicate } from "effect";
import { createRouter, createWebHistory, type Router } from "vue-router";
const router = createRouter({
const siteName = "Journal Média";
const separator = "|";
const generatePageTitle = (siteName: string, separator: string, pageTitle: string): string =>
[siteName, separator, pageTitle].join(" ");
const router: Router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
component: HomeView,
name: "home",
component: HomePage,
meta: { title: "Accueil" },
name: "Home",
path: "/",
},
{
component: () => import("@/pages/NotFoundPage.vue"),
meta: { title: "404" },
name: "NotFound",
path: "/:pathMatch(.*)*",
},
],
});
router.beforeEach((to, _): void => {
console.debug("router - to", to);
pipe(
Option.liftPredicate(Predicate.isString)(to.meta["title"]),
Option.getOrElse((): string => "???"),
(pageName: string): void => {
document.title = generatePageTitle(siteName, separator, pageName);
},
);
});
export default router;

25
src/router/typed-routes.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
import type { RouteRecordInfo } from "vue-router";
export interface RouteNamedMap {
Home: RouteRecordInfo<
"Home",
"/",
Record<never, never>,
Record<never, never>,
{ title: string }
>;
NotFound: RouteRecordInfo<
"NotFound",
"/:pathMatch(.*)*",
{ path: string },
{ path: string },
{ title: string }
>;
}
// Last, you will need to augment the Vue Router types with this map of routes
declare module "vue-router" {
interface TypesConfig {
RouteNamedMap: RouteNamedMap;
}
}

View file

@ -1,9 +1,8 @@
import { drizzle } from "drizzle-orm/sqlite-proxy";
import { drizzle, type SqliteRemoteDatabase } from "drizzle-orm/sqlite-proxy";
import { Data, Effect } from "effect";
import { SQLocalDrizzle } from "sqlocal/drizzle";
class LocalSqliteError extends Data.TaggedError("LocalSqliteError")<{ cause: unknown }> {
}
class LocalSqliteError extends Data.TaggedError("LocalSqliteError")<{ cause: unknown }> {}
export class LocalSqlite extends Effect.Service<LocalSqlite>()("LocalSqlite", {
effect: Effect.gen(function*() {
@ -30,13 +29,13 @@ export class LocalSqlite extends Effect.Service<LocalSqlite>()("LocalSqlite", {
}),
});
const orm = drizzle(client.driver, client.batchDriver);
const orm: SqliteRemoteDatabase = drizzle(client.driver, client.batchDriver);
const query = <R>(execute: (_: typeof orm) => Promise<R>) =>
Effect.tryPromise({
catch: (error: unknown) => new LocalSqliteError({ cause: error }),
try: () => execute(orm),
});
Effect.tryPromise({ catch: (error: unknown) => new LocalSqliteError({ cause: error }), try: () => execute(orm) });
yield* Effect.logDebug("--- DB ---");
yield* Effect.tryPromise(() => client.deleteDatabaseFile());
return { client, orm, query };
}),

14
src/services/logger.ts Normal file
View file

@ -0,0 +1,14 @@
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 LogLevelLive = pipe(
Config.logLevel("LOG_LEVEL"),
Effect.andThen((level: LogLevel) => Logger.minimumLogLevel(level)),
Layer.unwrapEffect, // Convertis l'Effect en Layer
Layer.provide(EnvConfigProvider),
);
export const PrettyLogger = Layer.mergeAll(Logger.pretty, LogLevelLive);

View file

@ -0,0 +1,20 @@
import type { SQLocalDrizzle } from "sqlocal/drizzle";
import v0000 from "@/db/drizzle/0000_perfect_justice.sql?raw";
import { Data, Effect } from "effect";
import { LocalSqlite } from "./db";
class MigrationsError extends Data.TaggedError("MigrationsError")<{ cause: unknown }> {}
const executeRawSql = (client: SQLocalDrizzle) => (sql: string) =>
Effect.tryPromise({ catch: (error: unknown) => new MigrationsError({ cause: error }), try: () => client.sql(sql) });
export class Migrations extends Effect.Service<Migrations>()("Migrations", {
dependencies: [LocalSqlite.Default],
effect: Effect.gen(function*() {
const db = yield* LocalSqlite;
yield* Effect.logDebug("--- MIGRATIONS ---");
return [yield* executeRawSql(db.client)(v0000)] as const;
}),
}) {}

View file

@ -1,4 +1,4 @@
import { DiaryEntries } from "@/db/schemas";
import { DiaryEntries, Users } from "@/db/schemas";
import { singleResultOrFail } from "@/libs/utils/effects";
import { desc } from "drizzle-orm";
import { Data, Effect } from "effect";
@ -12,11 +12,15 @@ export class ReadApi extends Effect.Service<ReadApi>()("ReadApi", {
effect: Effect.gen(function*() {
const { query } = yield* LocalSqlite;
yield* Effect.logDebug("--- READ-API ---");
return {
getAllEntries: () => query(_ => _.select().from(DiaryEntries)),
getLastAddedEntry: () =>
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

@ -0,0 +1,15 @@
import { Layer, ManagedRuntime } from "effect";
import { LocalSqlite } from "./db";
import { PrettyLogger } from "./logger";
import { Migrations } from "./migrations";
import { ReadApi } from "./read-api";
const MainLayer = Layer.mergeAll(
// WriteApi.Default,
LocalSqlite.Default,
Migrations.Default,
ReadApi.Default,
).pipe(Layer.provide(PrettyLogger));
export const RuntimeClient = ManagedRuntime.make(MainLayer);

119
src/styles/base/base.css Executable file
View file

@ -0,0 +1,119 @@
/*
* 1. Utilise un meilleur modèle de boîte.
* 2. Fait que seul font-size puisse influencer la taille du texte.
* 3. Applique les schémas de couleurs.
* 4. Utilise une indentation plus étroite.
* 5. Permet l'usage de propriétés intrinsèques comme auto ou fit-content dans les animations.
*/
html {
box-sizing: border-box; /* 1 */
tab-size: 2; /* 4 */
color-scheme: dark light; /* 3 */
interpolate-size: allow-keywords; /* 6 */
/* stylelint-disable */
-moz-text-size-adjust: none; /* 2 */
-webkit-text-size-adjust: none; /* 2 */
text-size-adjust: none; /* 2 */
block-size: 100%;
/* stylelint-enable */
/* scrollbar-gutter: stable; */
}
body {
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-variant-ligatures: common-ligatures no-discretionary-ligatures no-historical-ligatures
contextual;
line-height: var(--line-height-comfortable);
text-decoration-skip-ink: auto;
text-rendering: geometricprecision;
}
/*
* 1. Hérite le modèle de boîte de l'élément racine.
* 2. Désactive toute marge pour partir de bases saines.
* 3. Hérite toute propriété typographique et de couleur pour éviter des redéfinitions.
*/
*, *::before, *::after {
box-sizing: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 2 */
font: inherit; /* 3 */
font-feature-settings: inherit; /* 3 */
font-variation-settings: inherit; /* 3 */
color: inherit; /* 3 */
letter-spacing: inherit; /* 3 */
word-spacing: inherit; /* 3 */
}
/* Utilise une couleur particulière pour l'arrière-plan des éléments sélectionnés avec le curseur. */
*::selection {
color: var(--color-secondary);
background: var(--color-primary);
}
/* 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;
}
/* Retire les bordures et applique un modèle d'arrière-plan plus sain. */
*:where(:not(progress, meter)) {
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. */
:where(.visually-hidden:not(:focus, :active, :focus-within)) {
position: absolute;
overflow: hidden;
inline-size: 1px;
block-size: 1px;
white-space: nowrap;
clip-path: inset(50%);
}
/* Cache les éléments cachés. */
:where([hidden]) {
display: none;
}
/* Affiche un curseur « Désactivé » pour les éléments désactivés. */
:where([disabled]) {
cursor: not-allowed;
}
/* Applique des contours de focus visibles. */
:where(:focus-visible) {
outline: currentcolor solid 0.2rem;
outline-offset: 0.2rem;
}
:where(:focus-visible, :target) {
scroll-margin-block: 8vh;
}
/* Active une transition de page simple. */
@view-transition {
navigation: auto;
}
/* Désactive animations et transitions pour les Utilisateurs le demandant explicitement. */
@media (prefers-reduced-motion) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition: none !important;
animation-duration: 0s !important;
}
@view-transition {
navigation: none !important;
}
}

View file

@ -0,0 +1,56 @@
/* Réinitialise l'apparence d'éléments interactifs. */
:where(button, fieldset, input, select, textarea) {
appearance: none;
}
/* Hauteur de ligne plus étroite pour les éléments interactifs. */
:where(button, fieldset, input, label, select, textarea) {
line-height: var(--line-height-compact);
}
:where(textarea) {
resize: vertical;
}
@supports (resize: block) {
:where(textarea) {
resize: block;
}
}
/* Curseur de main pour les éléments interactifs cliquables. */
:where(button, label, select) {
cursor: pointer;
}
:where(button) {
inline-size: fit-content;
}
/* Évite le dépassement des textes. */
:where(p, h1, h2, h3, h4, h5, h6) {
overflow-wrap: break-word;
}
/* Les médias doivent occuper toute la longueur disponible au sein de leur propre bloc. */
:where(img, picture, video, canvas, svg) {
display: block;
max-inline-size: 100%;
block-size: auto;
}
:where(ol, ul) {
list-style: none;
}
:where(a) {
text-decoration: none;
}
/*
* Empêche les marqueurs de listes de modifier la hauteur de ligne sur Firefox.
* https://danburzo.ro/notes/moz-bullet-font
*/
::marker {
line-height: 0;
}

39
src/styles/base/variables.css Executable file
View file

@ -0,0 +1,39 @@
:root {
/* Couleurs */
/* Réelles */
--color-white: #fff;
--color-black: #000;
/* Logiques */
--color-primary: black;
--color-secondary: salmon;
--color-tertiary: dimgrey;
--color-quartary: #be2727;
/* Typographie */
/* Hauteurs de ligne */
--line-height-comfortable: 1.4;
--line-height-compact: 1.1;
/* Échelles */
--ratio: 1.4;
--s-5: calc(var(--s-4) / var(--ratio));
--s-4: calc(var(--s-3) / var(--ratio));
--s-3: calc(var(--s-2) / var(--ratio));
--s-2: calc(var(--s-1) / var(--ratio));
--s-1: calc(var(--s0) / var(--ratio));
--s0: 1rem;
--s1: calc(var(--s0) * var(--ratio));
--s2: calc(var(--s1) * var(--ratio));
--s3: calc(var(--s2) * var(--ratio));
--s4: calc(var(--s3) * var(--ratio));
--s5: calc(var(--s4) * var(--ratio));
/* Arrière-plans à motifs */
--bg25-secondary: repeating-conic-gradient(var(--color-tertiary) 0% 25%, transparent 0% 100%) 1px
0.5px / 2px 2px;
--bg75-secondary: repeating-conic-gradient(var(--color-secondary) 0% 75%, transparent 0% 100%) 1px
0.5px / 2px 2px;
}

View file

@ -0,0 +1,55 @@
.dialog {
position: fixed; /* 1 */
z-index: 2; /* 1 */
inset: 0; /* 1 */
display: flex; /* 2 */
background: var(--bg25-secondary);
margin: 0;
&[aria-hidden="true"] {
display: none; /* 1 */
}
}
.dialog-content {
position: relative; /* 2 */
max-inline-size: 80ch;
margin: auto; /* 1 */
padding: initial;
border: 1px solid var(--color-primary);
background-color: var(--color-secondary);
box-shadow: 0.5rem 0.5rem 0 0 var(--color-primary);
animation: fade-in 100ms 10ms both;
header {
overflow: hidden;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
border-block-end: 1px solid var(--color-primary);
h2 {
padding-inline: var(--s-1);
font-size: var(--s1);
}
button {
padding: var(--s-1);
font-family: Banquise, monospace;
font-weight: 500;
}
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dialog-overlay, .dialog-content {
animation: none;
}
}

View file

@ -0,0 +1,3 @@
[aria-current] {
font-weight: 125;
}

View file

@ -0,0 +1,7 @@
@import url("./box.css");
@import url("./center.css");
@import url("./cover.css");
@import url("./cluster.css");
@import url("./sidebar.css");
@import url("./stack.css");
@import url("./switcher.css");

17
src/styles/layouts/box.css Executable file
View file

@ -0,0 +1,17 @@
/*
* 1. Affiche une bordure pour les thèmes à haut contraste sans augmenter la taille de la boîte.
*/
.box {
padding: var(--s0, 1rem);
background-color: var(--color-secondary, #fff);
outline: 0.125rem solid transparent; /* 1 */
outline-offset: -0.125rem; /* 1 */
/* border: 1px solid var(--color-primary); */
}
/* Inverse les couleurs de la boîte. */
.box.invert {
color: var(--color-secondary, #fff);
background-color: var(--color-primary, #000);
}

14
src/styles/layouts/center.css Executable file
View file

@ -0,0 +1,14 @@
/*
* 1. Utilise un modèle de boîte excluant les marges internes du calcul de la longueur.
* 2. Centre l'Élément avec des marges externes automatiques en ligne et une longueur maximale.
* 3. Assure que des espaces latéraux sont présents.
*/
.center {
display: flex;
flex-flow: column nowrap;
align-items: center;
box-sizing: content-box; /* 1 */
max-inline-size: var(--longueur-max-texte, 80ch); /* 2 */
margin-inline: auto; /* 2 */
padding-inline: var(--s0, 1rem) var(--s0, 1rem); /* 3 */
}

View file

@ -0,0 +1,8 @@
.cluster {
display: flex;
flex-flow: row wrap;
gap: var(--s0, 1rem);
align-items: center;
/* justify-content: center; */
}

40
src/styles/layouts/cover.css Executable file
View file

@ -0,0 +1,40 @@
/*
* Cover est une disposition permettant de centrer verticalement en son sein un Élément, avec
* potentiellement une en-tête et/ou potentiellement un pied de page à chaque extrémité.
*
* Un usage typique de Cover est l'affichage d'une partie introductive au sein d'une page.
*
* 1. Arrange les Éléments en colonne.
* 2. Fait que la disposition ait une taille minimale fixe tout en permettant son expansion si le contenu la dépasse.
* 3. Empêche l'Élément centrale de la disposition de toucher les bords.
*/
.cover {
--cover-minimal-height: 100vb;
--cover-container-padding: var(--s0, 1rem);
--cover-inter-element-spacing: var(--s0, 1rem);
display: flex; /* 1 */
flex-flow: column nowrap; /* 1 */
min-block-size: var(--cover-minimal-height); /* 2 */
padding: var(--cover-container-padding); /* 3 */
/* Applique un espacement inter-élément pour tout Élément supplémentaire. */
> * {
margin-block: var(--cover-inter-element-spacing);
}
/* Pas de marge pour les Éléments aux extrémités de la disposition. */
> :first-child:not(.cover-center) {
margin-block-start: 0;
}
/* Pas de marge pour les Éléments aux extrémités de la disposition. */
> :last-child:not(.cover-center) {
margin-block-end: 0;
}
/* Centre verticalement l'Élément central. */
> .cover-center {
margin-block: auto;
}
}

View file

@ -0,0 +1,19 @@
.with-sidebar {
--gutter: var(--s0);
display: flex;
flex-flow: row wrap;
gap: var(--gutter, var(--s0));
align-content: start;
}
.with-sidebar > :first-child {
flex-basis: 0;
flex-grow: 999;
min-inline-size: 65%;
}
.with-sidebar > :last-child {
flex-basis: 15ch;
flex-grow: 1;
}

19
src/styles/layouts/stack.css Executable file
View file

@ -0,0 +1,19 @@
.stack {
--space: var(--s0, 1rem);
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
}
.stack > * {
margin-block: 0;
}
.stack > * + * {
margin-block-start: var(--space);
}
.stack:only-child {
block-size: 100%;
}

View file

@ -0,0 +1,16 @@
.switcher {
--threshold: 30rem;
display: flex;
flex-flow: row wrap;
gap: var(--s0, 1rem);
}
.switcher > *:not(.column-separator) {
flex-basis: calc((var(--threshold) - 100%) * 999);
flex-grow: 1;
}
.switcher > :nth-last-child(n+5), .switcher > :nth-last-child(n+5) ~ * {
flex-basis: 100%;
}

9
src/styles/main.css Executable file
View file

@ -0,0 +1,9 @@
@layer base, elements, layouts, components, themes;
@import url("./base/variables.css");
@import url("./base/base.css") layer(base);
@import url("./base/elements.css") layer(base);
@import url("./layouts/_layouts.css") layer(layouts);
@import url("./themes/default.css") layer(themes);

View file

@ -0,0 +1 @@

View file

@ -0,0 +1,60 @@
@import url("./default/buttons.css");
@import url("./default/headings.css");
body {
font-family: BRKLY, sans-serif;
font-weight: 100;
background: var(--color-secondary);
}
*:focus-visible {
animation: flicker 50ms 2;
}
.invert {
font-weight: 120;
color: var(--color-secondary);
background-color: var(--color-primary);
}
h1 {
font-family: Banquise, monospace;
}
.container {
--longueur-max-texte: 100ch;
place-content: start;
place-items: start;
/* opacity: 0;
animation: flicker 50ms 4 forwards 100ms ease-in; */
}
.container > :is(main, aside) {
margin-block-start: var(--s2);
}
main > header {
margin-block-end: var(--s2);
}
#last-watched-media {
--space: var(--s2);
}
@keyframes fade-in {
to {
opacity: 1;
}
}
@keyframes flicker {
from, 49% {
opacity: 0;
}
50%, to {
opacity: 1;
}
}

View file

@ -0,0 +1,54 @@
button {
--button-background-color: var(--color-secondary);
--button-border-color: var(--color-primary);
--button-font-weight: 100;
--button-padding: var(--s-1);
--button-text-color: var(--color-primary);
padding: var(--button-padding);
border: 1px solid var(--button-border-color);
font-weight: var(--button-font-weight);
color: var(--button-text-color);
background-color: var(--button-background-color);
box-shadow: 4px 4px 0 0 var(--color-primary);
&:hover {
--button-background-color: var(--color-primary);
--button-border-color: var(--color-secondary);
--button-font-weight: 120;
--button-text-color: var(--color-secondary);
}
&:active {
transform: translateX(2px) translateY(2px);
box-shadow: 1px 1px 0 0 var(--color-primary);
}
&:focus-visible {
outline-offset: -0.3rem;
}
/* Inversion des couleurs. */
&.invert {
--button-background-color: var(--color-primary);
--button-border-color: var(--color-secondary);
--button-text-color: var(--color-secondary);
outline-color: var(--color-secondary);
}
/* TODO: Déplacer dans un Composant. */
/* Bouton intégré dans un ensemble. */
&.integrated {
border: initial;
&:hover {
color: var(--color-primary);
background: var(--bg75-secondary);
}
&:active {
transform: translateX(2px) translateY(2px);
}
}
}

View file

@ -0,0 +1,12 @@
h1 {
font-family: Banquise, monospace;
font-size: var(--s3);
font-weight: 600;
letter-spacing: 1.5px;
}
h2 {
font-family: Banquise, monospace;
font-size: var(--s2);
letter-spacing: 1px;
}

4
src/views/BaseView.vue Normal file
View file

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

View file

@ -1,20 +0,0 @@
<script setup lang="ts">
import { defineAsyncComponent, Suspense } from "vue";
const AsyncComp = defineAsyncComponent({
delay: 0,
loader: () => import("@/components/LastAddedEntry.vue"),
timeout: 3000,
});
</script>
<template>
<main>
<Suspense>
<AsyncComp></AsyncComp>
<template #fallback>
Loading...
</template>
</Suspense>
</main>
</template>

View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import NavigationMenu from "@/components/NavigationMenu.vue";
</script>
<template>
<NavigationMenu></NavigationMenu>
</template>

22
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,22 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Le nom du fichier contenant la base de données SQLite. */
readonly VITE_DATABASE_NAME: string;
/** La clé API de l'application liée à un compte TMDB. */
readonly VITE_TMDB_API_KEY: string;
}
declare module "bun" {
interface Env {
/** Le nom du fichier contenant la base de données SQLite. */
readonly VITE_DATABASE_NAME: string;
/** La clé API de l'application liée à un compte TMDB. */
readonly VITE_TMDB_API_KEY: string;
}
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
export {};