feat(frontend): init book-farm ui

This commit is contained in:
Elisa Degobert 2025-02-02 12:00:04 +01:00
commit 0ce3ad0718
50 changed files with 14440 additions and 0 deletions

4
.browserslistrc Normal file
View File

@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

6
.editorconfig Normal file
View File

@ -0,0 +1,6 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_BOOK_API_URL=http://192.168.1.51:8000

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM node:lts-alpine
ARG api=http://192.168.1.51:8000
# "https://app.bookfarm.spacesheep.ovh"
ENV VITE_BOOK_API_URL=$api
# install simple http server for serving static content
RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app
# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .
# build app for production with minification
RUN npm run build
EXPOSE 8080
CMD [ "http-server", "dist" ]
#sudo docker build -t book-farm/frontend .
#sudo docker run -it -p 8080:8080 --rm --name book-farm-frontend book-farm/frontend

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Vuetify (Default)
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
## ❗️ Important Links
- 📄 [Docs](https://vuetifyjs.com/)
- 🚨 [Issues](https://issues.vuetifyjs.com/)
- 🏬 [Store](https://store.vuetifyjs.com/)
- 🎮 [Playground](https://play.vuetifyjs.com/)
- 💬 [Discord](https://community.vuetifyjs.com)
## 💿 Install
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
| Package Manager | Command |
|---------------------------------------------------------------|----------------|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
| [bun](https://bun.sh/#getting-started) | `bun install` |
After completing the installation, your environment is ready for Vuetify development.
## ✨ Features
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts)
- 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/)
- ⚡ **Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
- 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc)
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
## 💡 Usage
This section covers how to start the development server and build your project for production.
### Starting the Development Server
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
```bash
yarn dev
```
(Repeat for npm, pnpm, and bun with respective commands.)
> Add NODE_OPTIONS='--no-warnings' to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
### Building for Production
To build your project for production, use:
```bash
yarn build
```
(Repeat for npm, pnpm, and bun with respective commands.)
Once the build process is completed, your application will be ready for deployment in a production environment.
## 💪 Support Vuetify Development
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
## 📑 License
[MIT](http://opensource.org/licenses/MIT)
Copyright (c) 2016-present Vuetify, LLC

26
components.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActiveFilters: typeof import('./src/components/filter/ActiveFilters.vue')['default']
BookCard: typeof import('./src/components/book/BookCard.vue')['default']
BookCardSkeleton: typeof import('./src/components/skeletons/BookCardSkeleton.vue')['default']
BookDetailsSkeleton: typeof import('./src/components/skeletons/BookDetailsSkeleton.vue')['default']
BookLightCard: typeof import('./src/components/book/BookLightCard.vue')['default']
CollectionsFilter: typeof import('./src/components/filter/CollectionsFilter.vue')['default']
Filters: typeof import('./src/components/filter/Filters.vue')['default']
FiltersPanel: typeof import('./src/components/filter/FiltersPanel.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
NavBar: typeof import('./src/components/NavBar.vue')['default']
PageNumberFilter: typeof import('./src/components/filter/PageNumberFilter.vue')['default']
PublishedDateFilter: typeof import('./src/components/filter/PublishedDateFilter.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Suggestion: typeof import('./src/components/Suggestion.vue')['default']
}
}

2
env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />

36
eslint.config.js Normal file
View File

@ -0,0 +1,36 @@
/**
* .eslint.js
*
* ESLint configuration file.
*/
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
export default [
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
{
name: 'app/files-to-ignore',
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
},
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
{
rules: {
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
},
],
'vue/multi-word-component-names': 'off',
}
}
]

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BookFarm</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

12862
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "library",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "run-p \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --fix"
},
"dependencies": {
"@mdi/font": "7.4.47",
"axios": "^1.7.9",
"core-js": "^3.37.1",
"pinia": "^2.3.1",
"roboto-fontface": "*",
"vue": "^3.4.31",
"vuetify": "^3.6.14"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@tsconfig/node22": "^22.0.0",
"@types/node": "^22.9.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/eslint-config-typescript": "^14.1.3",
"@vue/tsconfig": "^0.5.1",
"@vueuse/core": "^12.5.0",
"@vueuse/nuxt": "^12.5.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"npm-run-all2": "^7.0.1",
"sass": "1.77.8",
"sass-embedded": "^1.77.8",
"typescript": "~5.6.3",
"unplugin-fonts": "^1.1.1",
"unplugin-vue-components": "^0.27.2",
"unplugin-vue-router": "^0.10.0",
"vite": "^5.4.10",
"vite-plugin-vuetify": "^2.0.3",
"vue-router": "^4.4.0",
"vue-tsc": "^2.1.10"
}
}

BIN
public/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

33
src/App.vue Normal file
View File

@ -0,0 +1,33 @@
<template>
<v-app>
<v-main class="bookfarm-app">
<div class="library">
<v-app-bar
:elevation="2"
color="#EEEBE0"
>
<div style="width:64px">
<v-img
src="@/assets/logo.png"
/>
</div>
<v-app-bar-title class="font-weight-medium">
BookFarm
</v-app-bar-title>
</v-app-bar>
<router-view />
</div>
</v-main>
</v-app>
</template>
<script lang="ts" setup>
</script>
<style lang="css">
.library {
margin-top: 8px;
padding: 0 12px;
overflow-y: scroll;
}
</style>

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

23
src/components/NavBar.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup lang="ts">
</script>
<template>
<v-app-bar
:elevation="2"
color="#F0EAD2"
class="bookfarm-bar"
>
<div style="width:64px">
<v-img
src="@/assets/logo.png"
/>
</div>
<v-app-bar-title class="font-weight-medium">
BookFarm
</v-app-bar-title>
</v-app-bar>
</template>
<style scoped>
</style>

35
src/components/README.md Normal file
View File

@ -0,0 +1,35 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```

View File

@ -0,0 +1,100 @@
<script setup lang="ts">
import {onMounted, type Ref, ref, watch} from "vue";
import {getBookRecommandations} from "@/services/bookApi";
import type {Book} from "@/services/models/book";
import BookLightCard from "@/components/book/BookLightCard.vue";
import type {BookSimilarParams} from "@/services/models/filter";
const model = ref(null);
const books: Ref<Book[]> = ref([]);
const {book} = defineProps<{ book: Book }>()
onMounted(getSimilarBooks);
async function getSimilarBooks() {
const params : BookSimilarParams= {
author: undefined,
fast: undefined,
collection: undefined
};
if(selectedOption.value === 0){
params.fast = true;
}
else if(selectedOption.value === 1){
params.author = true;
} else if(selectedOption.value === 2){
params.collection = true;
}
return getBookRecommandations(book.id, params)
.then(result => {
books.value = result;
})
.catch(err => {
console.log(err);
})
}
const suggestionOptions = [
{label: 'Vous aimeriez aussi', key:0},
{label: 'Du même auteur', key:1},
{label: 'Dans la même collection', key:2}
]
const selectedOption = ref(0)
watch(selectedOption, getSimilarBooks)
</script>
<template>
<v-sheet>
<span class="d-flex ga-1 bg-white pa-2">
<v-select
v-model="selectedOption"
class="font-weight-medium font-italic"
hide-details
item-title="label"
item-value="key"
:items="suggestionOptions"
>
<template #prepend-inner>
<v-icon
icon="mdi-heart"
color="pink"
/>
</template>
</v-select>
</span>
<v-slide-group
v-model="model"
class="pa-0"
center-active
show-arrows
>
<v-slide-group-item
v-for="book in books"
:key="book.id"
v-slot="{ toggle }"
>
<v-card
color="primary"
class="ma-2"
height="118"
width="118"
@click="toggle"
>
<div class="d-flex fill-height align-center justify-center">
<BookLightCard :book />
</div>
</v-card>
</v-slide-group-item>
</v-slide-group>
</v-sheet>
</template>
<style>
.v-slide-group__prev, .v-slide-group__next {
flex-basis: 24px;
min-width: 24px;
}
</style>

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import type {Book} from "@/services/models/book";
defineProps<{ book: Book }>()
</script>
<template>
<v-card
width="100%"
hover
height="120px"
rounded="lg"
color="white"
:to="`book/${book.id}`"
>
<div class="d-flex ga-1">
<v-avatar
rounded="lg"
size="120"
>
<v-img
:src="book.image_url ?? `https://cdn.vuetifyjs.com/images/cards/sunshine.jpg`"
/>
</v-avatar>
<div class="book-information">
<div class="book-author-title ">
<p
v-if="book.product_title"
class="text-primary font-weight-bold"
>
{{ book.product_title }}
</p>
<p
v-if="book.author"
class="text-secondary font-weight-medium text-capitalize"
>
{{ book.author.split("_").join(" ") }}
</p>
</div>
<v-chip-group
variant="flat"
column
>
<v-chip
class="ma-2"
label
size="x-small"
>🗓
{{ book.date_de_parution }}
</v-chip>
<v-chip
class="ma-2"
label
size="x-small"
>
📚 {{ book.nb_de_pages }} pages
</v-chip>
</v-chip-group>
</div>
</div>
</v-card>
</template>
<style scoped>
.book-information {
max-height: 120px;
}
.book-author-title {
padding: 4px 4px 0 4px;
> * {
padding: 0 2px;
font-size: 16px;
text-wrap: wrap;
line-height: 1em;
max-height: 2em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import type {Book} from "@/services/models/book";
defineProps<{ book: Book }>()
</script>
<template>
<v-card
width="100%"
hover
rounded="lg"
color="white"
:to="`/book/${book.id}`"
>
<v-img
:src="book.image_url ?? `https://cdn.vuetifyjs.com/images/cards/sunshine.jpg`"
class="align-end"
gradient="to bottom, rgba(0,0,0,.1), rgba(0,0,0,.5)"
height="118px"
cover
>
<v-card-title>
<div class="book-author-title ">
<p
v-if="book.product_title"
class="font-weight-bold"
>
{{ book.product_title }}
</p>
<p
v-if="book.author"
class="font-weight-medium text-capitalize"
>
{{ book.author.split("_").join(" ") }}
</p>
</div>
</v-card-title>
</v-img>
</v-card>
</template>
<style scoped>
.book-author-title {
color: white;
& p {
font-size: 12px;
text-wrap: wrap;
line-height: 1em;
max-height: 2em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import {storeToRefs} from "pinia";
import {useBooksStore} from "@/services/stores/booksStore";
const {filters} = storeToRefs(useBooksStore());
</script>
<template>
<div class="selected-filters">
<v-chip-group column>
<template v-if="filters.mot_clef">
<v-chip>Mot-clef: {{ filters.mot_clef }}</v-chip>
</template>
<template v-if="filters.collections">
<v-chip
v-for="collection in filters.collections"
:key="collection"
>
Collection: {{ collection }}
</v-chip>
</template>
<template v-if="filters.nb_de_pages.min">
<v-chip>📚 Nombre de pages minimum: {{ filters.nb_de_pages.min }}</v-chip>
</template>
<template v-if="filters.nb_de_pages.max">
<v-chip>📚 Nombre de pages maximum: {{ filters.nb_de_pages.max }}</v-chip>
</template>
<template v-if="filters.date_de_parution.après">
<v-chip>🗓 Publié après: {{ filters.date_de_parution.après }}</v-chip>
</template>
<template v-if="filters.date_de_parution.avant">
<v-chip>🗓 Publié avant : {{ filters.date_de_parution.avant }}</v-chip>
</template>
</v-chip-group>
</div>
</template>
<style scoped>
.selected-filters {
margin-bottom: 8px;
}
</style>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import {onMounted, type Ref, ref} from "vue";
import {storeToRefs} from "pinia";
import {useBooksStore} from "@/services/stores/booksStore";
import {getCollections} from "@/services/bookApi";
const {filters} = storeToRefs(useBooksStore());
const collections: Ref<string[]> = ref([]);
onMounted(() => {
getCollections()
.then((result) => {
collections.value = result;
})
})
</script>
<template>
<v-card-item>
<p class="font-weight-regular">
Collections
</p>
<v-select
v-model="filters.collections"
clearable
chips
label="Filtrer sur des collections"
:items="collections"
multiple
/>
</v-card-item>
</template>

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import {ref} from "vue";
import FiltersPanel from "@/components/filter/FiltersPanel.vue";
import {storeToRefs} from "pinia";
import {useBooksStore} from "@/services/stores/booksStore";
import ActiveFilters from "@/components/filter/ActiveFilters.vue";
const isPanelOpened = ref(false);
function togglePanel() {
isPanelOpened.value = !isPanelOpened.value;
}
const {filters} = storeToRefs(useBooksStore());
</script>
<template>
<div class="d-flex ga-2 align-center">
<v-text-field
v-model="filters.mot_clef"
prepend-inner-icon="mdi-magnify"
clearable
bg-color="#FFFFFF"
placeholder="Search a book"
hide-details
/>
<v-btn
icon="mdi-filter"
color="secondary"
density="comfortable"
size="small"
@click="togglePanel"
/>
</div>
<ActiveFilters />
<FiltersPanel
:is-panel-opened="isPanelOpened"
@close="togglePanel"
/>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import PageNumberFilter from "@/components/filter/PageNumberFilter.vue";
import CollectionsFilter from "@/components/filter/CollectionsFilter.vue";
import PublishedDateFilter from "@/components/filter/PublishedDateFilter.vue";
defineProps<{ isPanelOpened: boolean }>();
defineEmits(["close"]);
</script>
<template>
<v-expand-x-transition class="panel">
<v-card
v-show="isPanelOpened"
class="mx-auto"
bg-color="white"
height="100%"
width="100%"
@keydown.esc="$emit('close')"
>
<v-card-title class="d-flex justify-space-between pa-2 mt-2">
<span>Filtres</span>
<v-btn
icon="mdi-close"
density="compact"
@click="$emit('close')"
/>
</v-card-title>
<PageNumberFilter />
<CollectionsFilter />
<PublishedDateFilter />
</v-card>
</v-expand-x-transition>
</template>
<style scoped>
.panel {
position: absolute;
width: 80%;
top: 64px;
right: 0;
z-index: 1;
}
</style>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import {type Ref, ref, watch} from "vue";
import {storeToRefs} from "pinia";
import {useBooksStore} from "@/services/stores/booksStore";
const {filters} = storeToRefs(useBooksStore());
const pageSize: Ref<number[]> = ref([0, 2500]);
watch(() => pageSize.value, () => {
const [min, max] = pageSize.value;
if (min !== 0) {
filters.value.nb_de_pages.min = min;
} else {
filters.value.nb_de_pages.min = undefined;
}
if (max !== 2500) {
filters.value.nb_de_pages.max = max;
} else {
filters.value.nb_de_pages.max = undefined;
}
})
</script>
<template>
<v-card-item>
<p class="font-weight-regular">
Nombre de pages
</p>
<v-range-slider
v-model="pageSize"
:min="0"
:step="10"
:max="2500"
class="align-center"
hide-details
>
<template #prepend>
{{ pageSize[0] }}
</template>
<template #append>
{{ pageSize[1] }}
</template>
</v-range-slider>
</v-card-item>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import {type Ref, ref, watch} from "vue";
import {storeToRefs} from "pinia";
import {useBooksStore} from "@/services/stores/booksStore";
const {filters} = storeToRefs(useBooksStore());
const currentYear = new Date().getFullYear();
const publishedDate: Ref<number[]> = ref([1950, currentYear]);
watch(() => publishedDate.value, () => {
const [min, max] = publishedDate.value;
if (min !== 1950) {
filters.value.date_de_parution.après = min.toString();
} else {
filters.value.date_de_parution.après = undefined;
}
if (max !== currentYear) {
filters.value.date_de_parution.avant = max.toString();
} else {
filters.value.date_de_parution.avant = undefined;
}
})
</script>
<template>
<v-card-item>
<p class="font-weight-regular">
Date de parution
</p>
<v-range-slider
v-model="publishedDate"
:min="1950"
:max="currentYear"
:step="1"
class="align-center"
hide-details
>
<template #prepend>
{{ publishedDate[0] }}
</template>
<template #append>
{{ publishedDate[1] }}
</template>
</v-range-slider>
</v-card-item>
</template>

View File

@ -0,0 +1,11 @@
<template>
<div
class="v-skeleton-loader"
style="width: 100%;"
>
<div
class="rounded-lg v-skeleton-loader__bone v-skeleton-loader__image"
style="height: 120px;"
/>
</div>
</template>

View File

@ -0,0 +1,20 @@
<template>
<div
class="v-skeleton-loader"
>
<div
class="rounded-lg v-skeleton-loader__bone v-skeleton-loader__image"
style="height: 300px; width: 300px"
/>
</div>
<v-skeleton-loader
type="sentences"
width="100"
class="bg-transparent"
/>
<v-skeleton-loader
type="paragraph"
width="300"
class="bg-transparent"
/>
</template>

20
src/main.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Composables
import { createApp } from 'vue'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

192
src/pages/BookDetails.vue Normal file
View File

@ -0,0 +1,192 @@
<script setup lang="ts">
import {onMounted, ref, watch} from "vue";
import {useRouter} from "vue-router";
import Suggestion from "@/components/Suggestion.vue";
import {useBookStore} from "@/services/stores/bookStore";
import {storeToRefs} from "pinia";
const router = useRouter();
watch(() => router.currentRoute, () => {
console.log("watch", router.currentRoute.value.params.id)
bookStore.loadBook(router.currentRoute.value.params.id as string)
},)
const bookStore = useBookStore();
const {book, isLoading} = storeToRefs(bookStore)
onMounted(() => {
bookStore.loadBook(router.currentRoute.value.params.id as string);
})
const shouldShowReco = ref(false);
</script>
<template>
<v-fab
icon="mdi-arrow-left"
location="top left"
size="small"
class="back-button"
position="absolute"
:to="{name: 'search'}"
/>
<div class="book-details mt-4 mb-10 d-flex flex-column align-center">
<template v-if="isLoading">
<div
class="v-skeleton-loader"
>
<div
class="rounded-lg v-skeleton-loader__bone v-skeleton-loader__image"
style="height: 300px; width: 300px"
/>
</div>
<v-skeleton-loader
type="sentences"
width="100"
class="bg-transparent"
/>
<v-skeleton-loader
type="paragraph"
width="300"
class="bg-transparent"
/>
</template>
<template v-else>
<v-avatar
rounded="lg"
size="300"
>
<v-img
:src="book?.image_url ?? `https://cdn.vuetifyjs.com/images/cards/sunshine.jpg`"
/>
</v-avatar>
<div class="book-author-title d-flex flex-column align-center">
<p
v-if="book?.product_title"
class="text-primary font-weight-bold"
>
{{ book.product_title }}
</p>
<p
v-if="book?.author"
class="text-secondary font-weight-medium text-capitalize"
>
{{ book.author.split("_").join(" ") }}
</p>
</div>
<section
v-if="book?.resume"
class="ma-1"
>
<span class="text-decoration-underline text-primary font-weight-medium">Résumé</span>
<span class="book-summary">{{ book.resume }}</span>
</section>
<section class="ma-1 book-characteristics">
<span class="text-decoration-underline text-primary font-weight-medium">Caractéristiques</span>
<div>
<span class="charac">
<p class="font-weight-medium">Date de parution</p>
<p>{{ book?.date_de_parution }}</p>
</span>
<span class="charac">
<p class="font-weight-medium">Éditeur</p>
<p>{{ book?.editeur }}</p>
</span>
</div>
<div>
<span class="charac">
<p class="font-weight-medium">Collection</p>
<p>{{ book?.collection?.split("_").join(" ") }}</p>
</span>
<span class="charac">
<p class="font-weight-medium">Nombre de pages</p>
<p>{{ book?.nb_de_pages }}</p>
</span>
</div>
</section>
</template>
</div>
<v-card
class="recommandations"
:disabled="isLoading"
>
<v-slide-y-reverse-transition v-if="!isLoading">
<v-card v-show="shouldShowReco">
<Suggestion :book="book!"/>
</v-card>
</v-slide-y-reverse-transition>
<v-btn
width="100%"
@click="shouldShowReco = !shouldShowReco"
>
<div class="d-flex align-center">
<p>
{{ shouldShowReco ? "Cacher" : "Voir" }} les recommandations
</p>
<v-btn
size="x-small"
flat
:icon="shouldShowReco ? 'mdi-chevron-down' : 'mdi-chevron-up'"
/>
</div>
</v-btn>
</v-card>
</template>
<style scoped>
.book-details {
width: 300px;
margin-left: calc((100% - 300px) / 2);
}
.recommandations {
position: fixed;
bottom: 0;
width: 100%;
left: 0
}
section {
width: 100%;
display: flex;
flex-direction: column;
}
.book-summary {
width: 100%;
line-height: 1.2em;
max-height: 7em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 7;
-webkit-box-orient: vertical;
}
.book-characteristics {
& .charac {
width: 100%;
height: 100%;
display: flex;
gap: 8px;
line-height: 1.2em;
margin-top: 4px;
& > p:first-child {
background-color: #DDE5B6;
}
}
}
.back-button {
height: fit-content !important;
width: 64px;
z-index: 1;
position: fixed;
margin-top: 8px;
}
</style>

5
src/pages/README.md Normal file
View File

@ -0,0 +1,5 @@
# Pages
Vue components created in this folder will automatically be converted to navigatable routes.
Full documentation for this feature can be found in the Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository.

45
src/pages/Search.vue Normal file
View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import {onMounted} from "vue";
import BookCard from "@/components/book/BookCard.vue";
import Filters from "@/components/filter/Filters.vue";
import {useBooksStore} from "@/services/stores/booksStore";
import {storeToRefs} from "pinia";
import BookCardSkeleton from "@/components/skeletons/BookCardSkeleton.vue";
const booksStore = useBooksStore();
const {books, areBooksLoading, noData} = storeToRefs(booksStore)
onMounted(booksStore.searchBooks);
</script>
<template>
<Filters />
<div class="books">
<template v-if="areBooksLoading">
<BookCardSkeleton
v-for="i in [1,2,3]"
:key="i"
/>
</template>
<template v-else-if="noData">
Aucun livre trouvé ...
</template>
<template v-else>
<BookCard
v-for="book in books"
:key="book.id"
:book
/>
</template>
</div>
</template>
<style scoped>
.books {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

7
src/pages/index.vue Normal file
View File

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

3
src/plugins/README.md Normal file
View File

@ -0,0 +1,3 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.

20
src/plugins/index.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
// Plugins
import vuetify from './vuetify'
import router from '../router'
import { createPinia } from 'pinia'
// Types
import type { App } from 'vue'
const pinia = createPinia()
export function registerPlugins (app: App) {
app
.use(pinia)
.use(vuetify)
.use(router)
}

42
src/plugins/vuetify.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import {createVuetify, type ThemeDefinition} from 'vuetify'
const myCustomLightTheme: ThemeDefinition = {
dark: false,
colors: {
background: '#F0EAD2',
surface: '#f5f4f0',
primary: '#6C584C',
'primary-darken-1': '#3700B3',
secondary: '#A98467',
'secondary-darken-1': '#018786',
error: '#B00020',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00',
},
}
//red: E07A5F
// yellowish: F2CC8F
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'myCustomLightTheme',
themes: {
myCustomLightTheme,
},
//defaultTheme: 'dark',
},
})

47
src/router/index.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* router/index.ts
*
* Automatic routes for `./src/pages/*.vue`
*/
// Composables
import {createRouter, createWebHistory} from 'vue-router/auto'
import Search from "@/pages/Search.vue";
import BookDetails from "@/pages/BookDetails.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [{
path: "/",
name: "search",
component: Search
},
{
path: "/book/:id",
name: "book",
component: BookDetails
}
],
})
// Workaround for https://github.com/vitejs/vite/issues/11804
router.onError((err, to) => {
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
if (!localStorage.getItem('vuetify:dynamic-reload')) {
console.log('Reloading page to fix dynamic import error')
localStorage.setItem('vuetify:dynamic-reload', 'true')
location.assign(to.fullPath)
} else {
console.error('Dynamic import error, reloading page did not fix it', err)
}
} else {
console.error(err)
}
})
router.isReady().then(() => {
localStorage.removeItem('vuetify:dynamic-reload')
})
export default router

47
src/services/bookApi.ts Normal file
View File

@ -0,0 +1,47 @@
import type {Book} from "@/services/models/book";
import axios from "axios";
import type {BookFilters, BookSimilarParams} from "@/services/models/filter";
const BASE_API = import.meta.env.VITE_BOOK_API_URL;
const api = axios.create({baseURL: BASE_API, headers: {"Content-Type": "application/json"}})
/*
api.interceptors.request.use((config) => {
config.headers["Access-Control-Allow-Origin"] = "*";
return config;
})
*/
export async function search(filters: BookFilters): Promise<Book[]> {
const books = await api.post<Book[]>(`/search`, filters)
return Promise.resolve(books.data.map(book => ({
...book,
collection: book.collection?.split("_").join(" "),
editeur: book.editeur?.split("_").join(" ")
})));
}
export async function getBookById(id: string): Promise<Book> {
const book = await api.get<Book>(`/book/${id}`)
return Promise.resolve({
...book.data,
collection: book.data.collection?.split("_").join(" "),
editeur: book.data.editeur?.split("_").join(" ")
});
}
export async function getBookRecommandations(id: string, params: BookSimilarParams): Promise<Book[]> {
const books = await api.get<Book[]>(`/books/${id}/similar`, {params})
return Promise.resolve(books.data.map(book => ({
...book,
collection: book.collection?.split("_").join(" "),
editeur: book.editeur?.split("_").join(" ")
})));
}
export async function getCollections(): Promise<string[]> {
const collections = await api.get<string[]>(`/collections`)
return Promise.resolve(collections.data.map(coll => coll.split("_").join(" ")));
}

View File

@ -0,0 +1,19 @@
export type Book = {
id: string;
product_title?: string;
author?: string;
resume?: string;
image_url?: string;
collection?: string;
date_de_parution?: string;
ean?: number;
editeur?: string;
format?: string;
isbn?: string;
nb_de_pages?: number;
presentation?: string | null;
width?: number;
height?: number;
depth?: number;
poids ?:number;
}

View File

@ -0,0 +1,12 @@
export type BookFilters = {
mot_clef: string;
collections: string[];
nb_de_pages: { min?: number, max?: number },
date_de_parution: { après?: string, avant?: string }
}
export type BookSimilarParams = {
author?: boolean,
fast?: boolean,
collection?: boolean,
}

View File

@ -0,0 +1,24 @@
import {defineStore} from 'pinia'
import type {Ref} from "vue";
import {ref} from "vue";
import type {Book} from "@/services/models/book";
import {getBookById} from "@/services/bookApi";
export const useBookStore = defineStore('book', () => {
const book: Ref<Book | undefined> = ref(undefined)
const isLoading = ref(false);
async function loadBook(id: string) {
isLoading.value = true;
return getBookById(id)
.then(result => {
console.log("get book")
book.value = result
})
.finally(() => {
isLoading.value = false;
})
}
return {book, isLoading, loadBook}
})

View File

@ -0,0 +1,50 @@
import {defineStore} from 'pinia'
import type {Ref} from "vue";
import {computed, ref} from "vue";
import type {Book} from "@/services/models/book";
import {search} from "@/services/bookApi";
import {watchDebounced} from '@vueuse/core'
import type {BookFilters} from "@/services/models/filter";
export const useBooksStore = defineStore('books', () => {
const books: Ref<Book[]> = ref([])
const areBooksLoading = ref(false);
const noData = computed(() => books.value.length === 0)
const filters: Ref<BookFilters> = ref({
mot_clef: "", collections: [],
nb_de_pages: {min: undefined, max: undefined},
date_de_parution: {après: undefined, avant: undefined}
},
);
watchDebounced(
filters.value,
searchBooksWithFilters,
{debounce: 1000, maxWait: 1000},
)
function searchBooks() {
if (noData.value) {
return searchBooksWithFilters();
}
}
async function searchBooksWithFilters() {
areBooksLoading.value = true;
try {
books.value = await search({
...filters.value,
collections: filters.value.collections.map(c => (c as string).split(" ").join("_")),
date_de_parution: {
après: filters.value.date_de_parution.après,
avant: filters.value.date_de_parution.avant,
}
});
} finally {
areBooksLoading.value = false;
}
}
return {books, filters, areBooksLoading, noData, searchBooks}
})

3
src/styles/README.md Normal file
View File

@ -0,0 +1,3 @@
# Styles
This directory is for configuring the styles of the application.

10
src/styles/settings.scss Normal file
View File

@ -0,0 +1,10 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );

14
tsconfig.app.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

25
typed-router.d.ts vendored Normal file
View File

@ -0,0 +1,25 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
declare module 'vue-router/auto-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/BookDetails': RouteRecordInfo<'/BookDetails', '/BookDetails', Record<never, never>, Record<never, never>>,
'/Search': RouteRecordInfo<'/Search', '/Search', Record<never, never>, Record<never, never>>,
}
}

62
vite.config.mts Normal file
View File

@ -0,0 +1,62 @@
// Plugins
import Components from 'unplugin-vue-components/vite'
import Vue from '@vitejs/plugin-vue'
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
import ViteFonts from 'unplugin-fonts/vite'
import VueRouter from 'unplugin-vue-router/vite'
// Utilities
import {defineConfig} from 'vite'
import {fileURLToPath, URL} from 'node:url'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
VueRouter(),
Vue({
template: {transformAssetUrls},
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
}),
Components(),
ViteFonts({
google: {
families: [{
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900',
}],
},
}),
],
define: {'process.env': {}},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: [
'.js',
'.json',
'.jsx',
'.mjs',
'.ts',
'.tsx',
'.vue',
],
},
server: {
port:3000,
},
css: {
preprocessorOptions: {
sass: {
api: 'modern-compiler',
},
},
},
}
)