This commit is contained in:
gcch 2025-02-20 09:05:21 +01:00
commit 98131c3b78
29 changed files with 2003 additions and 0 deletions

7
src/App.vue Normal file
View file

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView></RouterView>
</template>

View file

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

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import { ReadApi } from "@/services/read-api";
import { Console, Effect, pipe } from "effect";
const lastAddedEntry = await pipe(
Effect.andThen(ReadApi, (api: ReadApi) =>
pipe(
api.getLastAddedEntry(),
Effect.tapError(Console.warn),
Effect.orElseSucceed(() => null),
)),
Effect.provide(ReadApi.Default),
Effect.runPromise,
);
</script>
<template>
<p>hello</p>
</template>

View file

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

22
src/db/constants.ts Normal file
View file

@ -0,0 +1,22 @@
export const MEDIUM_TYPES = {
DOCUMENTARY: "documentary",
FILM: "film",
SERIES: "series",
} as const;
export const DIARY_ENTRY_STATES = {
/** Un média abandonné, lors de son acquisition ou de son visionnage. */
DROPPED: "dropped",
/** Un média souhaité à acquérir. */
TO_FIND: "to_find",
/** Un média déjà vu et à revoir. */
TO_REWATCH: "to_rewatch",
/** Un média souhaité mais dont l'état est encore flou. */
TO_SORT: "to_sort",
/** Un média acquis et à regarder. */
TO_WATCH: "to_watch",
/** Un média à l'état inconnu (état par défaut). */
UNKNOWN: "unknown",
/** Un média ayant été regardé au mois une fois. */
WATCHED: "watched",
} as const;

20
src/db/drizzle.config.ts Normal file
View file

@ -0,0 +1,20 @@
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",
};
export default DrizzleKitConfig;

View file

@ -0,0 +1,66 @@
CREATE TABLE `art_works` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`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
);
--> statement-breakpoint
CREATE TABLE `diary_entries` (
`art_work_id` integer,
`date_created` text(10) NOT NULL,
`date_modified` text(10) NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`state_id` integer,
`user_id` integer,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`state_id`) REFERENCES `diary_entries_states`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `diary_entries_genres` (
`art_work_id` integer,
`genre_id` integer,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`genre_id`) REFERENCES `genres`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `diary_entries_states` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`state` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `diary_entries_states_state_unique` ON `diary_entries_states` (`state`);--> statement-breakpoint
CREATE TABLE `genres` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL
);
--> 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` (
`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 TABLE `users` (
`email` text NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_name_unique` ON `users` (`name`);--> statement-breakpoint
CREATE TABLE `viewings` (
`art_work_id` integer,
`date` text(10) NOT NULL,
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer,
FOREIGN KEY (`art_work_id`) REFERENCES `art_works`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

View file

@ -0,0 +1,277 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7f64c80e-6fd1-4f8c-859c-dac137206238",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"art_works": {
"name": "art_works",
"columns": {
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"medium_type_id": {
"name": "medium_type_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false },
"release_date": {
"name": "release_date",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"art_works_medium_type_id_medium_types_id_fk": {
"name": "art_works_medium_type_id_medium_types_id_fk",
"tableFrom": "art_works",
"tableTo": "medium_types",
"columnsFrom": ["medium_type_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"diary_entries": {
"name": "diary_entries",
"columns": {
"art_work_id": {
"name": "art_work_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date_created": {
"name": "date_created",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"date_modified": {
"name": "date_modified",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"state_id": {
"name": "state_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"diary_entries_art_work_id_art_works_id_fk": {
"name": "diary_entries_art_work_id_art_works_id_fk",
"tableFrom": "diary_entries",
"tableTo": "art_works",
"columnsFrom": ["art_work_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"diary_entries_state_id_diary_entries_states_id_fk": {
"name": "diary_entries_state_id_diary_entries_states_id_fk",
"tableFrom": "diary_entries",
"tableTo": "diary_entries_states",
"columnsFrom": ["state_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"diary_entries_user_id_users_id_fk": {
"name": "diary_entries_user_id_users_id_fk",
"tableFrom": "diary_entries",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"diary_entries_genres": {
"name": "diary_entries_genres",
"columns": {
"art_work_id": {
"name": "art_work_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"genre_id": {
"name": "genre_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true }
},
"indexes": {},
"foreignKeys": {
"diary_entries_genres_art_work_id_art_works_id_fk": {
"name": "diary_entries_genres_art_work_id_art_works_id_fk",
"tableFrom": "diary_entries_genres",
"tableTo": "art_works",
"columnsFrom": ["art_work_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"diary_entries_genres_genre_id_genres_id_fk": {
"name": "diary_entries_genres_genre_id_genres_id_fk",
"tableFrom": "diary_entries_genres",
"tableTo": "genres",
"columnsFrom": ["genre_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"diary_entries_states": {
"name": "diary_entries_states",
"columns": {
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"state": { "name": "state", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }
},
"indexes": {
"diary_entries_states_state_unique": {
"name": "diary_entries_states_state_unique",
"columns": ["state"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"genres": {
"name": "genres",
"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": {
"genres_name_unique": { "name": "genres_name_unique", "columns": ["name"], "isUnique": true },
"genres_slug_unique": { "name": "genres_slug_unique", "columns": ["slug"], "isUnique": true }
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medium_types": {
"name": "medium_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 }
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false },
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }
},
"indexes": {
"users_email_unique": { "name": "users_email_unique", "columns": ["email"], "isUnique": true },
"users_name_unique": { "name": "users_name_unique", "columns": ["name"], "isUnique": true }
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"viewings": {
"name": "viewings",
"columns": {
"art_work_id": {
"name": "art_work_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": { "name": "date", "type": "text(10)", "primaryKey": false, "notNull": true, "autoincrement": false },
"id": { "name": "id", "type": "integer", "primaryKey": true, "notNull": true, "autoincrement": true },
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"viewings_art_work_id_art_works_id_fk": {
"name": "viewings_art_work_id_art_works_id_fk",
"tableFrom": "viewings",
"tableTo": "art_works",
"columnsFrom": ["art_work_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"viewings_user_id_users_id_fk": {
"name": "viewings_user_id_users_id_fk",
"tableFrom": "viewings",
"tableTo": "users",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": { "schemas": {}, "tables": {}, "columns": {} },
"internal": { "indexes": {} }
}

View file

@ -0,0 +1,5 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [{ "idx": 0, "version": "6", "when": 1739953332871, "tag": "0000_perfect_justice", "breakpoints": true }]
}

76
src/db/schemas.ts Normal file
View file

@ -0,0 +1,76 @@
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

@ -0,0 +1,42 @@
import { Schema } from "effect";
export class TmdbMovieSearchQueryParams extends Schema.Class<TmdbMovieSearchQueryParams>("TmdbMovieSearchArgs")({
include_adult: Schema.Boolean.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => false),
),
language: Schema.NonEmptyString.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => "en-US"),
),
page: Schema.NonNegativeInt.pipe(
Schema.propertySignature,
Schema.withConstructorDefault(() => 1),
),
primary_release_year: Schema.NonEmptyString.pipe(Schema.length(4), Schema.optional),
query: Schema.NonEmptyString,
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,
})),
total_pages: Schema.NonNegativeInt,
total_results: Schema.NonNegativeInt,
}) {}

15
src/libs/utils/effects.ts Normal file
View file

@ -0,0 +1,15 @@
import { Array as Arr, Effect, pipe } from "effect";
/**
* Effet qui soit retourne le premier membre d'un tableau, soit retourne le résultat une fonction de secours s'il est vide.
* @param orFail La fonction de secours à exécuter.
* @returns Le premier membre du tableau ou le résultat de la fonction de secours.
*/
export const singleResultOrFail = <A, E>(orFail: () => E) =>
Effect.flatMap((results: A[]) =>
pipe(
results,
Arr.head,
Effect.mapError(_ => orFail()),
)
);

2
src/libs/utils/types.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/** Union de types des valeurs d'un Objet. */
export type Values<T extends Record<string, unknown>> = T[keyof T];

11
src/main.ts Normal file
View file

@ -0,0 +1,11 @@
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

15
src/router/index.ts Normal file
View file

@ -0,0 +1,15 @@
import HomeView from "@/views/HomeView.vue";
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
component: HomeView,
name: "home",
path: "/",
},
],
});
export default router;

43
src/services/db.ts Normal file
View file

@ -0,0 +1,43 @@
import { drizzle } from "drizzle-orm/sqlite-proxy";
import { Data, Effect } from "effect";
import { SQLocalDrizzle } from "sqlocal/drizzle";
class LocalSqliteError extends Data.TaggedError("LocalSqliteError")<{ cause: unknown }> {
}
export class LocalSqlite extends Effect.Service<LocalSqlite>()("LocalSqlite", {
effect: Effect.gen(function*() {
const client = yield* Effect.try({
catch: (error: unknown) => new LocalSqliteError({ cause: error }),
try: () =>
new SQLocalDrizzle({
databasePath: "database.sqlite3",
onInit: sql => {
return [
sql`PRAGMA busy_timeout = 5000;`,
sql`PRAGMA cache_size = 1000000000;`,
sql`PRAGMA foreign_key_check;`,
sql`PRAGMA foreign_keys = true;`,
sql`PRAGMA integrity_check;`,
sql`PRAGMA journal_mode = WAL;`,
sql`PRAGMA page_size = 1024;`,
sql`PRAGMA synchronous = NORMAL;`,
sql`PRAGMA foreign_key_check;`,
sql`PRAGMA temp_story = MEMORY;`,
sql`VACUUM;`,
];
},
}),
});
const orm = 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),
});
return { client, orm, query };
}),
}) {}

22
src/services/read-api.ts Normal file
View file

@ -0,0 +1,22 @@
import { DiaryEntries } from "@/db/schemas";
import { singleResultOrFail } from "@/libs/utils/effects";
import { desc } from "drizzle-orm";
import { Data, Effect } from "effect";
import { LocalSqlite } from "./db";
class ReadApiError extends Data.TaggedError("ReadApiError")<{ cause: unknown }> {}
export class ReadApi extends Effect.Service<ReadApi>()("ReadApi", {
dependencies: [LocalSqlite.Default],
effect: Effect.gen(function*() {
const { query } = yield* LocalSqlite;
return {
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." })),
),
};
}),
}) {}

20
src/views/HomeView.vue Normal file
View file

@ -0,0 +1,20 @@
<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>