48 Commits

Author SHA1 Message Date
Andreani Jean
8a7dc6d07d add new deisgn 2023-08-09 12:54:47 +02:00
Andreani Jean
fd8d3168a8 add nex version 2023-08-08 18:44:38 +02:00
Andreani Jean
db29dfdc4c refacto css to convert rem to px with sass function 2023-08-07 15:33:25 +02:00
Andreani Jean
e27c68c46c fix add other type file drag drop 2023-08-07 10:37:20 +02:00
Andreani Jean
403188eea1 fix sequences 2023-08-07 09:53:58 +02:00
Andreani Jean
b48acd231d wip test + add alerte is leave page 2023-08-07 09:53:58 +02:00
Andreani Jean
86d2ad76cb wip 2023-08-07 09:53:58 +02:00
Andreani Jean
a68a08f909 fix pipeline test e2e 2023-08-07 09:53:58 +02:00
Andreani Jean
cbe3f8e7be fix test:e2e 2023-08-07 09:53:58 +02:00
Andreani Jean
da67295896 add and fix test 2023-08-07 09:53:58 +02:00
Andreani Jean
62acc345c2 update geovisio + add tests to utils 2023-08-07 09:53:58 +02:00
Andreani Jean
9162a3a600 add fr moment locale 2023-08-07 09:53:58 +02:00
Andreani Jean
dcb9b1eb8e fix sort function 2023-08-07 09:53:58 +02:00
Andreani Jean
3e2ea47f74 fix TS 2023-08-07 09:53:58 +02:00
Andreani Jean
4bb802acbc fix interface + button & link css + font css viewer 2023-08-07 09:53:58 +02:00
Andreani Jean
12ef180e90 add V1 upload pictures 2023-08-07 09:53:58 +02:00
Andreani Jean
7ef22ea09d wip upload interface 2023-08-07 09:53:57 +02:00
Andreani Jean
0e9ec753da fix type check 2023-08-07 09:53:57 +02:00
Andreani Jean
ad60792020 test in dev 2023-08-07 09:53:57 +02:00
Adrien Pavie
b72dc72ed3 Update 03_Settings.md 2023-08-03 10:48:48 +00:00
Jean Andreani
ae0774da79 Merge branch 'fix/auth-conf-header' into 'main'
fix local api url to auth route + bug header instance without login

See merge request geovisio/website!76
2023-08-03 09:47:22 +00:00
Jean Andreani
01e9eee787 fix local api url to auth route + bug header instance without login 2023-08-03 09:47:22 +00:00
Jean Andreani
48cb836f5f Merge branch 'fix/geovisio-version' into 'main'
fix: edit version geovisio siteweb

See merge request geovisio/website!75
2023-08-03 08:51:08 +00:00
Jean Andreani
e4dffd032a fix: edit version geovisio siteweb 2023-08-03 08:51:07 +00:00
antoine-de
6726b62132 Merge branch 'bump_viewer_version' into 'main'
Update to the new viewer version

See merge request geovisio/website!73
2023-08-02 17:01:52 +00:00
antoine-de
f6c26c44b5 Update to the new viewer version 2023-08-02 18:55:22 +02:00
Jean Andreani
b73fe07c63 Merge branch 'fix/sort-sequences' into 'main'
Fix/sort sequences

Closes #23, #26, and #25

See merge request geovisio/website!72
2023-08-02 13:01:13 +00:00
Jean Andreani
88c6cdcead Fix/sort sequences 2023-08-02 13:01:13 +00:00
Jean Andreani
377280483c Merge branch 'feat/add-env-var-doc' into 'main'
Feat : add doc for env var

Closes #28

See merge request geovisio/website!71
2023-07-31 07:57:58 +00:00
Jean Andreani
4a1e3c00a4 Feat : add doc for env var 2023-07-31 07:57:58 +00:00
Jean Andreani
e270e72cfd Merge branch 'feat/pagination-follow-picture-on-map' into 'main'
feat : pagination follow picture on map

See merge request geovisio/website!67
2023-07-28 15:20:20 +00:00
Jean Andreani
b2cceaa697 feat : pagination follow picture on map 2023-07-28 15:20:19 +00:00
Jean Andreani
9e6c7eec58 Merge branch 'feat/pagination-pictures' into 'main'
Feat/pagination pictures

See merge request geovisio/website!66
2023-07-18 12:58:49 +00:00
Jean Andreani
8a5f38e190 Feat/pagination pictures 2023-07-18 12:58:49 +00:00
Jean Andreani
bd5afbfa8d Merge branch 'feat/hide-delete-sequence' into 'main'
Feat/hide delete sequence

See merge request geovisio/website!62
2023-07-17 14:24:13 +00:00
Jean Andreani
cc84cf1235 Feat/hide delete sequence 2023-07-17 14:24:12 +00:00
Jean Andreani
daeba42f18 Merge branch 'fix/overlay-hmr' into 'main'
fix/overlay-hmr

See merge request geovisio/website!65
2023-07-12 06:44:55 +00:00
Jean Andreani
07a477817d fix/overlay-hmr 2023-07-12 06:44:55 +00:00
Jean Andreani
42b46a75b8 Merge branch 'fix/overlay-hmr' into 'main'
add overlay false to conf vite

See merge request geovisio/website!64
2023-07-11 19:17:01 +00:00
Andreani Jean
9aca07a670 add overlay false to conf vite 2023-07-11 21:12:47 +02:00
Jean Andreani
ab489f3d21 Merge branch 'fix/header-links-logged' into 'main'
Fix/header links logged

See merge request geovisio/website!63
2023-07-11 14:09:35 +00:00
Jean Andreani
8109bbd812 Fix/header links logged 2023-07-11 14:09:35 +00:00
Jean Andreani
476cee1cfb Merge branch 'fix/confirm-box-sequence-management' into 'main'
fix/confirm-box-sequence-management

See merge request geovisio/website!61
2023-07-11 07:26:19 +00:00
Jean Andreani
4ed2c47e06 fix/confirm-box-sequence-management 2023-07-11 07:26:19 +00:00
Andreani Jean
e3c6d7def2 fix redirection guard 2023-07-05 11:37:37 +02:00
Andreani Jean
10d9d91cf1 remove package 2023-07-05 11:20:35 +02:00
Andreani Jean
bb4008fdb3 add click outside + fix logged guard 2023-07-05 11:02:11 +02:00
Andreani Jean
ff789b8da5 remove my sequence test 2023-07-04 22:52:06 +02:00
81 changed files with 4342 additions and 3809 deletions

View File

@@ -56,5 +56,4 @@ deploy:
CI: 1
script:
- yarn install
- yarn upgrade geovisio@develop
- yarn build

View File

@@ -8,7 +8,9 @@ Low-level settings can be changed through the `.env` file. An example is given i
Available parameters are:
- `VITE_API_URL`: the URL to the GeoVisio API (example: `https://geovisio.fr`)
- `VITE_API_URL`: the URL to the GeoVisio API (with trailing `/`, example: `https://geovisio.fr/`)
- `VITE_INSTANCE_NAME`: the name of the instance (example: `IGN`)
- `VITE_TILES`: the URL of your tiles : default tiles are the Open Street Map Tiles (example: `https://wxs.ign.fr/essentiels/static/vectorTiles/styles/PLAN.IGN/attenue.json`)
- Settings for the work environment:
- `NPM_CONFIG_PRODUCTION`: is it production environment (`true`, `false`)
- `YARN_PRODUCTION`: same as below, but if you use Yarn instead of NPM
@@ -26,7 +28,6 @@ All the wordings of the website are on this [locale file](./src/locales/fr.json)
- The website title (properties `title` and `meta.title`)
- The description (property `meta.description`)
- The used API URL (property `pages.upload.terminal_text`)
- Links to help pages:
- `upload.description`
- `upload.footer_description_terminal`

View File

@@ -21,13 +21,13 @@
},
"dependencies": {
"@popperjs/core": "^2.11.6",
"@vueuse/components": "^10.2.1",
"@vueuse/core": "^10.2.1",
"axios": "^1.2.3",
"bootstrap": "^5.2.3",
"bootstrap-icons": "^1.10.3",
"geovisio": "2.0.6",
"geovisio": "2.1.1",
"moment": "^2.29.4",
"pinia": "^2.1.4",
"vue": "^3.2.45",
"vue-axios": "^3.5.2",
"vue-eslint-parser": "^9.1.0",
@@ -38,6 +38,7 @@
"vue3-smooth-scroll": "^0.8.1"
},
"devDependencies": {
"@pinia/testing": "^0.1.2",
"@rushstack/eslint-patch": "^1.1.4",
"@types/jsdom": "^20.0.1",
"@types/node": "^18.11.18",

View File

@@ -3,19 +3,20 @@ import Header from '@/components/Header.vue'
import { RouterView } from 'vue-router'
import { useMeta } from 'vue-meta'
import { useI18n } from 'vue-i18n'
import title from '@/utils/index'
import authConfig from './composables/auth'
const { authConf } = authConfig()
const { t } = useI18n()
useMeta({
title: t('general.title'),
title: title(t('general.title')),
og: {
title: t('general.meta.title'),
description: t('general.meta.description')
title: title(t('general.meta.title')),
description: title(t('general.meta.description'))
},
twitter: {
title: t('general.meta.title'),
description: t('general.meta.description')
title: title(t('general.meta.title')),
description: title(t('general.meta.description'))
}
})
</script>
@@ -25,9 +26,8 @@ useMeta({
<template v-slot:title="{ content }">{{ content }}</template>
</metainfo>
<Header
v-if="authConf.user_profile"
:auth-enabled="authConf.enabled"
:user-profile-url="authConf.user_profile.url"
:user-profile-url="authConf.user_profile ? authConf.user_profile.url : ''"
/>
<RouterView />
</template>

View File

@@ -1,48 +1,56 @@
@mixin text($size) {
@if $size == h1 {
font-weight: normal;
font-size: 4rem;
font-size: toRem(3);
@media (max-width: 500px) {
font-size: 2.6rem;
@media (max-width: toRem(50)) {
font-size: toRem(2.6);
}
}
@if $size == h2 {
font-weight: normal;
font-size: 2rem;
font-size: toRem(2);
@media (max-width: 500px) {
font-size: 1.8rem;
@media (max-width: toRem(50)) {
font-size: toRem(1.8);
}
}
@if $size == h3 {
font-weight: normal;
font-size: toRem(1.8);
@media (max-width: toRem(50)) {
font-size: toRem(1.6);
}
}
@if $size == h4 {
font-weight: normal;
font-size: 1.6rem;
font-size: toRem(1.6);
}
@if $size == m-regular {
font-size: 1.6rem;
font-size: toRem(1.6);
font-weight: normal;
}
@if $size == m-r-regular {
font-size: 1.6rem;
font-size: toRem(1.6);
font-weight: normal;
@media (max-width: 500px) {
font-size: 1.2rem;
@media (max-width: toRem(50)) {
font-size: toRem(1.2);
}
}
@if $size == s-regular {
font-size: 1.4rem;
font-size: toRem(1.4);
font-weight: normal;
}
@if $size == xs-r-regular {
font-size: 1.2rem;
font-size: toRem(1.2);
font-weight: normal;
@media (max-width: 500px) {
font-size: 1rem;
@media (max-width: toRem(50)) {
font-size: toRem(1);
}
}
@if $size == xss-regular {
font-size: 0.9rem;
font-size: toRem(0.9);
font-weight: normal;
}
}

BIN
src/assets/images/360.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -1,5 +1,4 @@
html {
font-size: 62.5%; /* 1rem = 10px */
height: -webkit-fill-available;
}
body {
@@ -25,32 +24,36 @@ h5 {
padding: 0;
list-style: none;
}
:root {
--white: #ffffff;
--black: #181818;
--black-pale: #1b1a17;
--red: #f70000;
--red-pale: #ff726f;
--grey: #e6e6e6;
--grey-pale: #CFD2CF;
--grey-pale: #cfd2cf;
--grey-semi-dark: #808080;
--grey-dark: #54595e;
--blue: #4945ff;
--blue-dark: #051f61;
--blue-geovisio: #34495e;
--blue-semi: rgba(207, 226, 255, 0.5);
--blue-pale: #f9fafd;
--beige: #f5f3ec;
--beige-pale: #ececec80;
--yellow: #fec868;
--orange: #ff6f00;
--green: #59ce8f;
--green-pale: #f0ffee;
}
@media (min-width: 1024px) {
@media (min-width: toRem(102.4)) {
body {
display: flex;
place-items: center;
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
@supports (-webkit-touch-callout: none) {
/* CSS specific to iOS devices */
body {

4
src/assets/rem-calc.scss Normal file
View File

@@ -0,0 +1,4 @@
@function toRem($value) {
$remValue: calc($value / 1.6) + rem;
@return $remValue;
}

View File

@@ -1,40 +1,44 @@
<template>
<p class="instance-beta">
{{ $t('general.header.title') }}
{{ title($t('general.header.title')) }}
<span class="beta">{{ $t('general.header.beta_text') }}</span>
</p>
</template>
<script lang="ts" setup>
import title from '@/utils/index'
</script>
<style lang="scss" scoped>
.beta {
@include text(xs-r-regular);
color: var(--red);
border: 1px solid var(--red);
border-radius: 0.5rem;
padding: 0.2rem 0.3rem;
margin-left: 0.5rem;
border: toRem(0.1) solid var(--red);
border-radius: toRem(0.5);
padding: toRem(0.2) toRem(0.3);
margin-left: toRem(0.5);
position: absolute;
top: -1rem;
right: -9.5rem;
width: 9rem;
top: toRem(-1);
right: toRem(-9.5);
width: toRem(9);
display: flex;
justify-content: center;
}
@media (max-width: 768px) {
@media (max-width: toRem(76.8)) {
.instance-beta {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--grey);
padding: 1rem;
padding: toRem(1);
margin-bottom: 0;
}
.beta {
position: relative;
top: initial;
right: initial;
margin-left: 1rem;
margin-left: toRem(1);
}
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<button
:disabled="isLoading || disabled"
type="button"
:class="[look, 'default', { disabled }]"
:type="type"
:class="[look, 'default', { disabled: disabled || isLoading }]"
@click="$emit('trigger')"
>
<i v-if="icon" :class="[icon, 'icon']"></i>
@@ -14,109 +14,120 @@
</template>
<script lang="ts" setup>
import type { PropType } from 'vue'
type TypeInterface = 'button' | 'submit' | 'reset'
defineProps({
icon: { type: String, default: null },
disabled: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
text: { type: String, default: '' },
tooltip: { type: String, default: '' },
look: { type: String, default: '' }
look: { type: String, default: '' },
type: { type: String as PropType<TypeInterface>, default: 'button' }
})
</script>
<style lang="scss" scoped>
@media (min-width: 768px) {
@media (min-width: toRem(76.8)) {
.default:hover {
opacity: 0.8;
}
}
.default {
height: toRem(3.5);
min-width: toRem(3.5);
@include text(s-regular);
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: transparent;
padding: 1rem;
position: relative;
z-index: 1;
border-radius: toRem(0.5);
padding: toRem(1.3) toRem(2) toRem(1.3);
.icon {
font-size: 2.5rem;
font-size: toRem(2);
}
&:hover .tooltip-button {
visibility: visible;
}
}
.button--black {
height: 3.5rem;
border-radius: 0.5rem;
padding: 1.3rem 2rem 1.3rem;
color: var(--white);
background-color: var(--black);
}
.button--blue {
color: var(--white);
background-color: var(--blue);
&.disabled {
opacity: 0.6;
color: var(--white);
cursor: not-allowed;
}
}
.button--transparent {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 0.1rem solid var(--white);
border: toRem(0.1) solid var(--white);
background-color: var(--black);
color: var(--white);
}
.button--red {
height: 3.5rem;
min-width: 3.5rem;
border-radius: 0.5rem;
color: var(--red);
background-color: var(--white);
border: 0.1rem solid var(--red);
border: toRem(0.1) solid var(--red);
.icon {
margin-right: 0;
font-size: 1.4rem;
font-size: toRem(1.4);
color: var(--red);
}
.text {
margin-left: 1rem;
margin-left: toRem(1);
}
}
.button--white {
height: 3.5rem;
border-radius: 0.5rem;
color: var(--black);
background-color: var(--white);
border: 0.1rem solid var(--black);
border: toRem(0.1) solid var(--black);
.icon {
font-size: 1.4rem;
font-size: toRem(1.4);
color: var(--black);
margin-right: 0;
}
.text {
margin-left: 1rem;
margin-left: toRem(1);
}
}
.no-text {
height: 3rem;
width: 3rem;
height: toRem(3);
width: toRem(3);
padding: 0;
.icon {
color: var(---black);
font-size: toRem(1.8);
margin-right: 0;
}
}
.link--grey {
color: var(--grey-semi-dark);
.icon {
font-size: 1.4rem;
font-size: toRem(1.4);
color: var(--grey-semi-dark);
}
}
.link--red {
height: 3rem;
color: var(--red);
background-color: var(--white);
.icon {
font-size: 1.4rem;
font-size: toRem(1.4);
color: var(--red);
}
}
.icon {
margin-right: 1rem;
font-size: 2rem;
margin-right: toRem(1);
font-size: toRem(2);
color: var(--white);
}
.button--rounded {
@@ -125,32 +136,28 @@ defineProps({
align-items: center;
border-radius: 50%;
padding: 0;
height: 2.5rem;
width: 2.5rem;
height: toRem(2.5);
width: toRem(2.5);
.icon {
color: var(---black);
font-size: 1.8rem;
font-size: toRem(1.8);
margin-right: 0;
}
}
.default .tooltip-button {
.tooltip-button {
background-color: var(--black);
color: var(--white);
text-align: center;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
border-radius: toRem(0.5);
padding: toRem(0.5) toRem(1);
position: absolute;
bottom: -100%;
visibility: hidden;
width: 18rem;
width: toRem(18);
right: 0;
@include text(xss-regular);
}
.default:hover .tooltip-button {
visibility: visible;
}
.disabled {
color: var(--grey-pale);
border-color: var(--grey-pale);

View File

@@ -6,13 +6,15 @@
<nav class="nav">
<div class="wrapper-logo desktop">
<Link
:image="{
url: 'logo.jpeg',
alt: $t('general.header.alt_logo')
}"
:text="$t('general.header.title')"
path="/"
:image="{ url: 'logo.jpeg', alt: $t('general.header.alt_logo') }"
:route="{ name: 'home' }"
/>
<span class="title-text"
>{{ $t('general.header.title') }}
<span v-if="instanceName" class="instance-text">{{
instanceName
}}</span></span
>
</div>
<div class="wrapper-logo responsive">
<Link
@@ -20,59 +22,68 @@
url: 'logo.jpeg',
alt: $t('general.header.alt_logo')
}"
path="/"
:route="{ name: 'home' }"
/>
</div>
<div class="wrapper-entries">
<ul :class="['nav-list', { 'menu-open': !menuIsClosed }]">
<li v-if="isLogged" class="logged-link">
<div ref="list" class="wrapper-entries">
<ul
v-if="isLogged && authEnabled"
:class="['nav-list', { 'menu-open': !menuIsClosed }]"
>
<li class="logged-link">
<Link
:text="$t('general.header.sequences_text')"
icon="bi bi-images"
path="/mes-sequences"
:route="{ name: 'my-sequences' }"
@click.native="closeModal"
/>
</li>
<li v-if="userProfileUrl && isLogged" class="logged-link">
<li v-if="userProfileUrl.length" class="logged-link">
<Link
path="/mes-informations"
:route="{ name: 'my-information' }"
icon="bi bi-person"
:text="$t('general.header.my_information_text')"
@click.native="closeModal"
/>
</li>
<li v-if="userProfileUrl && isLogged" class="logged-link">
<li class="logged-link">
<Link
path="/mes-parametres"
:route="{ name: 'my-settings' }"
icon="bi bi-gear"
:text="$t('general.header.my_settings_text')"
@click.native="closeModal"
/>
</li>
<li v-if="userProfileUrl && isLogged" class="logged-link">
<li class="logged-link">
<Link
type="external"
icon="bi bi-power"
:text="$t('general.header.logout_text')"
:path="getAuthRoute('auth/logout', route.path)"
:path-external="getAuthRoute('auth/logout', route.path)"
@click.native="closeModal"
/>
</li>
</ul>
<div class="wrapper-right-entries">
<div v-if="isLogged && authEnabled">
<Link
:text="$t('general.header.upload_text')"
look="button button--blue"
:route="{ name: 'upload-pictures' }"
@click.native="closeModal"
/>
</div>
<div>
<Link
:text="$t('general.header.contribute_text')"
look="button white"
path="/partager-des-photos"
:route="{ name: 'share-pictures' }"
@click.native="closeModal"
/>
</div>
<button
v-if="isLogged"
v-if="isLogged && authEnabled"
class="menu-burger"
:aria-label="ariaLabel"
v-on-click-outside="closeModal"
@click="toggleMenu"
>
<div v-if="isLogged" class="item-with-sub">
@@ -83,11 +94,11 @@
<i v-else class="bi bi-list"></i>
</div>
</button>
<div v-else>
<div v-else-if="!isLogged && authEnabled">
<Link
type="external"
icon="bi bi-person-circle"
:path="getAuthRoute('auth/login', route.path)"
:path-external="getAuthRoute('auth/login', route.path)"
/>
</div>
</div>
@@ -98,7 +109,7 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import type { vOnClickOutside } from '@vueuse/components'
import { onClickOutside } from '@vueuse/core'
import { useCookies } from 'vue3-cookies'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -111,11 +122,13 @@ const { t } = useI18n()
const route = useRoute()
defineProps({
authEnabled: { type: Boolean, default: true },
userProfileUrl: { type: String, default: null }
userProfileUrl: { type: String, default: '' }
})
const list = ref(null)
let menuIsClosed = ref<boolean>(true)
onClickOutside(list, () => closeModal())
function closeModal() {
menuIsClosed.value = true
}
@@ -124,6 +137,7 @@ function toggleMenu(): void {
menuIsClosed.value = !menuIsClosed.value
}
const isLogged = computed((): boolean => !!cookies.get('user_id'))
const instanceName = computed((): string => import.meta.env.VITE_INSTANCE_NAME)
const ariaLabel = computed((): string =>
menuIsClosed.value
? t('general.header.burger_menu_aria_label_open')
@@ -142,19 +156,29 @@ const userName = computed((): string =>
.header {
display: flex;
align-items: center;
height: 8rem;
border-bottom: 0.1rem solid var(--black);
height: toRem(8);
background-color: var(--blue-pale);
}
.nav {
width: 100%;
padding-right: 2rem;
padding-left: 2rem;
padding-right: toRem(2);
padding-left: toRem(2);
display: flex;
justify-content: space-between;
}
.wrapper-logo {
display: flex;
align-items: center;
color: var(--blue-dark);
}
.title-text {
font-weight: bolder;
}
.instance-text {
font-weight: normal;
border-left: toRem(0.1) solid var(--blue-dark);
padding-left: toRem(0.7);
margin-left: toRem(0.5);
}
.wrapper-entries {
display: flex;
@@ -163,12 +187,12 @@ const userName = computed((): string =>
display: none;
}
.desktop {
display: block;
display: flex;
}
.wrapper-logo p {
@include text(m-r-regular);
margin-bottom: 0;
margin-left: 1rem;
margin-left: toRem(1);
position: relative;
}
.item-with-sub {
@@ -181,45 +205,42 @@ const userName = computed((): string =>
}
.sub-nav-block {
display: none;
border-radius: 0.5rem;
border: 1px solid var(--black);
border-radius: toRem(0.5);
border: toRem(0.1) solid var(--black);
background-color: var(--white);
position: absolute;
right: 0;
top: 3.5rem;
top: toRem(3.5);
z-index: 1;
width: 15rem;
width: toRem(15);
}
.logged-link {
display: flex;
padding: 0.5rem 2rem 0.7rem;
padding: toRem(0.5) toRem(2) toRem(0.7);
}
.logged-link:hover {
border-radius: 0.5rem;
border-radius: toRem(0.5);
background-color: var(--grey);
}
.nav-list-item {
margin-right: 1.5rem;
}
.menu-burger {
display: none;
margin-right: toRem(1.5);
}
.wrapper-right-entries {
display: flex;
align-items: center;
div:first-child {
margin-right: 2rem;
div {
margin-right: toRem(2);
}
}
.cross {
font-size: 2rem;
font-size: toRem(2);
}
.item-with-sub {
margin-right: 1.5rem;
margin-right: toRem(1.5);
}
.nav {
align-items: center;
padding: 1.5rem;
padding: toRem(1.5);
}
.nav-list {
display: none;
@@ -227,23 +248,23 @@ const userName = computed((): string =>
justify-content: center;
align-items: initial;
position: absolute;
width: 20rem;
top: 8rem;
width: toRem(20);
top: toRem(8);
right: 0;
z-index: 2;
background-color: var(--white);
box-shadow: 0 0.2rem 0.4rem rgb(0 0 0 / 10%);
box-shadow: 0 toRem(0.2) toRem(0.4) rgb(0 0 0 / 10%);
padding-left: 0;
padding-top: 1rem;
padding-bottom: 1rem;
border-radius: 1rem;
padding-top: toRem(1);
padding-bottom: toRem(1);
border-radius: toRem(1);
}
.menu-burger {
display: block;
background-color: transparent;
border: none;
width: 2.5rem;
font-size: 2.5rem;
width: toRem(2.5);
font-size: toRem(2.5);
padding: 0;
.item-with-sub {
@include text(s-regular);
@@ -252,8 +273,8 @@ const userName = computed((): string =>
align-items: center;
background-color: var(--blue);
color: var(--white);
height: 3rem;
width: 3rem;
height: toRem(3);
width: toRem(3);
border-radius: 50%;
margin-right: 0;
}
@@ -261,19 +282,18 @@ const userName = computed((): string =>
.menu-open {
display: flex;
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.header {
flex-direction: column;
height: 11rem;
height: toRem(11);
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 4;
background: var(--white);
}
.nav-list {
top: 11rem;
top: toRem(11);
width: 100%;
}
.desktop {

View File

@@ -8,11 +8,17 @@
@click="$emit('trigger')"
>
<div
v-if="href && status !== 'waiting-for-process'"
v-if="status.length && status !== 'waiting-for-process'"
class="photo-img-wrapper"
>
<i v-if="status === 'hidden'" class="bi bi-eye-slash icon-hidden"></i>
<img :src="href" alt="" loading="lazy" class="photo-img" />
<img
v-if="href"
:src="href"
alt=""
loading="lazy"
class="photo-img"
/>
</div>
<div v-else class="waiting-wrapper">
<i class="bi bi-card-image icon-waiting"></i>
@@ -31,19 +37,21 @@
<i class="bi bi-check-lg" />
</div>
<div v-if="status === 'waiting-for-process'" class="photo-info">
<span class="waiting">{{
<span class="waiting info">{{
$t('pages.sequence.waiting_process')
}}</span>
</div>
<div v-else class="photo-info">
<span v-if="created"><i class="bi bi-clock"></i> {{ created }}</span>
<span v-if="created" class="info"
><i class="bi bi-clock"></i> {{ created }}</span
>
<div class="button-info">
<Link
look="button--blue no-text"
look="button button--white-blue no-text"
icon="bi bi-cloud-download-fill"
type="external"
target="_blank"
:path="hrefHd"
:path-external="hrefHd"
/>
</div>
</div>
@@ -61,7 +69,12 @@ defineProps({
hrefHd: { type: String, default: null },
selected: { type: Boolean, default: false },
selectedOnMap: { type: Boolean, default: false },
status: { type: String, default: null }
status: {
type: String,
validator: (value: string): boolean =>
['waiting-for-process', 'ready', 'hidden', ''].includes(value),
default: ''
}
})
</script>
@@ -71,61 +84,59 @@ defineProps({
border: none;
width: 100%;
padding: 0;
position: relative;
}
.selected {
border: 0.1rem solid var(--blue);
border-radius: 0.5rem;
border: toRem(0.1) solid var(--blue);
border-radius: toRem(0.5);
box-shadow: 0px 4px 4px 0px #00000040;
}
.wrapper-image {
position: relative;
border-top-right-radius: 0.5rem;
border-top-left-radius: 0.5rem;
display: flex;
}
.photo-img-wrapper {
display: flex;
justify-content: center;
align-items: center;
border-top-right-radius: 0.5rem;
border-top-left-radius: 0.5rem;
height: 12rem;
border-top-right-radius: toRem(0.5);
border-top-left-radius: toRem(0.5);
height: toRem(16);
width: 100%;
object-fit: cover;
}
.photo-img {
height: 100%;
width: 100%;
border-top-right-radius: 0.5rem;
border-top-left-radius: 0.5rem;
border-radius: toRem(0.5);
}
.icon-hidden {
color: var(--grey-dark);
position: absolute;
font-size: 4rem;
font-size: toRem(4);
}
.waiting-wrapper {
height: 12rem;
height: toRem(16);
display: flex;
justify-content: center;
align-items: center;
color: var(--blue);
}
.icon-waiting {
height: 4rem;
font-size: 4rem;
height: toRem(4);
font-size: toRem(4);
}
.icon-img {
top: 1rem;
right: 1rem;
top: toRem(1);
right: toRem(1);
background-color: var(--white);
border-radius: 50%;
position: absolute;
height: 2rem;
width: 2rem;
height: toRem(2);
width: toRem(2);
display: flex;
justify-content: center;
align-items: center;
font-size: 1.3rem;
font-size: toRem(1.3);
}
.pointer-map,
.button-check-pointer {
@@ -135,11 +146,14 @@ defineProps({
opacity: 1;
}
.photo-info {
height: 5rem;
height: toRem(5);
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
padding: toRem(1);
position: absolute;
bottom: 0;
width: 100%;
}
.waiting {
@@ -148,21 +162,27 @@ defineProps({
color: var(--black);
}
.photo-img,
.photo-info {
&:hover {
.info {
padding: toRem(0.5) toRem(0.8);
background-color: var(--white);
border-radius: toRem(0.5);
color: var(--blue);
}
.button-image-item:hover {
.photo-img {
opacity: 0.5;
}
}
.hidden {
.photo-img,
.photo-info {
.info {
opacity: 0.3;
}
&:hover {
.photo-img,
.photo-info {
.info {
opacity: 1;
}
}

View File

@@ -24,11 +24,11 @@ import type { CheckboxInterface } from '@/views/interfaces/MySequenceView'
const emit = defineEmits<{ (e: 'trigger', value: CheckboxInterface): void }>()
const props = defineProps({
name: { type: String, default: null },
label: { type: String, label: '' },
label: { type: String, default: '' },
isChecked: { type: Boolean, default: false },
isIndeterminate: { type: Boolean, default: false }
})
let inputValue = ref<boolean>(props.isChecked)
const htmlCheckbox = <HTMLInputElement>document.getElementById('checkbox')
watchEffect(async () => {
@@ -37,8 +37,6 @@ watchEffect(async () => {
}
})
let inputValue = ref<boolean>(props.isChecked)
function updateValue(value: boolean): void {
if (htmlCheckbox) {
htmlCheckbox.indeterminate = false
@@ -54,8 +52,8 @@ function updateValue(value: boolean): void {
display: flex;
justify-content: center;
align-items: center;
height: 2rem;
width: 2rem;
height: toRem(2);
width: toRem(2);
}
.input {
-webkit-appearance: none;
@@ -68,7 +66,7 @@ function updateValue(value: boolean): void {
width: 100%;
}
.icon {
font-size: 2rem;
font-size: toRem(2);
position: absolute;
color: var(--grey-semi-dark);
}
@@ -78,7 +76,7 @@ function updateValue(value: boolean): void {
}
.label {
cursor: pointer;
margin-left: 0.5rem;
margin-left: toRem(0.5);
@include text(s-regular);
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<label
@dragover="dragover"
@dragleave="dragleave"
@drop="drop"
:class="['file-upload', { dragging: isDragging }]"
>
<input
ref="upload"
type="file"
multiple
:accept="accept"
capture="environment"
class="input-file"
@change="changeFile"
/>
<i class="bi bi-cloud-upload-fill"></i>
<span v-if="text" class="input-text">
{{ text }}
<span v-if="textSecondPart" class="last-word">{{ textSecondPart }}</span>
</span>
<span v-if="textPictureType" class="input-text-type">{{
textPictureType
}}</span>
</label>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const emit = defineEmits<{ (e: 'trigger', value: FileList): void }>()
let isDragging = ref<boolean>(false)
defineProps({
text: { type: String, default: null },
textPictureType: { type: String, default: null },
textSecondPart: { type: String, default: null },
accept: { type: String, default: '' }
})
interface HTMLInputChangeEvent extends Event {
target: HTMLInputElement & EventTarget
}
function changeFile(event: Event): void {
const { target } = event as HTMLInputChangeEvent
if (target && target.files) {
if (!checkPicturesType(target.files)) return
emit('trigger', target.files)
}
}
function dragover(event: DragEvent): void {
event.preventDefault()
isDragging.value = true
}
function dragleave(): void {
isDragging.value = false
}
function drop(event: DragEvent): void | boolean {
event.preventDefault()
const { dataTransfer } = event
if (dataTransfer && dataTransfer.files) {
if (!checkPicturesType(dataTransfer.files))
return (isDragging.value = false)
emit('trigger', dataTransfer.files)
isDragging.value = false
}
}
function checkPicturesType(files: FileList): number {
const picturesToUpload = [...files]
.filter((p) => p.type == 'image/jpeg')
.sort((a, b) => a.name.localeCompare(b.name))
return picturesToUpload.length
}
</script>
<style scoped lang="scss">
.file-upload {
border: toRem(0.1) dashed var(--blue);
background-color: var(--blue-semi);
border-radius: toRem(0.5);
padding: toRem(0.3) toRem(0.3) toRem(3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.dragging {
border-color: var(--green);
background-color: var(--green-pale);
.last-word,
.bi-cloud-upload-fill {
color: var(--green);
}
}
.file-upload input {
overflow: hidden;
width: 0;
}
.bi-cloud-upload-fill {
color: var(--blue);
font-size: toRem(7);
}
.input-text {
font-size: toRem(2);
@include text(m-regular);
font-weight: bold;
width: toRem(21);
text-align: center;
margin-bottom: toRem(1);
}
.input-text-type {
@include text(xs-r-regular);
}
.last-word {
color: var(--blue);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<a
v-if="type === 'external'"
:href="path"
:href="pathExternal"
:target="target"
:class="['default', look, { disabled }]"
:title="titleImg"
@@ -14,7 +14,8 @@
</a>
<router-link
v-else
:to="path"
:to="route"
:target="target"
:class="['default', look, { disabled }]"
:title="text"
>
@@ -28,6 +29,7 @@
import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import type { PropType } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { img } from '../utils/image'
const { t } = useI18n()
@@ -38,7 +40,8 @@ interface ImageInterface {
const props = defineProps({
text: { type: String, default: null },
path: { type: String, default: '' },
route: { type: Object as PropType<RouteLocationRaw>, default: {} },
pathExternal: { type: String, default: '' },
look: { type: String, default: '' },
type: { type: String, default: null },
alt: { type: String, default: '' },
@@ -54,67 +57,84 @@ const titleImg = computed<string>(() =>
</script>
<style lang="scss" scoped>
.icon {
color: var(--black);
font-size: 2.4rem;
margin-right: 1rem;
}
.default {
@include text(s-regular);
display: flex;
align-items: center;
color: var(--black);
color: var(--blue-dark);
text-decoration: none;
width: 100%;
.icon {
margin-right: 1rem;
margin-right: toRem(1);
}
&:hover {
opacity: toRem(0.8);
}
}
.icon {
color: var(--black);
font-size: toRem(2.4);
}
.logo {
height: toRem(4);
border-radius: toRem(0.5);
margin-right: toRem(1);
}
.button {
height: toRem(4);
border-radius: toRem(0.5);
padding: toRem(1.3) toRem(2) toRem(1.3);
background-color: var(--black);
color: var(--white);
}
.text {
width: 100%;
white-space: break-spaces;
}
.link:hover {
background-color: transparent;
text-decoration: underline;
}
.button {
height: 4rem;
border-radius: 0.5rem;
padding: 1.3rem 2rem 1.3rem;
background-color: var(--black);
color: var(--white);
}
.white {
.button--white {
background-color: var(--white);
color: var(--black);
border: 0.1rem solid var(--black);
border: toRem(0.1) solid var(--black);
.icon {
font-size: toRem(1.6);
}
&:hover {
background-color: var(--black);
color: var(--white);
.icon {
color: white;
}
}
}
.white .icon {
font-size: 1.6rem;
.button--white-blue {
background-color: var(--white);
border: toRem(0.1) solid var(--blue);
.icon {
font-size: toRem(1.4);
color: var(--blue);
}
}
.button:hover {
opacity: 0.9;
}
.white:hover {
background-color: var(--black);
color: var(--white);
}
.white:hover > .icon {
color: white;
}
.logo {
height: 4rem;
border-radius: 0.5rem;
margin-right: 1rem;
.button--blue {
background-color: var(--blue);
border: toRem(0.1) solid var(--blue);
.icon {
font-size: toRem(1.4);
color: var(--white);
}
}
.disabled {
color: grey;
cursor: not-allowed;
}
.disabled .icon {
color: grey;
cursor: not-allowed;
}
.disabled:hover {
text-decoration: none;
.icon {
color: grey;
cursor: not-allowed;
}
&:hover {
text-decoration: none;
}
}
.button--rounded {
background-color: var(--black);
@@ -123,50 +143,29 @@ const titleImg = computed<string>(() =>
align-items: center;
border-radius: 50%;
padding: 0;
height: 4rem;
width: 4rem;
height: toRem(4);
width: toRem(4);
.icon {
color: var(--white);
font-size: 2.8rem;
font-size: toRem(2.8);
margin-right: 0;
}
}
.button--white {
height: 4rem;
border-radius: 0.5rem;
color: var(--black);
background-color: var(--white);
border: 0.1rem solid var(--black);
.icon {
font-size: 1.4rem;
color: var(--black);
}
}
.button--blue {
height: 4rem;
border-radius: 0.5rem;
background-color: var(--white);
border: 0.1rem solid var(--blue);
.icon {
font-size: 1.4rem;
color: var(--blue);
}
}
.no-text {
height: 3rem;
width: 3rem;
height: toRem(3);
width: toRem(3);
padding: 0;
.icon {
margin: auto;
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.icon {
margin-right: 0.5rem;
margin-right: toRem(0.5);
}
.button {
padding-right: 1rem;
padding-left: 1rem;
padding-right: toRem(1);
padding-left: toRem(1);
}
.disable-mobile {
pointer-events: none;

View File

@@ -1,5 +1,5 @@
<template>
<div class="lds-ring">
<div :class="['lds-ring', look, { loaded: isLoaded }]">
<div></div>
<div></div>
<div></div>
@@ -7,27 +7,66 @@
</div>
</template>
<script setup></script>
<script setup lang="ts">
defineProps({
text: { type: String, default: null },
look: {
type: String,
validator: (value: string): boolean => ['sm', 'md', 'lg'].includes(value),
default: 'sm'
},
isLoaded: { type: Boolean, default: true }
})
</script>
<style scoped scss>
<style scoped lang="scss">
.lds-ring {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
display: flex;
justify-content: center;
align-items: center;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 8px solid var(--blue);
width: 256px;
height: 256px;
margin: 10px;
border: 10px solid var(--blue);
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: var(--blue) transparent transparent transparent;
}
.loaded {
div {
border-color: var(--blue);
}
}
.sm {
width: toRem(8);
height: toRem(8);
div {
width: toRem(8.4);
height: toRem(8.4);
}
}
.md {
width: toRem(16);
height: toRem(16);
div {
width: toRem(16.8);
height: toRem(16.8);
}
}
.lg {
width: toRem(32);
height: toRem(32);
div {
width: toRem(25.6);
height: toRem(25.6);
}
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}

View File

@@ -0,0 +1,81 @@
<template>
<div
:class="['wrapper-pagination', { 'no-border': type.includes('double') }]"
>
<button
:disabled="linkDisabled"
:class="['pagination-button', { disabled: linkDisabled }]"
>
<i
v-if="type.length"
:class="`bi bi-chevron-${type} chevron`"
@click="triggerPagination"
></i>
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const emit = defineEmits<{ (e: 'trigger', value: string): void }>()
const props = defineProps({
href: { type: String, default: null },
selfLink: { type: Object, default: {} },
type: { type: String, default: '' }
})
const linkDisabled = computed((): boolean => {
return (
(props.type === 'double-left' || props.type === 'double-right') &&
props.href === props.selfLink.href
)
})
function triggerPagination(): void {
if (linkDisabled.value) return
emit('trigger', props.href)
}
</script>
<style lang="scss" scoped>
.wrapper-pagination {
border: toRem(0.1) solid var(--black);
border-radius: 50%;
background-color: var(--blue-pale);
height: toRem(3);
width: toRem(3);
display: flex;
&:first-child {
margin-right: toRem(2);
}
&:last-child {
margin-left: toRem(2);
}
&:nth-child(2) {
margin-right: toRem(1);
}
&:nth-child(3) {
margin-left: toRem(1);
}
}
.pagination-button {
background-color: transparent;
border: none;
font-size: toRem(1.7);
width: 100%;
display: flex;
align-items: center;
.chevron {
width: toRem(3.5);
}
}
.no-border {
border: none;
background-color: transparent;
}
.disabled {
cursor: default;
pointer-events: unset;
}
</style>

View File

@@ -14,7 +14,7 @@
<div class="entry-button-terminal">
<Button
look="button--transparent"
:text="$t('pages.upload.button_copy')"
:text="$t('pages.share_pictures.button_copy')"
:icon="clipboardIcon"
@trigger="copyText(textInstall)"
class="entry-button"
@@ -28,7 +28,7 @@
<div class="entry-button-terminal">
<Button
look="button--transparent"
:text="$t('pages.upload.button_copy')"
:text="$t('pages.share_pictures.button_copy')"
:icon="clipboardIcon"
@trigger="copyText(textUpload)"
class="entry-button"
@@ -65,23 +65,23 @@ async function copyText(text: string): Promise<void> {
.header {
display: flex;
align-items: center;
border-top-right-radius: 1.5rem;
border-top-left-radius: 1.5rem;
border-top-right-radius: toRem(1.5);
border-top-left-radius: toRem(1.5);
background-color: var(--grey);
padding-left: 0.5rem;
height: 3rem;
padding-left: toRem(0.5);
height: toRem(3);
width: 100%;
}
.editor {
border-bottom-right-radius: 1.5rem;
border-bottom-left-radius: 1.5rem;
border-bottom-right-radius: toRem(1.5);
border-bottom-left-radius: toRem(1.5);
background-color: var(--black-pale);
height: 32rem;
height: toRem(32);
width: 100%;
position: relative;
}
.screen {
padding: 3rem 2rem 2rem;
padding: toRem(3) toRem(2) toRem(2);
height: 100%;
}
.screen:nth-child(2n) {
@@ -90,10 +90,10 @@ async function copyText(text: string): Promise<void> {
.upload-command {
display: flex;
align-items: center;
margin-top: 2rem;
margin-top: toRem(2);
}
.entry-button-terminal {
margin-top: 2rem;
margin-top: toRem(2);
margin-left: auto;
}
.code {
@@ -105,13 +105,13 @@ async function copyText(text: string): Promise<void> {
}
.tilde {
color: var(--green);
margin-right: 1rem;
margin-right: toRem(1);
}
.round {
height: 1.5rem;
width: 1.5rem;
height: toRem(1.5);
width: toRem(1.5);
border-radius: 50%;
margin: 0.5rem;
margin: toRem(0.5);
}
.red {
background-color: var(--red);
@@ -123,9 +123,9 @@ async function copyText(text: string): Promise<void> {
background-color: var(--green);
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.editor {
min-height: 27rem;
min-height: toRem(27);
}
.upload-command {
margin-top: 0;

View File

@@ -14,7 +14,12 @@
<script lang="ts" setup>
defineProps({
text: { type: String, default: '' },
look: { type: String, default: '' }
look: {
type: String,
validator: (value: string): boolean =>
['success', 'error', ''].includes(value),
default: ''
}
})
</script>
@@ -22,26 +27,26 @@ defineProps({
.toast-wrapper {
position: fixed;
right: 0;
bottom: 2rem;
bottom: toRem(2);
transform: translateX(100%);
display: flex;
justify-content: center;
align-items: center;
color: var(--white);
@include text(s-regular);
border-radius: 0.5rem;
height: 4rem;
min-width: 10rem;
padding-right: 1rem;
padding-left: 1rem;
border-radius: toRem(0.5);
height: toRem(4);
min-width: toRem(10);
padding-right: toRem(1);
padding-left: toRem(1);
}
.button-close {
position: absolute;
top: -0.5rem;
right: -0.5rem;
height: 1.8rem;
width: 1.8rem;
border: 0.1rem solid var(--black);
top: toRem(-0.5);
right: toRem(-0.5);
height: toRem(1.8);
width: toRem(1.8);
border: toRem(0.1) solid var(--black);
background-color: var(--white);
border-radius: 50%;
display: flex;
@@ -50,10 +55,10 @@ defineProps({
}
.toast-text {
margin-bottom: 0;
margin-left: 1rem;
margin-left: toRem(1);
}
.display {
transform: translateX(-3rem);
transform: translateX(toRem(-3));
transition: transform 0.3s ease-in-out;
}
.error {

View File

@@ -0,0 +1,38 @@
<template>
<div class="share-card">
<img v-if="image" :src="img(image.url)" :alt="image.alt" class="image" />
<h2 class="title">{{ title }}</h2>
<p class="text">{{ text }}</p>
</div>
</template>
<script lang="ts" setup>
import { img } from '../../utils/image'
import type { PropType } from 'vue'
interface ImageInterface {
url: string
alt: string
}
defineProps({
image: { type: Object as PropType<ImageInterface>, default: null },
title: { type: String, default: '' },
text: { type: String, default: '' }
})
</script>
<style scoped lang="scss">
.share-card {
color: var(--blue-dark);
}
.title {
@include text(h2);
}
.text {
@include text(s-regular);
color: var(--grey-semi-dark);
}
.image {
height: toRem(8);
margin-bottom: toRem(1);
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div :class="['imported', { 'first-sequence': index === 0 }]">
<div class="uploaded-pictures">
<p v-if="sequence.pictures" class="uploaded-title">
<span
>{{ $t('pages.upload.import') }} {{ sequence.title }} -
{{ sequence.pictures.length }}/{{ sequence.pictureCount }}</span
>
</p>
<ul class="uploaded-picture-list">
<PictureItem
v-for="picture in uploadPictures"
:text="$t('pages.upload.uploaded_word')"
:name="picture.name"
look="success"
>
<i class="bi bi-check-circle"></i>
</PictureItem>
</ul>
<div v-if="sequence.id" class="wrapper-button-sequence">
<Link
:text="$t('pages.upload.sequence_link')"
look="button button--white"
target="_blank"
:route="{ name: 'sequence', params: { id: sequence.id } }"
/>
</div>
</div>
<div class="errors-pictures">
<p v-if="uploadErrors.length" class="uploaded-title">
{{ uploadErrors.length }} {{ $t('pages.upload.error_word') }}
</p>
<ul class="uploaded-error-list">
<PictureItem
v-for="error in uploadErrors"
:text="error.message"
:name="error.name"
look="error"
/>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import Link from '@/components/Link.vue'
import PictureItem from '@/components/upload/PictureItem.vue'
import type { uploadErrorInterface } from '@/views/interfaces/UploadPicturesView'
defineProps({
index: { type: Number, default: 0 },
sequence: { type: Object, default: {} },
picturesCount: { type: Number, default: null },
uploadErrors: {
type: Array as PropType<uploadErrorInterface[]>,
default: []
},
uploadPictures: {
type: Array as PropType<uploadErrorInterface[]>,
default: []
}
})
</script>
<style scoped lang="scss">
.imported {
display: flex;
padding-bottom: toRem(2);
}
.first-sequence {
border-radius: toRem(0.5);
}
.uploaded-pictures,
.errors-pictures {
width: 50%;
}
.title-current-upload {
margin-bottom: toRem(2);
@include text(m-regular);
color: var(--blue-dark);
margin-left: toRem(2);
}
.uploaded-title {
margin-bottom: toRem(2);
width: 100%;
color: var(--grey-semi-dark);
font-weight: bold;
@include text(s-regular);
}
.uploaded-picture-list {
padding-left: 0;
}
.uploaded-picture-list,
.uploaded-error-list {
padding-top: 0rem;
padding-right: toRem(2);
padding-bottom: toRem(2);
overflow-y: auto;
max-height: toRem(50);
}
.bi-check-circle {
color: var(--green);
font-size: toRem(2);
}
.wrapper-button-sequence {
padding-top: toRem(2);
width: fit-content;
margin: auto;
}
.errors-pictures {
padding-left: toRem(2);
padding-right: toRem(2);
.uploaded-title {
margin-top: toRem(4);
}
}
@media (max-width: toRem(76.8)) {
.uploaded-pictures,
.errors-pictures {
width: 100%;
padding: 0;
}
}
@media (max-width: toRem(50)) {
.imported {
flex-direction: column;
}
.uploaded-error-list {
padding-left: 0;
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<li :class="['uploaded-picture-item', look]">
<div class="uploaded-information">
<span v-if="itemUploadedText">{{ itemUploadedText }}</span>
</div>
<slot></slot>
</li>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps({
name: { type: String, default: '' },
text: { type: String, default: '' },
look: {
type: String,
validator: (value: string): boolean =>
['error', 'success', 'default'].includes(value),
default: 'default'
}
})
const itemUploadedText = computed<string | null>(
() => `${props.name} - ${props.text}`
)
</script>
<style scoped lang="scss">
.uploaded-picture-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: toRem(1) toRem(2);
margin-top: toRem(1);
border-radius: toRem(0.5);
width: 100%;
@include text(xs-r-regular);
}
.success {
background-color: var(--white);
border: toRem(0.1) solid var(--grey);
color: var(--black);
}
.error {
background-color: var(--white);
border: toRem(0.1) solid var(--red-pale);
color: var(--red);
}
.uploaded-information {
margin-right: toRem(1);
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div v-if="isDisplayed" class="tooltip-banner">
<div class="top-banner">
<span
><i class="bi bi-info-circle"></i>
{{ $t('pages.upload.tooltip_banner_title') }}</span
>
<Button look="no-text" icon="bi bi-x-lg" @trigger="isDisplayed = false" />
</div>
<p class="text-banner">{{ $t('pages.upload.tooltip_banner_text') }}</p>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import Button from '@/components/Button.vue'
let isDisplayed = ref<boolean>(true)
</script>
<style lang="scss" scoped>
.tooltip-banner {
border-left: toRem(1) solid var(--blue);
border-radius: toRem(1);
padding: toRem(2);
background-color: var(--white);
}
.top-banner {
@include text(h2);
display: flex;
justify-content: space-between;
margin-bottom: toRem(0.5);
color: var(--blue-dark);
}
.text-banner {
@include text(s-regular);
margin-bottom: 0;
margin-right: 3rem;
color: var(--grey-semi-dark);
}
@media (max-width: toRem(76.8)) {
.text-banner {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="wrapper-loader">
<div class="loader">
<span class="loader-percentage">{{ loadPercentage }}</span>
<Loader look="lg" :is-loaded="isLoaded" />
</div>
<div v-if="loadPercentage === '100%'" class="wrapper-button-new-upload">
<Button
:text="$t('pages.upload.button_new_upload')"
look="button button--blue"
@trigger="$emit('triggerNewUpload')"
/>
</div>
<div v-if="uploadedSequences[0]" class="loader-information">
<span class="loader-title">{{ uploadPendingTitle }}</span>
<span v-if="loadPercentage !== '100%'" class="loader-text">{{
$t('pages.upload.upload_pending_pictures', { count: picturesCount })
}}</span>
<span class="loader-text-size"
>{{ uploadedSequences[0].pictureSize }}/{{ loadTextSize }}
</span>
<span class="loader-text-warning">{{
$t('pages.upload.leave_message')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import Loader from '@/components/Loader.vue'
import Button from '@/components/Button.vue'
import { computed } from 'vue'
import type { PropType } from 'vue'
import type { sequenceInterface } from '@/views/interfaces/UploadPicturesView'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
loadPercentage: { type: String, default: '0%' },
loadTextSize: { type: String, default: '0 Mo' },
isLoaded: { type: Boolean, default: false },
uploadedSequences: {
type: Array as PropType<sequenceInterface[]>,
default: []
},
picturesCount: { type: Number, default: null }
})
const uploadPendingTitle = computed<string>(() => {
if (props.loadPercentage !== '100%') return t('pages.upload.upload_pending')
return t('pages.upload.upload_done')
})
</script>
<style scoped lang="scss">
.wrapper-loader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.loader {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.loader-percentage {
width: toRem(10);
position: absolute;
text-align: center;
@include text(h1);
color: var(--blue);
}
.wrapper-button-new-upload {
display: flex;
justify-content: center;
}
.loader-information {
margin-top: toRem(2);
display: flex;
flex-direction: column;
}
.loader-title,
.loader-text,
.loader-text-size {
text-align: center;
@include text(s-regular);
}
.loader-title {
@include text(h2);
margin-bottom: toRem(0.5);
}
.loader-text {
color: var(--blue);
border-bottom: toRem(0.1) solid var(--grey);
}
.loader-text-size {
color: var(--grey-semi-dark);
}
.loader-text-warning {
text-align: center;
@include text(s-regular);
color: var(--orange);
margin-top: toRem(1);
width: toRem(31);
}
</style>

View File

@@ -1,15 +1,16 @@
{
"general": {
"title": "Instance Panoramax IGN",
"title": "Instance Panoramax",
"meta": {
"title": "Instance Panoramax IGN",
"title": "Instance Panoramax",
"description": "Panoramax, lalternative libre pour photo-cartographier les territoires"
},
"header": {
"contribute_text": "Partager vos photos",
"contribute_text": "Comment contribuer ?",
"upload_text": "Partager des photos",
"sequences_text": "Mes photos",
"alt_logo": "Logo de l'instance",
"title": "Instance Panoramax IGN",
"title": "Panoramax",
"beta_text": "Version beta",
"logout_text": "Déconnexion",
"my_information_text": "Mes informations",
@@ -32,19 +33,25 @@
"setting_tooltip": "Afficher ou masquer le token"
},
"sequence": {
"title": "Séquence :",
"sequence_published": "Séquence publiée",
"sequence_waiting": "En cours de publication",
"sequence_hidden": "Séquence masquée",
"hide_sequence_tooltip": "Masque la séquence sur la carte",
"delete_sequence_tooltip": "Supprime définitivement la séquence",
"hide_photo_tooltip": "Masque les photos sur la carte",
"delete_photo_tooltip": "Supprime définitivement les photo",
"confirm_dialog": "Les photos sélectionnées vont être définitivement supprimées",
"delete_photo_tooltip": "Supprime définitivement les photos",
"confirm_pictures_dialog": "⚠️ Les photos sélectionnées vont être définitivement supprimées",
"confirm_sequence_dialog": "⚠️ La séquence va être définitivement supprimée",
"created": "Versement :",
"taken": "Prise de vue :",
"duration": "Durée :",
"duration_begin": "Début :",
"duration_end": "Fin :",
"camera": "Matériel :",
"button_delete": "Supprimer",
"button_disable": "Masquer",
"button_enable": "Afficher",
"picture_selected": "{count} photo sélectionnée| {count} photos sélectionnées",
"hours": "{count} heure| {count} heures",
"minutes": "{count} minute| {count} minutes",
"seconds": "{count} seconde| {count} secondes",
@@ -64,16 +71,24 @@
"sequence_waiting": "⌛ En cours de publication",
"sequence_hidden": "❌ Masquée",
"no_sequences_text": "Vous n'avez pas encore de photos publiées \uD83D\uDE22",
"button_upload": "Partager vos photos"
"button_upload": "Partager vos photos",
"sequence_deleted": "La séquence a bien été supprimée"
},
"upload": {
"title": "Partagez vos photos",
"sub_title": "Un compte utilisateur est obligatoire pour partager des photos",
"photo_type1": "Des lieux visibles depuis la voie publique",
"photo_type2": "360° ou non",
"photo_type3": "Vues du sol",
"photo_type4": "Géolocalisées",
"description": "Ici, vos photos sont accessibles à tous :\n\n{check} automatiquement floutées dans le respect de <a href='https://panoramax.fr/foire-aux-questions' target='_blank' style='color:black'>la législation</a>\n{check} libres de droit, <a href='https://www.etalab.gouv.fr/licence-ouverte-open-licence/' target='_blank' style='color:black'>sous licence ouverte</a>\n{check} sous forme «brute» pour des réutilisations variées (ex: préparation des chantiers)\n\n",
"share_pictures": {
"title": "Pourquoi partager ses photos sur Panoramax ?",
"description": "Avec Panoramax, vos photos sont accessibles à tous. Elles seront automatiquement floutées grâce à notre algorithme de floutage dans le respect de <a href='https://panoramax.fr/foire-aux-questions' target='_blank' style='color:black'>la législation</a> et libres de droit.\nLa mise en ligne se fera <a href='https://www.etalab.gouv.fr/licence-ouverte-open-licence/' target='_blank' style='color:black'>sous licence ouverte</a> sous forme «brute» pour des réutilisations variées (ex: préparation des chantiers, gestion de la voirie etc).",
"arg_title1": "Des lieux visibles depuis la voie publique",
"arg_title2": "Une gestion des photos au format 360° ou non",
"arg_title3": "Une fonctionnalité de visualisation de vue du sol",
"arg_title4": "Des photos automatiquement géolocalisées",
"arg_text1": "Le lorem ipsum est, en imprimerie, une suite de mots sans signification utilisée à titre provisoire pour calibrer une mise en page, le texte définitif venant remplacer le faux-texte dès qu'il est prêt ou que la",
"arg_text2": "Le lorem ipsum est, en imprimerie, une suite de mots sans signification utilisée à titre provisoire pour calibrer une mise en page, le texte définitif venant remplacer le faux-texte dès qu'il est prêt ou que la",
"arg_text3": "Le lorem ipsum est, en imprimerie, une suite de mots sans signification utilisée à titre provisoire pour calibrer une mise en page, le texte définitif venant remplacer le faux-texte dès qu'il est prêt ou que la",
"arg_text4": "Le lorem ipsum est, en imprimerie, une suite de mots sans signification utilisée à titre provisoire pour calibrer une mise en page, le texte définitif venant remplacer le faux-texte dès qu'il est prêt ou que la",
"arg_alt1": "Image d'un immeuble",
"arg_alt2": "Image d'un signe 360",
"arg_alt3": "Image d'un pointer de localisation sur une carte",
"arg_alt4": "Image d'un pointer de localisation",
"footer_block": "⚠️️️ Aujourd'hui, le versement de grands volumes d'images est possible via une ligne de commande. Bientôt, d'autres moyens de versement seront disponibles, notamment via une interface web.",
"button": "Accéder à l'outil",
"alt_img_upload": "Image qui représente plusieurs photos en cours de téléchargement",
@@ -83,8 +98,33 @@
"user_account_button": "Créer un compte",
"description_terminal": "<a href='https://gitlab.com/geovisio/cli' target='_blank' style='color:black'>L'outil en ligne de commande</a> vous permet de partager de grands volumes de photos. La procédure est simple et vous devez disposer <a target='_blank' href='https://www.python.org/downloads/' style='color:black'>de python (au moins la version 3.8)</a>.\n\n1. Installer loutil en ligne de commande geovisio\n2. Lancez la commande de versement dimages sur le dossier choisi. Après '--api-url', renseignez l'url de l'api de l'instance où partager les photos et le chemin vers votre dossier de photos sur votre machine. Loutil demandera vos informations de connexion avant l'import. Une fois les données chargées, un temps de traitement est nécessaire pour les rendre disponibles.",
"terminal_install": "pip install geovisio_cli",
"terminal_text": "geovisio upload --api-url https://panoramax.ign.fr <DOSSIER_PHOTOS>",
"terminal_text": "geovisio upload --api-url {url} <DOSSIER_PHOTOS>",
"button_copy": "Copier"
},
"upload": {
"title": "Déposez vos photos",
"title_uploading": "Traitement des images",
"text": "Déposez vos fichiers dans cet espace. Chaque photo ou groupe de photos envoyé constituera une 'séquence'. Vous pourrez retrouver ensuite toutes vos séquences dans la section 'Mes images'. Déposez vos fichiers dans cet espace. Chaque photo ou groupe de photos envoyé",
"no_picture_uploading_text": "Aucune photo en cours de téléchargement actuellement",
"tooltip_banner_title": "Contribuez à la base de photos de Panoramax",
"tooltip_banner_text": "Déposez vos fichiers dans cet espace. Chaque photo ou groupe de photos envoyé constituera une 'séquence'. Vous pourrez retrouver ensuite toutes vos séquences dans la section 'Mes images'.",
"input_label": "Glissez vos images ici ou cliquez sur ",
"import_word": "importer",
"import_type": "Format JPEG uniquement",
"sequence_title": "Séquence du ",
"button_text": "Télécharger",
"uploaded_files": "{count} fichier| {count} fichiers",
"no_uploaded_files": "Aucun fichier sélectionné",
"uploaded_word": " Image téléchargée",
"import": "Imports",
"error_word": "Images en erreur",
"sequence_uploading_title": "Dernier import",
"upload_pending": "Transfert en cours...",
"upload_done": "Transfert terminé !",
"upload_pending_pictures": "Envoi de {count} photo en cours |Envoi de {count} photos en cours",
"sequence_link": "Voir la sequence",
"button_new_upload": "Nouveau téléchargement",
"leave_message": "⚠️ Attention, le téléchargement sera interrompu si vous quittez la page avant la fin."
}
}
}

View File

@@ -6,11 +6,13 @@ import axios from 'axios'
import VueAxios from 'vue-axios'
import { globalCookiesConfig } from 'vue3-cookies'
import { createMetaManager } from 'vue-meta'
import { pinia } from './store'
import fr from './locales/fr.json'
import './assets/main.css'
import './assets/main.scss'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap/dist/js/bootstrap.js'
import 'bootstrap-icons/font/bootstrap-icons.css'
import 'geovisio/build/index.css'
axios.defaults.baseURL = import.meta.env.VITE_API_URL
axios.defaults.withCredentials = true
@@ -31,6 +33,7 @@ globalCookiesConfig({
const app = createApp(App)
app.use(pinia)
app.use(i18n)
app.use(router)
app.use(VueAxios, axios)

View File

@@ -1,13 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useCookies } from 'vue3-cookies'
import type { RouteRecordRaw } from 'vue-router'
import type {
RouteRecordRaw,
NavigationGuardNext,
RouteLocationNormalized
} from 'vue-router'
import axios from 'axios'
import { getAuthRoute } from '@/utils/auth'
import HomeView from '../views/HomeView.vue'
import MyInformationView from '../views/MyInformationView.vue'
import MySettingsView from '../views/MySettingsView.vue'
import MySequencesView from '../views/MySequencesView.vue'
import MySequenceView from '../views/MySequenceView.vue'
import UploadView from '../views/UploadView.vue'
import SharePicturesView from '../views/SharePicturesView.vue'
import UploadPicturesView from '../views/UploadPicturesView.vue'
const { cookies } = useCookies()
const routes: Array<RouteRecordRaw> = [
{
@@ -33,28 +39,60 @@ const routes: Array<RouteRecordRaw> = [
{ path: '/sequence/:id', name: 'sequence', component: MySequenceView },
{
path: '/partager-des-photos',
name: 'upload',
component: UploadView
name: 'share-pictures',
component: SharePicturesView
},
{
path: '/telecharger',
name: 'upload-pictures',
component: UploadPicturesView
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to, from, next) => {
const loggedRoutes =
to.name === 'my-information' ||
to.name === 'my-settings' ||
to.name === 'my-sequences' ||
to.name === 'sequence'
if (loggedRoutes) {
const isSiteLogin = !!cookies.get('user_id')
if (!isSiteLogin) {
next((window.location.href = getAuthRoute('auth/login', to.path)))
} else {
next()
router.beforeResolve(
async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const siteLoggedRoutes =
to.name === 'my-settings' ||
to.name === 'my-sequences' ||
to.name === 'sequence' ||
to.name === 'upload-pictures'
if (siteLoggedRoutes) {
if (!isSiteLogged()) goToLoginPage(to.path)
else return next()
}
} else next()
})
if (to.name === 'my-information') {
try {
const keycloakLogout = await isKeycloakLogout()
if (keycloakLogout.status >= 300 || !isSiteLogged()) {
return goToLoginPage(to.path)
} else return next()
} catch (e) {
return goToLoginPage(to.path)
}
}
next()
}
)
function isSiteLogged(): boolean {
return !!cookies.get('user_id')
}
async function isKeycloakLogout(): Promise<{ status: number }> {
const loginUrl = `/api/users/me`
return await axios.get(loginUrl)
}
function goToLoginPage(path: string): void {
window.location.replace(getAuthRoute('auth/login', path))
}
export default router

2
src/store/index.ts Normal file
View File

@@ -0,0 +1,2 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()

17
src/store/sequence.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
export const useSequenceStore = defineStore('sequence', {
state: () => ({
toastText: <string>'',
toastLook: <string>''
}),
actions: {
addToastText(text: string, look: string): void {
this.toastText = text
this.toastLook = look
setTimeout(() => {
this.toastText = ''
}, 3000)
}
}
})

View File

@@ -1,5 +1,5 @@
describe('In the home page', () => {
it('click on the link in the header to go to the upload page', () => {
it('click on the link in the header to go to the utils page', () => {
cy.visit('/')
cy.fixture('home').then((homeData) => {
cy.contains(homeData.textLinkUpload).click()

View File

@@ -1,4 +1,4 @@
describe('In the upload page', () => {
describe('In the utils page', () => {
it('go to the login page', () => {
cy.visit('partager-des-photos')
cy.fixture('upload').then((uploadData) => {

View File

@@ -1,5 +1,5 @@
{
"addressToSearch": "97 boulevard Voltaire 75011 paris",
"textAddressToSelect": "Boulevard Voltaire, Quartier de la Folie-Méricourt, Paris 11e Arrondissement, Paris, Île-de-France, France métropolitaine, 75011, France",
"textLinkUpload": "Partager vos photos"
"textLinkUpload": "Aide"
}

View File

@@ -0,0 +1,127 @@
import { test, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Button from '../../../components/Button.vue'
import i18n from '../config'
describe('Template', () => {
describe('Props', () => {
test('should have default props', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
}
})
expect(wrapper.vm.icon).toBe(null)
expect(wrapper.vm.disabled).toBe(false)
expect(wrapper.vm.isLoading).toBe(false)
expect(wrapper.vm.text).toBe('')
expect(wrapper.vm.tooltip).toBe('')
expect(wrapper.vm.look).toBe('')
expect(wrapper.vm.type).toBe('button')
})
})
describe('When the component is disabled', () => {
test('should render the button with disabled class', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
disabled: true
}
})
expect(wrapper.html()).contains('class="default disabled"')
})
})
describe('When the component is loading', () => {
test('should render the button with disabled class', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
isLoading: true
}
})
expect(wrapper.html()).contains('class="default disabled"')
})
})
describe('When the component have an icon', () => {
test('should render the button the icon displayed', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
icon: 'my-icon'
}
})
expect(wrapper.html()).contains('<i')
expect(wrapper.html()).contains('class="my-icon icon"')
})
})
describe('When the component have a type submit', () => {
test('should render the type to submit', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
type: 'submit'
}
})
expect(wrapper.html()).contains('type="submit"')
})
})
describe('When the component have tooltip', () => {
test('should render the button with a tooltip', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
tooltip: 'my tooltip'
}
})
expect(wrapper.html()).contains(
'class="tooltip-button">my tooltip</span>'
)
})
})
describe('When the component have a text', () => {
test('should render the button with a text', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
text: 'My text'
}
})
expect(wrapper.html()).contains('class="text">My text</span>')
})
})
describe('When the component have specific look', () => {
test('should render the button with a specific class', () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
},
props: {
look: 'my--look'
}
})
expect(wrapper.html()).contains('class="my--look default"')
})
})
describe('When the button is trigger', () => {
test('should emit', async () => {
const wrapper = shallowMount(Button, {
global: {
plugins: [i18n]
}
})
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('trigger')
})
})
})

View File

@@ -5,6 +5,9 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useCookies } from 'vue3-cookies'
import fr from '../../../locales/fr.json'
import Header from '../../../components/Header.vue'
import MyInformation from '../../../views/MyInformationView.vue'
import MySettings from '../../../views/MySettingsView.vue'
import SharePictures from '../../../views/SharePicturesView.vue'
vi.mock('vue-router')
vi.mock('vue3-cookies', () => {
const mockCookies = {
@@ -80,26 +83,6 @@ describe('Template', () => {
expect(wrapper.html()).contains('general.header.sequences_text')
expect(wrapper.html()).contains('general.header.my_settings_text')
})
it('should render the component with all links', async () => {
vi.spyOn(useCookies().cookies, 'get').mockReturnValue('user_id=id')
import.meta.env.VITE_API_URL = 'api-url/'
const wrapper = shallowMount(Header, {
props: {
authEnabled: true,
userProfileUrl: 'profil'
},
global: {
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('auth/logout')
expect(wrapper.html()).contains('path="/mes-informations"')
expect(wrapper.html()).contains('path="/mes-parametres"')
expect(wrapper.html()).contains('path="/mes-sequences"')
})
})
})

View File

@@ -0,0 +1,159 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import ImageItem from '../../../components/ImageItem.vue'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.vm.created).toBe(null)
expect(wrapper.vm.href).toBe(null)
expect(wrapper.vm.hrefHd).toBe(null)
expect(wrapper.vm.selected).toBe(false)
expect(wrapper.vm.selectedOnMap).toBe(false)
expect(wrapper.vm.status).toBe('')
})
})
describe('When the component have default props filled', () => {
it('should render the component with a href, a hrefHd and a date', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
created: '10 mars 2023',
href: 'my-url',
hrefHd: 'my-url-hd',
status: 'ready'
}
})
expect(wrapper.html()).contains('<img')
expect(wrapper.html()).contains('src="my-url"')
expect(wrapper.html()).contains('<link-stub')
expect(wrapper.html()).contains('10 mars 2023')
})
})
describe('When the component is selected', () => {
it('should render the component with the selected class', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
selected: true
}
})
expect(wrapper.html()).contains('class="selected button-image-item"')
})
})
describe('When the component is ready', () => {
it('should render the component with the status class', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
href: 'my-url',
status: 'ready'
}
})
expect(wrapper.html()).contains('src="my-url"')
expect(wrapper.html()).contains('class="ready"')
})
})
describe('When the component is waiting-for-process', () => {
it('should render the component with waiting-for-process classes and elements', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
status: 'waiting-for-process',
href: 'my-url'
}
})
expect(wrapper.html()).contains('class="waiting-for-process"')
expect(wrapper.html()).contains('pages.sequence.waiting_process')
expect(wrapper.html()).contains('class="bi bi-card-image icon-waiting"')
})
})
describe('When the component is selected on the map but not selected', () => {
it('should render the component with the map pointer', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
selectedOnMap: true,
selected: false
}
})
expect(wrapper.html()).contains('class="icon-img pointer-map"')
})
})
describe('When the component is selected but not selected on the map', () => {
it('should render the component with the check icon', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
selectedOnMap: false,
selected: true
}
})
expect(wrapper.html()).contains('class="bi bi-check-lg"')
expect(wrapper.html()).contains('class="icon-img button-check"')
})
})
describe('When the component is selected and selected on the map', () => {
it('should render the component with the check icon', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
selectedOnMap: true,
selected: true
}
})
expect(wrapper.html()).contains('class="icon-img button-check-pointer"')
expect(wrapper.html()).contains('class="bi bi-check-lg"')
})
})
describe('When the button is trigger', () => {
it('should emit', () => {
const wrapper = shallowMount(ImageItem, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
wrapper.vm.$emit('trigger')
expect(wrapper.emitted().trigger).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,99 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import InputCheckbox from '../../../components/InputCheckbox.vue'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.vm.name).toBe(null)
expect(wrapper.vm.label).toBe('')
expect(wrapper.vm.isChecked).toBe(false)
expect(wrapper.vm.isIndeterminate).toBe(false)
})
})
describe('When the component have a label', () => {
it('should render the component with a href, a hrefHd and a date', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
label: 'my label'
}
})
expect(wrapper.html()).contains('class="label"')
expect(wrapper.html()).contains('my label')
})
})
describe('When the component is checked', () => {
it('should render the component with the checked icon', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
isChecked: true,
isIndeterminate: false
}
})
expect(wrapper.html()).contains('class="icon bi bi-check-square"')
})
})
describe('When the component is not checked', () => {
it('should render the component with the not checked icon', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
isChecked: false,
isIndeterminate: false
}
})
expect(wrapper.html()).contains('class="icon bi bi-square"')
})
})
describe('When the component is not checked', () => {
it('should render the component with the not checked icon', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
isChecked: false,
isIndeterminate: true
}
})
expect(wrapper.html()).contains('class="icon bi bi-dash-square"')
})
})
describe('When the input is trigger', () => {
it('should emit', () => {
const wrapper = shallowMount(InputCheckbox, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
wrapper.vm.$emit('trigger', true)
expect(wrapper.emitted().trigger).toBeTruthy()
expect(wrapper.emitted().trigger[0][0]).toEqual(true)
})
})
})

View File

@@ -25,35 +25,6 @@ const stubs = {
}
}
describe('Template', () => {
describe('Snapshot', () => {
test('Should match snapshot with external link', () => {
const wrapper = mount(Link, {
global: {
stubs,
plugins: [i18n]
},
props: {
type: 'external',
text: 'My-text',
path: 'my-path',
target: '_blank'
}
})
expect(wrapper.element).toMatchSnapshot()
})
test('Should match snapshot with internal link', () => {
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n, router]
},
props: {
text: 'My-text',
path: 'my-path'
}
})
expect(wrapper.element).toMatchSnapshot()
})
})
describe('Props', () => {
test('should have default props', () => {
const wrapper = shallowMount(Link, {
@@ -63,7 +34,8 @@ describe('Template', () => {
})
expect(wrapper.vm.text).toBe(null)
expect(wrapper.vm.path).toBe('')
expect(wrapper.vm.route).toStrictEqual({})
expect(wrapper.vm.pathExternal).toBe('')
expect(wrapper.vm.look).toBe('')
expect(wrapper.vm.type).toBe(null)
expect(wrapper.vm.alt).toBe('')

View File

@@ -0,0 +1,45 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Loader from '../../../components/Loader.vue'
import i18n from '../config'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(Loader, {
global: {
plugins: [i18n]
}
})
expect(wrapper.vm.text).toBe(null)
expect(wrapper.vm.look).toBe('sm')
expect(wrapper.vm.isLoaded).toBe(true)
})
})
describe('When the component is loading', () => {
it('should not have the loaded class', () => {
const wrapper = shallowMount(Loader, {
global: {
plugins: [i18n]
},
props: {
isLoaded: false
}
})
expect(wrapper.html()).contains('class="lds-ring sm"')
})
})
describe('When the component have a look', () => {
it('should have a look classe', () => {
const wrapper = shallowMount(Loader, {
global: {
plugins: [i18n]
},
props: {
look: 'md'
}
})
expect(wrapper.html()).contains('class="lds-ring md loaded"')
})
})
})

View File

@@ -0,0 +1,75 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Pagination from '../../../components/Pagination.vue'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(Pagination, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.vm.href).toBe(null)
expect(wrapper.vm.selfLink).toStrictEqual({})
expect(wrapper.vm.type).toBe('')
})
})
describe('When the component is the last or the first page', () => {
it('should render the component without border', () => {
const wrapper = shallowMount(Pagination, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
type: 'double-left'
}
})
expect(wrapper.html()).contains('class="wrapper-pagination no-border"')
})
})
describe('When the component is disabled', () => {
it('should render the component a disabled state', () => {
const href = 'my-url'
const wrapper = shallowMount(Pagination, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
type: 'double-left',
href,
selfLink: { href }
}
})
expect(wrapper.html()).contains('class="pagination-button disabled"')
})
})
describe('When the button is trigger', () => {
it('should emit', () => {
const href = 'my-url'
const wrapper = shallowMount(Pagination, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
type: 'left',
href,
selfLink: { href: 'self-link' }
}
})
wrapper.vm.$nextTick()
wrapper.vm.$emit('trigger', href)
expect(wrapper.emitted().trigger).toBeTruthy()
expect(wrapper.emitted().trigger[0][0]).toEqual(href)
})
})
})

View File

@@ -26,7 +26,7 @@ describe('Template', () => {
}
}
})
expect(wrapper.html()).contains('pages.upload.button_copy')
expect(wrapper.html()).contains('pages.share_pictures.button_copy')
})
it('should render the view with the button', () => {
const wrapper = shallowMount(Terminal, {

View File

@@ -0,0 +1,68 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import Toast from '../../../components/Toast.vue'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(Toast, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.vm.text).toBe('')
expect(wrapper.vm.look).toStrictEqual('')
})
})
describe('When the component have look and text filled', () => {
it('should render the component with the error and the display class', () => {
const wrapper = shallowMount(Toast, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
text: 'my text error',
look: 'error'
}
})
expect(wrapper.html()).contains('class="toast-wrapper error display"')
expect(wrapper.html()).contains('class="bi bi-exclamation-triangle"')
expect(wrapper.html()).contains('my text error')
})
it('should render the component with the success and the display class', () => {
const wrapper = shallowMount(Toast, {
global: {
mocks: {
$t: (msg) => msg
}
},
props: {
text: 'my text success',
look: 'success'
}
})
expect(wrapper.html()).contains('class="toast-wrapper success display"')
expect(wrapper.html()).contains('class="bi bi-check-circle"')
expect(wrapper.html()).contains('my text success')
})
})
describe('When the button is trigger', () => {
it('should emit', () => {
const wrapper = shallowMount(Toast, {
global: {
mocks: {
$t: (msg) => msg
}
}
})
wrapper.vm.$emit('trigger')
expect(wrapper.emitted().trigger).toBeTruthy()
})
})
})

View File

@@ -1,32 +0,0 @@
// Vitest Snapshot v1
exports[`Template > Snapshot > Should match snapshot with external link 1`] = `
<a
class="default"
data-v-409c8661=""
href="my-path"
target="_blank"
title=""
>
<!--v-if-->
<!--v-if-->
<span
class="text"
data-v-409c8661=""
>
My-text
</span>
</a>
`;
exports[`Template > Snapshot > Should match snapshot with internal link 1`] = `
<router-link-stub
ariacurrentvalue="page"
class="default"
custom="false"
data-v-409c8661=""
replace="false"
title="My-text"
to="my-path"
/>
`;

View File

@@ -0,0 +1,111 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import ImportedSection from '../../../../components/upload/ImportedSection.vue'
import i18n from '../../config'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
}
})
expect(wrapper.vm.index).toBe(0)
expect(wrapper.vm.sequence).toStrictEqual({})
expect(wrapper.vm.picturesCount).toBe(null)
expect(wrapper.vm.uploadErrors).toStrictEqual([])
expect(wrapper.vm.uploadPictures).toStrictEqual([])
})
})
describe('When the index is 0', () => {
it('should have a specific class and wordings', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
},
props: {
index: 0
}
})
expect(wrapper.html()).contains(
'class="information-section first-sequence"'
)
expect(wrapper.html()).contains('Dernier impor')
})
})
describe('When the sequence have pictures', () => {
it('should have a title', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
},
props: {
sequence: {
id: 'id132435',
title: 'my title',
pictureCount: 3,
pictures: [{ id: 'id' }]
}
}
})
expect(wrapper.html()).contains('class="uploaded-title"')
expect(wrapper.html()).contains('Imports my title - 1/3')
})
it('should have a Link', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
},
props: {
sequence: {
id: 'id132435',
title: 'my title',
pictureCount: 3,
pictures: [{ id: 'id' }]
}
}
})
expect(wrapper.html()).contains('<link-stub')
expect(wrapper.html()).contains('text="Voir la sequence"')
})
})
describe('When there are uploaded pictures', () => {
it('should have a list of pictures', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
},
props: {
uploadPictures: [{ name: 'my name1' }, { name: 'my name2' }]
}
})
expect(wrapper.html()).contains('name="my name1"')
expect(wrapper.html()).contains('name="my name2"')
expect(wrapper.html()).contains('text=" Image téléchargée"')
expect(wrapper.html()).contains('look="success"')
expect(wrapper.html()).contains('<picture-item-stub')
})
})
describe('When there are uploaded errors', () => {
it('should have a list of errors', () => {
const wrapper = shallowMount(ImportedSection, {
global: {
plugins: [i18n]
},
props: {
uploadErrors: [
{ name: 'my name1', message: 'my message1' },
{ name: 'my name2', message: 'my message2' }
]
}
})
expect(wrapper.html()).contains('name="my name1"')
expect(wrapper.html()).contains('name="my name2"')
expect(wrapper.html()).contains('text="my message1"')
expect(wrapper.html()).contains('text="my message2"')
expect(wrapper.html()).contains('look="error"')
expect(wrapper.html()).contains('<picture-item-stub')
})
})
})

View File

@@ -0,0 +1,47 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import PictureItem from '../../../../components/upload/PictureItem.vue'
import i18n from '../../config'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(PictureItem, {
global: {
plugins: [i18n]
}
})
expect(wrapper.vm.name).toBe('')
expect(wrapper.vm.text).toStrictEqual('')
expect(wrapper.vm.look).toBe('default')
})
})
describe('When the component have a look', () => {
it('should have a specific class', () => {
const wrapper = shallowMount(PictureItem, {
global: {
plugins: [i18n]
},
props: {
look: 'success'
}
})
expect(wrapper.html()).contains('class="uploaded-picture-item success"')
})
})
describe('When the component have a name and a text', () => {
it('should have a specific class', () => {
const wrapper = shallowMount(PictureItem, {
global: {
plugins: [i18n]
},
props: {
name: 'name',
text: 'text'
}
})
expect(wrapper.html()).contains('class="uploaded-information"')
expect(wrapper.html()).contains('name - text')
})
})
})

View File

@@ -0,0 +1,68 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import UploadLoader from '../../../../components/upload/UploadLoader.vue'
import i18n from '../../config'
describe('Template', () => {
describe('Props', () => {
it('should have default props', () => {
const wrapper = shallowMount(UploadLoader, {
global: {
plugins: [i18n]
}
})
expect(wrapper.vm.loadPercentage).toBe('0%')
expect(wrapper.vm.loadTextSize).toBe('0 Mo')
expect(wrapper.vm.isLoaded).toBe(false)
expect(wrapper.vm.picturesCount).toBe(null)
expect(wrapper.vm.uploadedSequences).toStrictEqual([])
})
})
describe('When the component have a percentage equal to 100%', () => {
it('should have a button to do a new upload', () => {
const wrapper = shallowMount(UploadLoader, {
global: {
plugins: [i18n]
},
props: {
loadPercentage: '100%'
}
})
expect(wrapper.html()).contains('button-stub')
expect(wrapper.html()).contains('text="Nouveau téléchargement"')
})
})
describe('When the component have an uploaded sequence', () => {
it('should render the uploaded sequences information', () => {
const wrapper = shallowMount(UploadLoader, {
global: {
plugins: [i18n]
},
props: {
uploadedSequences: [{ pictureSize: '2345 Mo' }],
loadTextSize: '2345 Mo',
loadPercentage: '97%'
}
})
expect(wrapper.html()).contains('Transfert en cours...')
expect(wrapper.html()).contains('2345 Mo/2345 Mo')
})
describe('When the loading is completed', () => {
it('should render the loading ended information', () => {
const wrapper = shallowMount(UploadLoader, {
global: {
plugins: [i18n]
},
props: {
uploadedSequences: [{ pictureSize: '2345 Mo' }],
loadTextSize: '2345 Mo',
loadPercentage: '100%'
}
})
expect(wrapper.html()).contains('text="Nouveau téléchargement"')
expect(wrapper.html()).contains('Transfert terminé !')
expect(wrapper.html()).contains('2345 Mo/2345 Mo')
})
})
})
})

14
src/tests/unit/config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createI18n } from 'vue-i18n'
import fr from '../../locales/fr.json'
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
globalInjection: true,
legacy: false,
messages: {
fr
}
})
export default i18n

View File

@@ -0,0 +1,183 @@
import { it, describe, expect } from 'vitest'
import {
imageStatus,
photoToDeleteOrPatchSelected,
spliceIntoChunks,
formatPaginationItems
} from '../../views/utils/sequence/index'
import {
formatPictureSize,
formatTextSize
} from '../../views/utils/upload/index'
import { getAuthRoute } from '../../utils/auth'
import { img, getPicId } from '../../utils/image'
import title from '../../utils/index'
describe('imageStatus', () => {
it('should render the "status" value', () => {
const sequenceStatus = 'hidden'
const imgStatus = 'not hidden'
expect(imageStatus(imgStatus, sequenceStatus)).toEqual('hidden')
})
it('should render the "sequenceStatus" value', () => {
const sequenceStatus = 'not hidden'
const status = 'hidden'
expect(imageStatus(status, sequenceStatus)).toEqual('hidden')
})
})
describe('photoToDeleteOrPatchSelected', () => {
it('should render true', () => {
const imagesToDelete = ['1', '2']
const item = {
assets: { thumb: { href: '' }, hd: { href: '' } },
properties: { created: new Date(), 'geovisio:status': '' },
id: '1',
bbox: [1, 3]
}
expect(photoToDeleteOrPatchSelected(item, imagesToDelete)).toEqual(true)
})
it('should render false', () => {
const imagesToDelete = ['1', '2']
const item = {
assets: { thumb: { href: '' }, hd: { href: '' } },
properties: { created: new Date(), 'geovisio:status': '' },
id: '3',
bbox: [1, 3]
}
expect(photoToDeleteOrPatchSelected(item, imagesToDelete)).toEqual(false)
})
})
describe('spliceIntoChunks', () => {
it('should render an chunked array of array with 4 elements max', () => {
const array = ['123', '345', '6777', '0000', '66666', '222222', '9393888']
const chunkSize = 4
expect(spliceIntoChunks(array, chunkSize)).toEqual([
['123', '345', '6777', '0000'],
['66666', '222222', '9393888']
])
})
})
describe('formatPaginationItems', () => {
it('should render the "rel" links formated and without the "left" element', () => {
const links = [
{
href: 'http://localhost:5000/api/',
rel: 'root',
title: 'Instance catalog',
type: 'application/json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff5-4d70-88fa-6b2be3357709',
rel: 'parent',
type: 'application/json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff5-4d70-88fa-6b2be3357709/items?limit=100',
rel: 'self',
type: 'application/geo+json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff5-4d70-88fa-6b2be3357709/items?limit=100',
rel: 'first',
type: 'application/geo+json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff…a-6b2be3357709/items?limit=100&startAfterRank=100',
rel: 'next',
type: 'application/geo+json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff…-6b2be3357709/items?limit=100&startAfterRank=1023',
rel: 'last',
type: 'application/geo+json'
}
]
expect(formatPaginationItems(links)).toEqual([
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff5-4d70-88fa-6b2be3357709/items?limit=100',
rel: 'double-left',
type: 'application/geo+json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff…a-6b2be3357709/items?limit=100&startAfterRank=100',
rel: 'right',
type: 'application/geo+json'
},
{
href: 'http://localhost:5000/api/collections/076e04c2-5ff…-6b2be3357709/items?limit=100&startAfterRank=1023',
rel: 'double-right',
type: 'application/geo+json'
}
])
})
})
describe('formatPictureSize', () => {
it('should render the size number', () => {
const size = 560673
expect(formatPictureSize(size)).toEqual(1)
})
})
describe('formatTextSize', () => {
const size = 2260121
it('should render the size text formated in ko', () => {
expect(formatTextSize(size, 1)).toEqual('2207.15 Ko')
})
it('should render the size text formated in mo', () => {
expect(formatTextSize(size, 2)).toEqual('2.16 Mo')
})
it('should render the size text formated in go', () => {
expect(formatTextSize(size, 3)).toEqual('0 Go')
})
})
describe('getAuthRoute', () => {
it('should render auth route', () => {
import.meta.env.VITE_API_URL = 'my-url/'
const authRoute = 'auth'
const nextRoute = 'mes-sequences'
const returnedRoute = `${
import.meta.env.VITE_API_URL
}api/${authRoute}?next_url=${encodeURIComponent(
`${location.protocol}//${location.host}${nextRoute}`
)}`
expect(getAuthRoute(authRoute, nextRoute)).toEqual(returnedRoute)
})
})
describe('img', () => {
it('should render the formated img path', () => {
const name = 'my-img'
expect(img(name)).contains('src/assets/images')
})
})
describe('getPicId', () => {
it('should return the Id of the picture in the url', () => {
const url = 'http://dummy.com?pic=3205340583&test'
Object.defineProperty(window, 'location', {
value: {
href: url
},
writable: true // possibility to override
})
expect(getPicId()).toEqual('3205340583')
})
})
describe('title', () => {
it('should return the formated title with instance name', () => {
import.meta.env.VITE_INSTANCE_NAME = 'my instance'
const myTitle = 'my title'
expect(title(myTitle)).toEqual('my title my instance')
})
it('should return the formated title without instance name', () => {
import.meta.env.VITE_INSTANCE_NAME = ''
const myTitle = 'my title'
expect(title(myTitle)).toEqual('my title')
})
})

View File

@@ -1,32 +0,0 @@
import { it, describe, expect, vi, beforeEach } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import MySequenceView from '../../../views/MySequenceView.vue'
import axios from 'axios'
import { createI18n } from 'vue-i18n'
import fr from '../../../locales/fr.json'
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
globalInjection: true,
legacy: false,
messages: {
fr
}
})
describe('Template', () => {
it('should render the view without sequences', async () => {
await axios.get.mockReturnValue({ data: { links: [] } })
const wrapper = shallowMount(MySequenceView, {
global: {
plugins: [i18n],
mocks: {
$t: (msg) => msg
}
}
})
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('api/collections/1234567')
})
})

View File

@@ -6,6 +6,7 @@ import { createI18n } from 'vue-i18n'
import fr from '../../../locales/fr.json'
import Button from '../../../components/Button.vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createTestingPinia } from '@pinia/testing'
vi.mock('../../../utils/dates', () => ({
formatDate: vi.fn()
}))
@@ -50,7 +51,7 @@ describe('Template', () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: { links: [] } })
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router],
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
}
@@ -59,8 +60,22 @@ describe('Template', () => {
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('api/users/me/catalog')
expect(wrapper.vm.userSequences).toEqual([])
expect(wrapper.html()).contains('general.header.contribute_text')
expect(wrapper.html()).contains('path="/partager-des-photos"')
expect(wrapper.html()).contains('general.header.upload_text')
})
it('should render the view with a loader loading', async () => {
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
}
}
})
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('api/users/me/catalog')
expect(wrapper.vm.userSequences).toEqual([])
expect(wrapper.html()).contains('general.header.upload_text')
})
it('should render the view with a sequence in the list', async () => {
@@ -69,63 +84,56 @@ describe('Template', () => {
})
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n],
plugins: [i18n, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
}
}
})
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('api/users/me/catalog')
expect(wrapper.vm.userSequences).toEqual([mockResponseSequences[1]])
expect(wrapper.html()).contains('src="https://my-link/thumb.jpg"')
expect(wrapper.html()).contains('ma sequence 1')
expect(wrapper.html()).contains('16')
wrapper.vm.isLoading = true
await wrapper.vm.$nextTick()
expect(wrapper.html()).contains('<loader')
})
})
describe('Methods', () => {
describe('sortElements', () => {
const mockResponseSequencesToSort = [
{
href: 'https://my-link',
id: 'my-id',
rel: 'child',
'stats:items': { count: 16 },
title: 'za sequence 1',
extent: {
temporal: {
interval: [
['2030-09-22T08:03:08+00:00', '2022-09-22T08:03:08+00:00']
]
}
}
},
{
href: 'https://my-link',
id: 'my-id',
rel: 'child',
'stats:items': { count: 2 },
title: 'ma sequence 1',
extent: {
temporal: {
interval: [
['2022-09-22T08:03:08+00:00', '2022-09-22T08:03:08+00:00']
]
}
const mockResponseSequencesToSort = [
{
href: 'https://my-link',
id: 'my-id',
rel: 'child',
'stats:items': { count: 16 },
title: 'za sequence 1',
extent: {
temporal: {
interval: [['2030-09-22T08:03:08+00:00', '2022-09-22T08:03:08+00:00']]
}
}
]
beforeEach(async () => {
await axios.get.mockReturnValue({
data: { links: mockResponseSequencesToSort }
})
await flushPromises()
},
{
href: 'https://my-link',
id: 'my-id',
rel: 'child',
'stats:items': { count: 2 },
title: 'ma sequence 1',
extent: {
temporal: {
interval: [['2022-09-22T08:03:08+00:00', '2022-09-22T08:03:08+00:00']]
}
}
}
]
beforeEach(async () => {
await axios.get.mockReturnValue({
data: { links: mockResponseSequencesToSort }
})
await flushPromises()
})
describe('sortAlpha', () => {
it('should should sort sequences by title', async () => {
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router],
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
@@ -134,22 +142,24 @@ describe('Methods', () => {
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortElements')
const spy = vi.spyOn(wrapper.vm, 'sortAlpha')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-title"]'
)
await buttonWrapper.vm.$emit('trigger')
expect(spy).toHaveBeenCalledWith('alpha')
expect(spy).toHaveBeenCalledWith('title')
expect(wrapper.vm.userSequences[0]).toEqual(
mockResponseSequencesToSort[1]
)
})
})
describe('sortNum', () => {
it('should should sort sequences by number of pictures', async () => {
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router],
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
@@ -158,8 +168,9 @@ describe('Methods', () => {
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortElements')
const spy = vi.spyOn(wrapper.vm, 'sortNum')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-number"]'
)
@@ -173,7 +184,7 @@ describe('Methods', () => {
it('should should sort sequences by date', async () => {
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router],
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
@@ -182,8 +193,9 @@ describe('Methods', () => {
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortElements')
const spy = vi.spyOn(wrapper.vm, 'sortNum')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-date"]'
)

View File

@@ -46,14 +46,13 @@ describe('Template', () => {
}
})
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('api/users/me/tokens')
expect(wrapper.vm.userTokens).toEqual(mockResponseTokens)
expect(wrapper.html()).contains('•••••••••••••••••••••••••••••••')
expect(wrapper.html()).contains('icon="bi bi-eye"')
expect(wrapper.html()).contains('look="button--rounded"')
expect(wrapper.html()).contains('look="no-text"')
expect(wrapper.html()).contains(
'icon="bi bi-clipboard-plus" disabled="false" isloading="false" text="pages.upload.button_copy" tooltip="" look="button--white"'
'icon="bi bi-clipboard-plus" disabled="false" isloading="false" text="pages.share_pictures.button_copy" tooltip="" look="button--white"'
)
})
})

View File

@@ -1,6 +1,6 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import UploadView from '../../../views/UploadView.vue'
import SharePicturesView from '../../../views/SharePicturesView.vue'
import { createI18n } from 'vue-i18n'
import fr from '../../../locales/fr.json'
import { createRouter, createWebHistory } from 'vue-router'
@@ -20,7 +20,7 @@ const router = createRouter({
})
describe('Template', () => {
it('should render the view with the button link', async () => {
const wrapper = shallowMount(UploadView, {
const wrapper = shallowMount(SharePicturesView, {
global: {
plugins: [i18n, router],
mocks: {
@@ -34,12 +34,12 @@ describe('Template', () => {
expect(wrapper.html()).contains('<link')
expect(wrapper.html()).contains('path="')
expect(wrapper.html()).contains('/auth/login')
expect(wrapper.html()).contains('look="button"')
expect(wrapper.html()).contains('look="button button--blue"')
expect(wrapper.html()).contains('type="external"')
})
it('should render the view without the button link', async () => {
import.meta.env.VITE_API_URL = 'api-url/'
const wrapper = shallowMount(UploadView, {
const wrapper = shallowMount(SharePicturesView, {
global: {
plugins: [i18n, router],
mocks: {
@@ -50,6 +50,6 @@ describe('Template', () => {
}
}
})
expect(wrapper.html()).not.toContain('pages.upload.sub_title')
expect(wrapper.html()).not.toContain('pages.share_pictures.sub_title')
})
})

View File

@@ -0,0 +1,50 @@
import { it, describe, expect, vi } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import UploadPicturesView from '../../../views/UploadPicturesView.vue'
import { createRouter, createWebHistory } from 'vue-router'
import i18n from '../config'
import InputUpload from '../../../components/InputUpload.vue'
import Button from '../../../components/Button.vue'
const router = createRouter({
history: createWebHistory(),
routes: []
})
describe('Template', () => {
it('should render the view with the input upload and the good wordings', () => {
const wrapper = shallowMount(UploadPicturesView, {
global: {
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('pages.upload.title')
expect(wrapper.html()).contains('<input-upload-stub')
expect(wrapper.html()).contains('pages.upload.input_label')
expect(wrapper.html()).contains('<button-stub')
expect(wrapper.html()).contains('text="pages.upload.button_text"')
})
describe('trigger addPictures', () => {
it('should trigger to add pictures', async () => {
const wrapper = shallowMount(UploadPicturesView, {
global: {
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
},
components: {
InputUpload
}
}
})
const spy = vi.spyOn(wrapper.vm, 'addPictures')
const wrapperInputUpload = wrapper.findComponent(InputUpload)
await wrapperInputUpload.trigger('trigger')
await wrapperInputUpload.vm.$emit('trigger', [{}, {}])
expect(spy).toHaveBeenCalledTimes(1)
expect(wrapper.html()).contains('2 fichiers')
})
})
})

View File

@@ -1,6 +1,8 @@
function getAuthRoute(authRoute: string, nextRoute: string): string {
const next = `${location.protocol}//${location.host}${nextRoute}`
return `/api/${authRoute}?next_url=${encodeURIComponent(`${next}`)}`
return `${
import.meta.env.VITE_API_URL
}api/${authRoute}?next_url=${encodeURIComponent(`${next}`)}`
}
export { getAuthRoute }

View File

@@ -1,4 +1,5 @@
import moment from 'moment'
import 'moment/dist/locale/fr'
function formatDate(date: Date, formatType: string): string {
const formatDate = moment(date)

5
src/utils/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export default function title(title: string): string {
const instanceName = import.meta.env.VITE_INSTANCE_NAME
if (instanceName) return `${title} ${instanceName}`
return title
}

View File

@@ -1,20 +1,42 @@
import axios from 'axios'
import type { ViewerMapInterface } from '@/views/interfaces/common'
import type {
ViewerMapInterface,
OptionalViewerMapInterface
} from '@/views/interfaces/common'
import GeoVisio from 'geovisio'
async function fetchMapAndViewer(picId?: string) {
let params: ViewerMapInterface = {
async function fetchMapAndViewer(params?: OptionalViewerMapInterface) {
const tiles = import.meta.env.VITE_TILES
let paramsGeovisio: ViewerMapInterface = {
map: {
startWide: true,
style: await getIgnTiles(),
maxZoom: 19
}
}
if (picId) params = { ...params, picId: picId }
if (tiles) {
const style = tiles.includes('wxs.ign.fr') ? await getIgnTiles() : tiles
paramsGeovisio = {
map: {
...paramsGeovisio.map,
style
}
}
}
if (params) {
const { picId } = params
const { fetchOptions } = params
if (picId) paramsGeovisio = { ...paramsGeovisio, picId: picId }
if (fetchOptions) {
paramsGeovisio = {
...paramsGeovisio,
fetchOptions: fetchOptions
}
}
}
return new GeoVisio(
'viewer', // Div ID
`${import.meta.env.VITE_API_URL}api/search`,
params
paramsGeovisio
)
}
@@ -24,7 +46,15 @@ async function getIgnTiles(): Promise<object> {
)
data.sources.plan_ign.scheme = 'xyz'
data.sources.plan_ign.attribution = 'Données cartographiques : © IGN'
const objIndex = data.layers.findIndex(
(el: any) => el.id === 'toponyme - parcellaire - adresse'
)
data.layers[objIndex].layout = {
...data.layers[objIndex].layout,
'text-field': ['concat', ['get', 'numero'], ['get', 'indice_de_repetition']]
}
// Patch tms scheme to xyz to make it compatible for Maplibre GL JS / Mapbox GL JS
// Patch num_repetition
return data
}

View File

@@ -1,41 +0,0 @@
import axios from 'axios'
import type {
ResponseUserPhotoInterface,
ResponseUserSequenceInterface
} from '../views/interfaces/MySequenceView'
function deleteACollectionItem(
collectionId: string | string[],
itemId: string
): Promise<unknown> {
return axios.delete(`api/collections/${collectionId}/items/${itemId}`)
}
function patchACollectionItem(
isVisible: string,
collectionId: string | string[],
itemId: string
): Promise<unknown> {
return axios.patch(`api/collections/${collectionId}/items/${itemId}`, {
visible: isVisible
})
}
async function fetchCollectionItems(collectionId: string | string[]): Promise<{
data: { features: [ResponseUserPhotoInterface] }
}> {
return await axios.get(`api/collections/${collectionId}/items`)
}
async function fetchCollection(collectionId: string | string[]): Promise<{
data: ResponseUserSequenceInterface
}> {
return await axios.get(`api/collections/${collectionId}`)
}
export {
deleteACollectionItem,
patchACollectionItem,
fetchCollectionItems,
fetchCollection
}

View File

@@ -1,9 +1,4 @@
<template>
<link
rel="stylesheet"
type="text/css"
href="https://cdn.jsdelivr.net/npm/geovisio@develop/build/index.css"
/>
<main class="entry-page">
<section id="viewer" class="entry-viewer">
<div v-if="mapIsLoaded" class="entry-report-button">
@@ -51,28 +46,29 @@ onMounted(async () => {
}
})
</script>
<style scoped>
<style scoped lang="scss">
.entry-page {
display: flex;
}
.entry-viewer {
font-size: initial;
width: 100vw;
position: relative;
min-height: calc(100vh - 8rem);
min-height: calc(100vh - #{toRem(8)});
}
.gvs-has-map .entry-report-button {
display: block;
position: absolute;
right: 1rem;
top: 2rem;
right: toRem(1);
top: toRem(2);
z-index: 1;
}
.gvs-focus-map .entry-report-button {
display: none;
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.entry-page {
padding-top: 11rem;
padding-top: toRem(11);
overflow: hidden;
}
@supports (-webkit-touch-callout: none) {
@@ -86,7 +82,7 @@ onMounted(async () => {
@supports not (-webkit-touch-callout: none) {
/* CSS for other than iOS devices */
.entry-viewer {
min-height: calc(100vh - 17rem);
min-height: calc(100vh - #{toRem(17)});
}
}
}

View File

@@ -12,30 +12,30 @@ const myAccountUrl = computed<string>(
)
</script>
<style scoped>
<style lang="scss" scoped>
.entry-page {
padding-right: 4rem;
padding-left: 8rem;
padding-right: toRem(4);
padding-left: toRem(8);
}
.iframe {
width: 100%;
min-height: calc(100vh - 9rem);
min-height: calc(100vh - #{toRem(9)});
}
@media (max-width: 848px) {
@media (max-width: toRem(84.8)) {
.entry-page {
padding-top: 4rem;
padding-top: toRem(4);
}
}
@media (max-width: 768px) {
@media (max-width: toRem(76.8)) {
.entry-page {
padding-right: 2rem;
padding-left: 2rem;
padding-right: toRem(2);
padding-left: toRem(2);
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.entry-page {
padding-top: 14rem;
padding-top: toRem(14);
}
}
</style>

View File

@@ -1,9 +1,4 @@
<template>
<link
rel="stylesheet"
type="text/css"
href="https://cdn.jsdelivr.net/npm/geovisio@develop/build/index.css"
/>
<main :class="['entry-page', { 'menu-is-open': menuIsOpen }]">
<div class="button-close">
<Button
@@ -13,87 +8,123 @@
/>
</div>
<section id="viewer" class="entry-viewer"></section>
<div v-if="userSequence && !patchOrDeleteIsLoading" class="menu-right">
<div class="menu-top">
<div v-if="sequence && !isLoading" class="menu-right">
<div class="menu-top" ref="collapseMenu">
<div class="header-menu">
<button
data-bs-target="#collapseTarget"
data-bs-toggle="collapse"
class="button-collapse"
@click="onToggleHeader"
@click="toggleMenu"
>
<span :class="[sequence.status, 'sequence-status']">{{
sequenceStatus
}}</span>
<h1 class="title">
{{ $t('pages.sequence.title') }} {{ userSequence.title }}
{{ sequence.title }}
</h1>
<i :class="headerPanelIsOpen ? 'bi bi-dash' : 'bi bi-plus'"></i>
</button>
<div v-if="false" class="wrapper-button">
<div class="wrapper-button">
<div class="disable-button">
<Button
:text="$t('pages.sequence.button_disable')"
:tooltip="$t('pages.sequence.hide_sequence_tooltip')"
:text="
sequence.status === 'ready'
? $t('pages.sequence.button_disable')
: $t('pages.sequence.button_enable')
"
look="button--white"
icon="bi bi-eye"
:icon="
sequence.status === 'ready' ? 'bi bi-eye-slash' : 'bi bi-eye'
"
@trigger="patchCollection"
/>
</div>
<Button
:tooltip="$t('pages.sequence.delete_sequence_tooltip')"
:text="$t('pages.sequence.button_delete')"
look="button--red"
icon="bi bi-trash"
@trigger="deleteCollection"
/>
</div>
</div>
<div class="collapse py-2 show" id="collapseTarget">
<span class="description">{{ userSequence.description }}</span>
<div
:class="[sequence.status, 'collapse py-2 show']"
id="collapseTarget"
>
<div class="block-collapse">
<div class="wrapper-info-top">
<span
<span v-if="sequence.created"
>{{ $t('pages.sequence.created') }}
{{
formatDate(new Date(userSequence.created), 'Do MMMM YYYY')
}}</span
{{ formatDate(new Date(sequence.created), 'Do MMMM YY') }}</span
>
<span
<span v-if="sequence.duration"
>{{ $t('pages.sequence.duration') }}
{{ sequence.duration }}</span
>
<span v-if="sequence.taken"
>{{ $t('pages.sequence.taken') }}
{{ formatDate(userSequence.taken, 'Do MMMM YYYY') }}</span
{{ formatDate(sequence.taken, 'Do MMMM YY') }}</span
>
</div>
<div class="wrapper-info-top">
<span
>{{ $t('pages.sequence.duration') }}
{{ userSequence.duration }}</span
<span v-if="sequence.extent.temporal.interval[0][0]"
>{{ $t('pages.sequence.duration_begin') }}
{{
formatDate(
sequence.extent.temporal.interval[0][0],
'Do MMMM YY, hh:mm:ss'
)
}}</span
>
<span
>{{ $t('pages.sequence.camera') }} {{ userSequence.camera }} -
{{ userSequence.cameraModel }}</span
<span v-if="sequence.extent.temporal.interval[0][1]"
>{{ $t('pages.sequence.duration_end') }}
{{
formatDate(
sequence.extent.temporal.interval[0][1],
'Do MMMM YY, hh:mm:ss'
)
}}</span
>
<span v-if="sequence.camera"
>{{ $t('pages.sequence.camera') }} {{ sequence.camera }} -
{{ sequence.cameraModel }}</span
>
</div>
</div>
</div>
</div>
<div
v-if="userPhotos && userPhotos.length"
:class="['photos-wrapper', { 'header-open': headerPanelIsOpen }]"
>
<div class="delete-all">
<div v-if="pictures && pictures.length" class="photos-wrapper">
<div class="delete-all" ref="deleteAll">
<div class="wrapper-select">
<InputCheckbox
:is-checked="userPhotos.length === imagesToDelete.length"
:is-checked="pictures.length === picturesToDelete.length"
:is-indeterminate="isIndeterminate"
:label="selectedText"
@trigger="triggerCheck"
/>
<div v-if="picturesToDelete.length" class="wrapper-photo-selected">
<span class="photo-selected-separator">-</span>
<span>{{
$t('pages.sequence.picture_selected', picturesToDelete.length)
}}</span>
</div>
</div>
<div class="action-buttons">
<Button
look="button--white"
:icon="
imagesToDeleteStatus === 'hidden' ||
picturesToDeleteStatus === 'hidden' ||
imagesSelectedHaveDifferentStatus
? 'bi bi-eye'
: 'bi bi-eye-slash'
"
:tooltip="$t('pages.sequence.hide_photo_tooltip')"
:disabled="!imagesToDelete.length"
:disabled="
!picturesToDelete.length || sequence.status === 'hidden'
"
@trigger="patchOrDeleteCollectionItems('PATCH')"
/>
<div class="button-hidde">
@@ -101,7 +132,7 @@
look="button--red"
icon="bi bi-trash"
:tooltip="$t('pages.sequence.delete_photo_tooltip')"
:disabled="!imagesToDelete.length"
:disabled="!picturesToDelete.length"
@trigger="patchOrDeleteCollectionItems('DELETE')"
/>
</div>
@@ -109,83 +140,160 @@
</div>
<ul class="photo-list">
<li
v-for="(item, i) in userPhotos"
v-for="(item, i) in pictures"
:id="`photo${i}`"
class="photo-item"
>
<ImageItem
:href="item.assets.thumb.href"
:href-hd="item.assets.hd.href"
:created="formatDate(item.properties.created, 'HH:mm')"
:selected="photoToDeleteOrPatchSelected(item)"
:created="formatDate(item.properties.datetime, 'HH:mm:ss')"
:selected="photoToDeleteOrPatchSelected(item, picturesToDelete)"
:selected-on-map="itemSelected === item.id"
:status="item.properties['geovisio:status']"
:status="
imageStatus(item.properties['geovisio:status'], sequence.status)
"
@trigger="selectImageAndMove(item)"
/>
</li>
<div class="entry-pagination">
<Pagination
v-for="item in paginationLinks"
:type="item.rel"
:href="item.href"
:self-link="selfLink[0]"
@trigger="goToNextPage"
/>
</div>
</ul>
</div>
<p v-else class="no-photo">{{ $t('pages.sequence.no_image') }}</p>
<Toast :text="toastText" :look="toastLook" @trigger="toastText = ''" />
</div>
<div v-else class="menu-right wrapper-loader">
<Loader />
<Loader look="sm" :is-loaded="false" />
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSequenceStore } from '@/store/sequence'
import { storeToRefs } from 'pinia'
import Button from '@/components/Button.vue'
import Toast from '@/components/Toast.vue'
import Pagination from '@/components/Pagination.vue'
import InputCheckbox from '@/components/InputCheckbox.vue'
import Loader from '@/components/Loader.vue'
import ImageItem from '@/components/ImageItem.vue'
import { formatDate, durationCalc } from '@/utils/dates'
import { durationCalc, formatDate } from '@/utils/dates'
import {
deleteACollectionItem,
patchACollectionItem,
fetchCollectionItems,
fetchCollection
} from '@/utils/sequence'
fetchCollectionItemsWithFullUrl,
fetchCollection,
deleteACollection,
patchACollection
} from '@/views/utils/sequence/request'
import {
imageStatus,
scrollIntoSelected,
photoToDeleteOrPatchSelected,
spliceIntoChunks,
formatPaginationItems
} from '@/views/utils/sequence/index'
import { fetchMapAndViewer } from '@/utils/mapAndViewer'
import type {
ResponseUserPhotoInterface,
ResponseUserPhotoLinksInterface,
CheckboxInterface,
UserSequenceInterface
UserSequenceInterface,
ResponseUserSequenceInterface
} from './interfaces/MySequenceView'
const { t } = useI18n()
const route = useRoute()
let userSequence = ref<UserSequenceInterface>()
let userPhotos = ref<ResponseUserPhotoInterface[] | []>([])
let imagesToDelete = ref<string[]>([])
const router = useRouter()
const sequenceStore = useSequenceStore()
const { toastText, toastLook } = storeToRefs(sequenceStore)
let sequence = ref<UserSequenceInterface>()
let pictures = ref<ResponseUserPhotoInterface[] | []>([])
let picturesToDelete = ref<string[]>([])
let paginationLinks = ref<ResponseUserPhotoLinksInterface[] | []>([])
let selfLink = ref<ResponseUserPhotoLinksInterface[] | []>([])
let menuIsOpen = ref<boolean>(true)
let headerPanelIsOpen = ref<boolean>(true)
let isShiftPressed = ref<boolean>(false)
let itemSelected = ref<string>('')
let toastText = ref<string>('')
let toastLook = ref<string>('')
let patchOrDeleteIsLoading = ref<boolean>(false)
let isLoading = ref<boolean>(false)
let viewer = ref()
const collapseMenu = ref<HTMLDivElement>()
const deleteAll = ref<HTMLDivElement>()
const menuHeight = ref<string>()
interface EventInterface {
detail: {
picId: string
}
}
watchEffect(async () => {
const viewerMap = await viewer.value
if (viewerMap && viewerMap.addEventListener) {
viewerMap.addEventListener('picture-loaded', (e: EventInterface): void => {
itemSelected.value = e.detail.picId
scrollIntoSelected(e.detail.picId)
onMounted(async () => {
try {
const fetchAllCollectionInfo = await Promise.all([
fetchCollection(route.params.id),
fetchCollectionItems(route.params.id, '?limit=100')
])
selfLink.value = fetchAllCollectionInfo[1].data.links.filter(
(el) => el.rel === 'self'
)
paginationLinks.value = formatPaginationItems(
fetchAllCollectionInfo[1].data.links
)
formatSequenceFetched(fetchAllCollectionInfo[0].data)
const collectionItems = fetchAllCollectionInfo[1].data.features
const collectionItemsReady = collectionItems.filter(
(el) => el.properties['geovisio:status'] === 'ready'
)
pictures.value = collectionItems
viewer.value = await fetchMapAndViewer({
fetchOptions: {
credentials: 'include'
}
})
setHeightValue()
if (itemSelected.value.length || !collectionItemsReady[0]) return
viewer.value._api.onceReady().then(() => {
viewer.value.goToPicture(collectionItemsReady[0].id, sequence.value?.id)
})
itemSelected.value = collectionItemsReady[0].id
scrollIntoSelected(collectionItemsReady[0].id, pictures.value)
} catch (err) {
console.log(err)
}
})
const imagesToDeleteStatus = computed((): string => {
watchEffect(async () => {
if (!viewer.value || !viewer.value.addEventListener) return
viewer.value.addEventListener(
'picture-loaded',
async (e: { detail: { picId: string } }): Promise<void> => {
if (itemSelected.value === e.detail.picId) return
if (!pictureExistInList(e.detail.picId)) {
await goToTheGoodPage(e.detail.picId)
}
itemSelected.value = e.detail.picId
scrollIntoSelected(e.detail.picId, pictures.value)
}
)
})
const sequenceStatus = computed((): string => {
if (sequence.value?.status === 'ready')
return t('pages.sequence.sequence_published')
if (sequence.value?.status === 'hidden')
return t('pages.sequence.sequence_hidden')
return t('pages.sequence.sequence_waiting')
})
const picturesToDeleteStatus = computed((): string => {
if (fullImagesToDelete().length) {
return fullImagesToDelete()[0].properties['geovisio:status']
}
@@ -204,75 +312,120 @@ const imagesSelectedHaveDifferentStatus = computed((): boolean => {
const isIndeterminate = computed(
(): boolean =>
!!imagesToDelete.value.length &&
!!userSequence.value &&
userPhotos.value.length !== imagesToDelete.value.length
!!picturesToDelete.value.length &&
!!sequence.value &&
pictures.value.length !== picturesToDelete.value.length
)
const selectedText = computed((): string =>
imagesToDelete.value.length === userPhotos.value.length
picturesToDelete.value.length === pictures.value.length
? t('pages.sequence.unselect_text')
: t('pages.sequence.select_text')
)
onMounted(async () => {
try {
const fetchAllCollectionInfo = await Promise.all([
fetchCollection(route.params.id),
fetchCollectionItems(route.params.id)
])
const collection = fetchAllCollectionInfo[0].data
userSequence.value = {
title: collection.title,
description: collection.description,
license: collection.license,
taken: collection.extent.temporal.interval[0][0],
created: collection.created,
location: collection.extent.spatial.bbox[0],
imageCount: collection['stats:items'].count,
duration: formatSequenceDuration(collection.extent.temporal.interval[0]),
camera: collection.summaries['pers:interior_orientation'][0].make,
cameraModel: collection.summaries['pers:interior_orientation'][0].model
async function goToTheGoodPage(id: string): Promise<void> {
const { data } = await fetchCollectionItems(
route.params.id,
`?withPicture=${id}&limit=100`
)
selfLink.value = data.links.filter((el) => el.rel === 'self')
paginationLinks.value = formatPaginationItems(data.links)
pictures.value = data.features
}
function pictureExistInList(picId: string): boolean {
return !!pictures.value.find((el) => el.id === picId)
}
function setHeightValue() {
setTimeout(() => {
if (collapseMenu.value && deleteAll.value) {
const height =
collapseMenu.value?.clientHeight + deleteAll.value?.clientHeight + 130
menuHeight.value = `${height}px`
}
const collectionItems = fetchAllCollectionInfo[1].data.features
const collectionItemsReady = collectionItems.filter(
(el) => el.properties['geovisio:status'] === 'ready'
)
userPhotos.value = collectionItems
if (collectionItemsReady[0]) {
viewer.value = await fetchMapAndViewer(collectionItemsReady[0].id)
return scrollIntoSelected(collectionItemsReady[0].id)
}
viewer.value = await fetchMapAndViewer()
} catch (err) {
console.log(err)
}, 500)
}
function toggleMenu() {
headerPanelIsOpen.value = !headerPanelIsOpen.value
setHeightValue()
}
function formatSequenceFetched(collectionInfo: ResponseUserSequenceInterface) {
const times = ['hours', 'minutes', 'seconds']
sequence.value = {
...collectionInfo,
duration: formatDuration(collectionInfo.extent.temporal.interval[0], times),
status: collectionInfo['geovisio:status']
}
})
}
function formatDuration(temporal: Date[], times: string[]): string {
let timer = ''
times.map((el) => {
const durationCalculated = durationCalc(temporal[1], temporal[0], el)
if (durationCalculated > 0) {
timer += ` ${t(`pages.sequence.${el}`, durationCalculated)}`
}
})
return timer
}
function hiddeAllPictures(): void {
pictures.value.map((el) => (el.properties['geovisio:status'] = 'false'))
}
async function deleteCollection(): Promise<void> {
if (confirm(t('pages.sequence.confirm_sequence_dialog'))) {
isLoading.value = true
await deleteACollection(route.params.id)
isLoading.value = false
sequenceStore.addToastText(t('pages.sequences.sequence_deleted'), 'success')
await router.push({ name: 'my-sequences' })
}
}
async function patchCollection(): Promise<void> {
isLoading.value = true
let visible
if (sequence.value?.status === 'ready') visible = 'false'
else visible = 'true'
await patchACollection(route.params.id, visible)
const fetchCollectionInfo = await fetchCollection(route.params.id)
formatSequenceFetched(fetchCollectionInfo.data)
if (visible === 'false') hiddeAllPictures()
else {
const { data } = await fetchCollectionItems(route.params.id, '?limit=100')
pictures.value = data.features
}
viewer.value.reloadVectorTiles()
isLoading.value = false
}
function fullImagesToDelete(): ResponseUserPhotoInterface[] {
return userPhotos.value.filter((el) => imagesToDelete.value.includes(el.id))
return pictures.value.filter((el) => picturesToDelete.value.includes(el.id))
}
function scrollIntoSelected(id: string): void {
const itemPosition = userPhotos.value.map((el) => el.id).indexOf(id)
const elementTarget = document.querySelector(`#photo${itemPosition - 2}`)
if (elementTarget) elementTarget.scrollIntoView()
async function goToNextPage(value: string) {
isLoading.value = true
const { data } = await fetchCollectionItemsWithFullUrl(value)
selfLink.value = data.links.filter((el) => el.rel === 'self')
paginationLinks.value = formatPaginationItems(data.links)
pictures.value = data.features
scrollIntoSelected(pictures.value[0].id, pictures.value)
picturesToDelete.value = []
isLoading.value = false
setHeightValue()
}
function triggerCheck(value: CheckboxInterface): void {
value.isChecked
? (imagesToDelete.value = userPhotos.value
? (picturesToDelete.value = pictures.value
.filter(
(el) => el.properties['geovisio:status'] !== 'waiting-for-process'
)
.map((el) => el.id))
: (imagesToDelete.value = [])
: (picturesToDelete.value = [])
}
function onToggleHeader(): void {
headerPanelIsOpen.value = !headerPanelIsOpen.value
}
function photoToDeleteOrPatchSelected(
item: ResponseUserPhotoInterface
): boolean {
return imagesToDelete.value.includes(item.id)
}
function selectPhotoToDeleteOrPatch(
item: ResponseUserPhotoInterface
): string[] {
@@ -287,55 +440,50 @@ function selectPhotoToDeleteOrPatch(
}
})
if (isShiftPressed.value) {
const userPhotosIndex = userPhotos.value.findIndex(
(el) => el.id === item.id
const picturesIndex = pictures.value.findIndex((el) => el.id === item.id)
const picturesLastIndex = pictures.value.findIndex(
(el) => el.id === picturesToDelete.value[0]
)
const userPhotosLastIndex = userPhotos.value.findIndex(
(el) => el.id === imagesToDelete.value[0]
const slicedUserPhotos = pictures.value.slice(
picturesLastIndex,
picturesIndex + 1
)
const slicedUserPhotos = userPhotos.value.slice(
userPhotosLastIndex,
userPhotosIndex + 1
)
return (imagesToDelete.value = slicedUserPhotos.map((el) => el.id))
return (picturesToDelete.value = slicedUserPhotos.map((el) => el.id))
}
if (imagesToDelete.value.includes(item.id)) {
return (imagesToDelete.value = imagesToDelete.value.filter(
if (picturesToDelete.value.includes(item.id)) {
return (picturesToDelete.value = picturesToDelete.value.filter(
(el) => el !== item.id
))
}
return (imagesToDelete.value = [...imagesToDelete.value, item.id])
return (picturesToDelete.value = [...picturesToDelete.value, item.id])
}
async function selectImageAndMove(
item: ResponseUserPhotoInterface
): Promise<void> {
selectPhotoToDeleteOrPatch(item)
if (
imagesToDelete.value.length < 2 &&
picturesToDelete.value.length < 2 &&
item.properties['geovisio:status'] === 'ready'
) {
const viewerMap = await viewer.value
viewerMap.goToPicture(item.id)
viewerMap.goToPicture(item.id, sequence.value?.id)
itemSelected.value = item.id
scrollIntoSelected(item.id)
await goToTheGoodPage(item.id)
scrollIntoSelected(item.id, pictures.value)
}
}
function spliceIntoChunks(arr: string[], chunkSize: number) {
const res = []
arr = ([] as string[]).concat(...arr)
while (arr.length) {
res.push(arr.splice(0, chunkSize))
}
return res
}
async function patchOrDeleteCollectionItems(
requestType: string
): Promise<void> {
patchOrDeleteIsLoading.value = true
if (
requestType === 'DELETE' &&
!confirm(t('pages.sequence.confirm_pictures_dialog'))
)
return
isLoading.value = true
toastText.value = ''
const chunksItems = spliceIntoChunks(imagesToDelete.value, 4)
const chunksItems = spliceIntoChunks(picturesToDelete.value, 4)
try {
let items: unknown[] = []
if (imagesSelectedHaveDifferentStatus.value) {
@@ -343,13 +491,11 @@ async function patchOrDeleteCollectionItems(
items = [
...items,
...(await Promise.all(
el.map((ele) => {
el.map(async (ele) => {
if (requestType === 'PATCH') {
return patchACollectionItem('true', route.params.id, ele)
}
if (confirm(t('pages.sequence.confirm_dialog'))) {
return deleteACollectionItem(route.params.id, ele)
return await patchACollectionItem('true', route.params.id, ele)
}
return await deleteACollectionItem(route.params.id, ele)
})
))
]
@@ -359,61 +505,41 @@ async function patchOrDeleteCollectionItems(
items = [
...items,
...(await Promise.all(
el.map((ele) => {
el.map(async (ele) => {
if (requestType === 'PATCH') {
const imageToDelete = userPhotos.value.find(
const imageToDelete = pictures.value.find(
(elem) => elem.id === ele
)
const isVisible =
imageToDelete?.properties['geovisio:status'] === 'ready'
? 'false'
: 'true'
return patchACollectionItem(isVisible, route.params.id, ele)
}
if (confirm(t('pages.sequence.confirm_dialog'))) {
return deleteACollectionItem(route.params.id, ele)
return await patchACollectionItem(
isVisible,
route.params.id,
ele
)
}
return await deleteACollectionItem(route.params.id, ele)
})
))
]
}
}
const { data } = await fetchCollectionItems(route.params.id)
userPhotos.value = data.features
toastText.value = t('general.success_text')
toastLook.value = 'success'
patchOrDeleteIsLoading.value = false
scrollIntoSelected(imagesToDelete.value[0])
imagesToDelete.value = []
const { data } = await fetchCollectionItems(route.params.id, '?limit=100')
pictures.value = data.features
isLoading.value = false
viewer.value.reloadVectorTiles()
viewer.value.goToPicture(pictures.value[0].id, route.params.id)
scrollIntoSelected(picturesToDelete.value[0], pictures.value)
picturesToDelete.value = []
sequenceStore.addToastText(t('general.success_text'), 'success')
} catch (e) {
toastText.value = t('general.error_text')
toastLook.value = 'error'
patchOrDeleteIsLoading.value = false
imagesToDelete.value = []
sequenceStore.addToastText(t('general.error_text'), 'error')
isLoading.value = false
picturesToDelete.value = []
}
}
function formatSequenceDuration(temporal: Date[]): string {
let timer = ''
if (durationCalc(temporal[1], temporal[0], 'hours') > 0) {
timer += ` ${t(
'pages.sequence.hours',
durationCalc(temporal[1], temporal[0], 'hours')
)}`
}
if (durationCalc(temporal[1], temporal[0], 'minutes')) {
timer += ` ${t(
'pages.sequence.minutes',
durationCalc(temporal[1], temporal[0], 'minutes')
)}`
}
if (durationCalc(temporal[1], temporal[0], 'seconds') > 0)
timer += ` ${t(
'pages.sequence.seconds',
durationCalc(temporal[1], temporal[0], 'seconds')
)}`
return timer
}
</script>
<style lang="scss" scoped>
@@ -423,11 +549,12 @@ function formatSequenceDuration(temporal: Date[]): string {
.entry-viewer {
width: 50vw;
position: relative;
height: calc(100vh - 8rem);
height: calc(100vh - #{toRem(8)});
font-size: 137.5%;
}
.menu-right {
width: 50vw;
height: calc(100vh - 8rem);
height: calc(100vh - #{toRem(8)});
overflow: hidden;
box-shadow: 0px 4px 20px 0px #00000033;
}
@@ -441,19 +568,21 @@ function formatSequenceDuration(temporal: Date[]): string {
align-items: center;
}
.disable-button {
margin-right: 1rem;
margin-right: toRem(1);
}
.collapse {
&:first-child {
@include text(s-regular);
color: var(--grey-dark);
margin-bottom: 1rem;
margin-bottom: toRem(1);
}
&.hidden {
opacity: 0.4;
}
}
.block-collapse {
display: flex;
}
.description,
.wrapper-info-top {
@include text(s-regular);
color: var(--grey-dark);
@@ -461,34 +590,52 @@ function formatSequenceDuration(temporal: Date[]): string {
.wrapper-info-top {
display: flex;
flex-direction: column;
margin-top: 1rem;
margin-top: toRem(1);
&:first-child {
border-right: 0.1rem solid var(--grey-dark);
padding-right: 2rem;
border-right: toRem(0.1) solid var(--grey-dark);
padding-right: toRem(2);
}
&:nth-child(2) {
padding-left: 2rem;
padding-left: toRem(2);
}
}
.title {
@include text(h2);
color: var(--grey-dark);
margin-right: 1rem;
margin-right: toRem(1);
}
.button-close {
display: none;
}
.menu-top {
margin: 2rem 2rem 0;
padding: 1rem 2rem;
border: 0.1rem solid var(--grey);
border-radius: 0.5rem;
margin: toRem(2) toRem(2) 0;
padding: toRem(1) toRem(2);
border: toRem(0.1) solid var(--grey);
border-radius: toRem(0.5);
background-color: var(--blue-semi);
}
.header-menu {
display: flex;
justify-content: space-between;
}
.sequence-status {
border-radius: toRem(3);
padding: toRem(0.5) toRem(1);
margin-right: toRem(2);
color: var(--white);
&.ready {
background-color: var(--orange);
border: toRem(0.1) solid var(--orange);
}
&.waiting-for-process {
background-color: var(--yellow);
border: toRem(0.1) solid var(--yellow);
}
&.hidden {
background-color: var(--blue-geovisio);
border: toRem(0.1) solid var(--blue-geovisio);
}
}
.button-collapse {
border: none;
background-color: transparent;
@@ -499,30 +646,35 @@ function formatSequenceDuration(temporal: Date[]): string {
.bi-plus,
.bi-dash {
color: var(--grey-dark);
font-size: 3rem;
font-size: toRem(3);
}
.photos-wrapper {
padding: 1rem 0rem 2rem 1rem;
height: calc(100vh - 21rem);
}
.header-open {
height: calc(100vh - 31rem);
padding: toRem(1) 0rem toRem(2) toRem(1);
height: calc(100vh - v-bind(menuHeight));
}
.delete-all {
display: flex;
justify-content: space-between;
align-items: center;
margin-left: 1rem;
margin-right: 2rem;
margin-bottom: 1rem;
margin-left: toRem(1);
margin-right: toRem(2);
margin-bottom: toRem(1);
}
.wrapper-select {
display: flex;
align-items: center;
}
.wrapper-photo-selected {
@include text(xs-regular);
}
.photo-selected-separator {
margin-right: toRem(0.5);
margin-left: toRem(0.5);
}
.button-hidde {
margin-right: 1rem;
margin-left: 1rem;
margin-right: toRem(1);
margin-left: toRem(1);
}
.delete-all-text {
@include text(xs-r-regular);
@@ -540,19 +692,30 @@ function formatSequenceDuration(temporal: Date[]): string {
padding: 0;
}
.photo-item {
width: calc(33% - 2rem);
width: calc(33% - #{toRem(2)});
height: fit-content;
margin: 1rem;
border-radius: 0.5rem;
margin: toRem(1);
border-radius: toRem(0.5);
background-color: var(--grey);
}
.no-photo {
@include text(s-regular);
text-align: center;
margin-top: 10rem;
margin-top: toRem(10);
color: var(--grey-dark);
}
@media (max-width: 1024px) {
.entry-pagination {
margin-top: toRem(2);
width: 100%;
display: flex;
justify-content: center;
}
@media (max-width: toRem(102.4)) {
.header-menu {
flex-direction: column;
align-items: flex-start;
margin-bottom: toRem(1);
}
.block-collapse {
flex-direction: column;
}
@@ -568,24 +731,10 @@ function formatSequenceDuration(temporal: Date[]): string {
}
}
.photo-item {
width: calc(50% - 2rem);
}
.header-open {
height: calc(100vh - 35rem);
width: calc(50% - #{toRem(2)});
}
}
@media (max-width: 768px) {
.header-menu {
flex-direction: column;
align-items: flex-start;
margin-bottom: 1rem;
}
.photos-wrapper {
height: calc(100vh - 29rem);
}
.header-open {
height: calc(100vh - 41rem);
}
@media (max-width: toRem(76.8)) {
.photo-item {
width: 100%;
margin-right: 0;
@@ -595,19 +744,29 @@ function formatSequenceDuration(temporal: Date[]): string {
margin-right: 0;
}
.wrapper-button {
margin-top: 1rem;
margin-top: toRem(1);
}
.photo-list {
padding-right: 2rem;
padding-right: toRem(2);
}
.delete-all {
text-align: left;
}
.wrapper-select {
flex-direction: column;
align-items: initial;
}
.wrapper-photo-selected:nth-child(2) {
margin-top: toRem(0.5);
}
.photo-selected-separator {
display: none;
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.entry-page {
height: calc(100vh - 11rem);
height: calc(100vh - #{toRem(11)});
overflow: hidden;
}
.entry-viewer {
@@ -616,7 +775,7 @@ function formatSequenceDuration(temporal: Date[]): string {
z-index: 1;
}
.menu-right {
padding-top: 11rem;
padding-top: toRem(11);
height: 100%;
position: fixed;
top: 0;
@@ -631,15 +790,15 @@ function formatSequenceDuration(temporal: Date[]): string {
.button-close {
position: absolute;
right: 0;
top: 22rem;
top: toRem(22);
z-index: 3;
background-color: var(--black);
height: 5rem;
height: toRem(5);
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
border-top-left-radius: toRem(0.5);
border-bottom-left-radius: toRem(0.5);
}
.menu-is-open {
.menu-right {
@@ -649,9 +808,22 @@ function formatSequenceDuration(temporal: Date[]): string {
width: auto;
}
.button-close {
left: calc(20vw - 3rem);
left: calc(20vw - #{toRem(3)});
right: initial;
}
}
.entry-pagination {
margin-top: toRem(1);
}
}
@media (min-width: 1900px) {
.menu-right {
width: initial;
max-width: toRem(100);
}
.entry-viewer {
width: 100%;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<main class="entry-page">
<h1 class="sequences-title">{{ $t('pages.sequences.title') }}</h1>
<ul class="sequence-list">
<ul v-if="!isLoading" class="sequence-list">
<li class="sequence-item">
<div class="sequence-header-item"></div>
<div class="sequence-header-item">
@@ -10,7 +10,7 @@
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-title"
@trigger="sortElements('alpha')"
@trigger="sortAlpha('title')"
/>
</div>
<div class="sequence-header-item">
@@ -19,7 +19,7 @@
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-number"
@trigger="sortElements('num')"
@trigger="sortNum('num')"
/>
</div>
<div class="sequence-header-item">
@@ -28,7 +28,7 @@
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-date"
@trigger="sortElements('date')"
@trigger="sortNum('date')"
/>
</div>
<div class="sequence-header-item">
@@ -37,12 +37,12 @@
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-date"
@trigger="sortElements('alpha')"
@trigger="sortAlpha('geovisio:status')"
/>
</div>
</li>
<li
v-if="userSequences && userSequences.length"
v-if="userSequences.length"
v-for="item in userSequences"
class="sequence-item"
>
@@ -55,11 +55,21 @@
>
<div class="wrapper-thumb">
<img
v-if="item['stats:items'].count > 0"
:src="`${item.href}/thumb.jpg`"
lazy="loading"
alt=""
class="thumb"
/>
<div class="wrapper-thumb-hover">
<img
v-if="item['stats:items'].count > 0"
:src="`${item.href}/thumb.jpg`"
lazy="loading"
alt=""
class="thumb-hover"
/>
</div>
</div>
<div>
<span>
@@ -92,90 +102,91 @@
{{ $t('pages.sequences.no_sequences_text') }}
</p>
<Link
:text="$t('general.header.contribute_text')"
:text="$t('general.header.upload_text')"
look="button"
path="/partager-des-photos"
:route="{ name: 'share-pictures' }"
/>
</div>
</ul>
<div v-else class="loader">
<Loader look="sm" :is-loaded="false" />
</div>
<Toast :text="toastText" :look="toastLook" @trigger="toastText = ''" />
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSequenceStore } from '@/store/sequence'
import { storeToRefs } from 'pinia'
import axios from 'axios'
import Button from '@/components/Button.vue'
import Link from '@/components/Link.vue'
import Toast from '@/components/Toast.vue'
import Loader from '@/components/Loader.vue'
import type {
LinkInterface,
ExtentInterface
ExtentLinkInterface
} from './interfaces/MySequencesView'
import { formatDate } from '@/utils/dates'
const { t } = useI18n()
const sequenceStore = useSequenceStore()
const { toastText, toastLook } = storeToRefs(sequenceStore)
let userSequences = ref<LinkInterface[]>([])
let isSorted = ref<boolean>(false)
let isLoading = ref<boolean>(false)
function sequenceStatus(status: string): string {
if (status === 'ready') {
return t('pages.sequences.sequence_published')
}
if (status === 'waiting-for-process') {
if (status === 'ready') return t('pages.sequences.sequence_published')
if (status === 'waiting-for-process')
return t('pages.sequences.sequence_waiting')
}
return t('pages.sequences.sequence_hidden')
}
function sortElements(type: string): void {
let aa: string | number
let bb: string | number
if (!isSorted.value) {
const sorted = userSequences.value.sort(
(a: ExtentInterface, b: ExtentInterface): number => {
aa = new Date(a.extent.temporal.interval[0][0]).getTime()
bb = new Date(b.extent.temporal.interval[0][0]).getTime()
if (type === 'alpha') {
aa = a.title
bb = b.title
}
if (type === 'num') {
aa = Number(a['stats:items'].count)
bb = Number(b['stats:items'].count)
}
isSorted.value = true
if (aa < bb) return -1
return 0
function sortAlpha<TKey extends keyof LinkInterface>(key: TKey): void {
const sorted = userSequences.value.sort(
(
a: { [K in TKey]: LinkInterface[TKey] },
b: { [K in TKey]: LinkInterface[TKey] }
) => {
if (a[key] < b[key]) return !isSorted.value ? -1 : 1
if (a[key] > b[key]) return !isSorted.value ? 1 : -1
return 0
}
)
isSorted.value = !isSorted.value
userSequences.value = sorted
}
function sortNum(type: string): void {
let aa, bb: number
const sorted = userSequences.value.sort(
(a: ExtentLinkInterface, b: ExtentLinkInterface) => {
aa = new Date(a.extent.temporal.interval[0][0]).getTime()
bb = new Date(b.extent.temporal.interval[0][0]).getTime()
if (type === 'num') {
aa = Number(a['stats:items'].count)
bb = Number(b['stats:items'].count)
}
)
userSequences.value = sorted
} else {
const sorted = userSequences.value.sort(
(a: ExtentInterface, b: ExtentInterface): number => {
aa = new Date(a.extent.temporal.interval[0][0]).getTime()
bb = new Date(b.extent.temporal.interval[0][0]).getTime()
if (type === 'alpha') {
aa = a.title
bb = b.title
}
if (type === 'num') {
aa = Number(a['stats:items'].count)
bb = Number(b['stats:items'].count)
}
isSorted.value = false
if (aa > bb) return -1
return 0
}
)
userSequences.value = sorted
}
if (aa < bb) return !isSorted.value ? -1 : 1
if (aa > bb) return !isSorted.value ? 1 : -1
return 0
}
)
isSorted.value = !isSorted.value
userSequences.value = sorted
}
onMounted(async () => {
isLoading.value = true
try {
const { data } = await axios.get('api/users/me/catalog')
const relChild = data.links.filter(
(el: LinkInterface) => el.rel === 'child'
)
userSequences.value = relChild
isLoading.value = false
} catch (err) {
isLoading.value = false
console.log(err)
}
})
@@ -183,19 +194,19 @@ onMounted(async () => {
<style lang="scss" scoped>
.entry-page {
padding-right: 8rem;
padding-left: 8rem;
padding-top: 11rem;
min-height: calc(100vh - 8rem);
padding-right: toRem(8);
padding-left: toRem(8);
padding-top: toRem(11);
min-height: calc(100vh - #{toRem(8)});
}
.sequences-title {
@include text(h1);
margin-bottom: 4rem;
margin-bottom: toRem(4);
}
.sequence-list {
box-shadow: 0px 2px 30px 0px #0000000f;
border-radius: 2rem;
border-radius: toRem(2);
padding: 0;
}
.sequence-item {
@@ -206,18 +217,18 @@ onMounted(async () => {
margin: auto;
background-color: var(--blue-pale);
&:last-child {
border-bottom-right-radius: 2rem;
border-bottom-left-radius: 2rem;
border-bottom-right-radius: toRem(2);
border-bottom-left-radius: toRem(2);
.button-item {
border-bottom-right-radius: 2rem;
border-bottom-left-radius: 2rem;
border-bottom-right-radius: toRem(2);
border-bottom-left-radius: toRem(2);
}
}
&:first-child {
margin-bottom: 1rem;
padding: 1rem 3rem;
border-bottom: 0.1rem solid var(--grey);
border-radius: 2rem 2rem 0rem 0rem;
margin-bottom: toRem(1);
padding: toRem(1) toRem(3);
border-bottom: toRem(0.1) solid var(--grey);
border-radius: toRem(2) toRem(2) 0rem 0rem;
background-color: var(--white);
}
&:nth-child(2n) {
@@ -228,33 +239,54 @@ onMounted(async () => {
display: flex;
align-items: center;
}
.wrapper-thumb {
position: relative;
}
.wrapper-thumb-hover {
display: none;
border-radius: toRem(0.3);
border: toRem(1) solid var(--grey);
position: absolute;
bottom: 0;
height: toRem(15);
z-index: 1;
}
.thumb-hover {
height: 100%;
}
.wrapper-thumb:hover {
.wrapper-thumb-hover {
display: block;
}
}
.thumb {
height: 100%;
width: 100%;
object-fit: cover;
border-radius: 0.5rem;
border-radius: toRem(0.5);
position: relative;
}
.button-item {
display: flex;
align-items: center;
width: 100%;
padding: 2rem 3rem;
padding: toRem(2) toRem(3);
background-color: transparent;
border: none;
text-decoration: none;
& > * {
padding: 1rem;
padding: toRem(1);
text-align: initial;
width: 31%;
color: var(--black);
}
> :first-child {
color: var(--blue);
width: 6rem;
width: toRem(6);
}
& > :first-child {
padding: 0;
margin-right: 2rem;
margin-right: toRem(2);
}
& > :nth-child(2) {
color: var(--blue);
@@ -267,41 +299,49 @@ onMounted(async () => {
}
}
.bi-images {
margin-right: 0.5rem;
margin-right: toRem(0.5);
}
.sequence-header-item {
width: 31%;
&:first-child {
margin-right: 2rem;
margin-right: toRem(2);
}
&:first-child {
width: 6rem;
width: toRem(6);
}
}
.no-sequence {
padding-top: 2rem;
padding-bottom: 4rem;
padding-top: toRem(2);
padding-bottom: toRem(4);
margin: auto;
width: fit-content;
text-align: center;
@include text(m-regular);
}
.no-sequence-text {
margin-bottom: 4rem;
margin-bottom: toRem(4);
}
@media (max-width: 768px) {
.loader {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
margin-top: toRem(20);
}
@media (max-width: toRem(76.8)) {
.entry-page {
padding-right: 2rem;
padding-left: 2rem;
padding-top: 14rem;
min-height: calc(100vh - 11rem);
padding-right: toRem(2);
padding-left: toRem(2);
padding-top: toRem(14);
min-height: calc(100vh - #{toRem(11)});
}
.button-item,
.sequence-item:first-child {
padding-right: 1rem;
padding-left: 1rem;
padding-right: toRem(1);
padding-left: toRem(1);
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.button-item {
flex-direction: column;
align-items: center;
@@ -317,8 +357,8 @@ onMounted(async () => {
display: none;
}
.sequence-item {
border-top-right-radius: 1rem;
border-top-left-radius: 1rem;
border-top-right-radius: toRem(1);
border-top-left-radius: toRem(1);
}
}
</style>

View File

@@ -9,7 +9,7 @@
<div class="entry-button">
<Button
:data-test="`button-eye-${i}`"
look="button--rounded"
look="no-text"
:tooltip="$t('pages.settings.setting_tooltip')"
:icon="
!item.token || item.isHidden ? 'bi bi-eye' : 'bi bi-eye-slash'
@@ -26,7 +26,7 @@
<Button
:data-test="`button-copy-${i}`"
look="button--white"
:text="$t('pages.upload.button_copy')"
:text="$t('pages.share_pictures.button_copy')"
:icon="
item.copied
? 'bi bi-clipboard-check-fill'
@@ -86,31 +86,31 @@ onMounted(async () => {
<style lang="scss" scoped>
.entry-page {
padding-right: 8rem;
padding-left: 8rem;
padding-top: 11rem;
min-height: calc(100vh - 8rem);
padding-right: toRem(8);
padding-left: toRem(8);
padding-top: toRem(11);
min-height: calc(100vh - #{toRem(8)});
}
.settings-title {
@include text(h1);
margin-bottom: 4rem;
margin-bottom: toRem(4);
}
.settings-list {
padding-left: 0;
}
.settings-item-title {
font-size: 1.8rem;
font-size: toRem(1.8);
}
.settings-item {
font-size: 1.4rem;
border: 0.1rem solid var(--grey);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 2rem;
font-size: toRem(1.4);
border: toRem(0.1) solid var(--grey);
border-radius: toRem(0.5);
padding: toRem(1.5);
margin-bottom: toRem(2);
}
.item-information {
height: 5rem;
margin-top: 1rem;
height: toRem(5);
margin-top: toRem(1);
display: flex;
justify-content: space-between;
align-items: center;
@@ -120,17 +120,18 @@ onMounted(async () => {
width: 80%;
}
.token {
margin-top: toRem(0.4);
width: 100%;
word-wrap: break-word;
}
.entry-button {
margin-right: 1rem;
margin-right: toRem(1);
}
@media (max-width: 1024px) {
@media (max-width: toRem(102.4)) {
.settings-item {
position: relative;
height: 14rem;
height: toRem(14);
}
.item-information {
height: initial;
@@ -138,21 +139,21 @@ onMounted(async () => {
align-items: flex-start;
}
.reveal-token {
margin-right: 2rem;
margin-right: toRem(2);
}
.entry-copy-button {
position: absolute;
bottom: 1rem;
right: 1rem;
bottom: toRem(1);
right: toRem(1);
}
}
@media (max-width: 768px) {
@media (max-width: toRem(76.8)) {
.entry-page {
padding-right: 2rem;
padding-left: 2rem;
padding-top: 14rem;
min-height: calc(100vh - 11rem);
padding-right: toRem(2);
padding-left: toRem(2);
padding-top: toRem(14);
min-height: calc(100vh - #{toRem(11)});
}
.settings-item {
height: initial;
@@ -162,7 +163,7 @@ onMounted(async () => {
}
.token {
max-width: initial;
width: calc(100% - 3.5rem);
width: calc(100% - #{toRem(3.5)});
}
.entry-copy-button {
display: none;

View File

@@ -0,0 +1,483 @@
<template>
<main class="entry-page">
<section>
<div class="information-wrapper">
<div class="information">
<h1 class="page-title">{{ $t('pages.share_pictures.title') }}</h1>
<p
class="page-description"
v-html="$t('pages.share_pictures.description')"
></p>
</div>
<img
src="@/assets/images/person-map.png"
alt=""
class="information-image"
/>
</div>
<ul class="card-list">
<li class="card-list-item">
<ShareCard
:image="{
url: 'building.png',
alt: $t('pages.share_pictures.arg_alt1')
}"
:title="$t('pages.share_pictures.arg_title1')"
:text="$t('pages.share_pictures.arg_text1')"
/>
</li>
<li class="card-list-item">
<ShareCard
:image="{
url: '360.png',
alt: $t('pages.share_pictures.arg_alt2')
}"
:title="$t('pages.share_pictures.arg_title2')"
:text="$t('pages.share_pictures.arg_text2')"
/>
</li>
<li class="card-list-item">
<ShareCard
:image="{
url: 'pointer-map.png',
alt: $t('pages.share_pictures.arg_alt3')
}"
:title="$t('pages.share_pictures.arg_title3')"
:text="$t('pages.share_pictures.arg_text3')"
/>
</li>
<li class="card-list-item">
<ShareCard
:image="{
url: 'pointer.png',
alt: $t('pages.share_pictures.arg_alt4')
}"
:title="$t('pages.share_pictures.arg_title4')"
:text="$t('pages.share_pictures.arg_text4')"
/>
</li>
</ul>
</section>
<section id="sec-1" class="section-upload">
<div class="wrapper-upload">
<div class="wrapper-upload-text">
<h1 class="upload-title">{{ $t('pages.share_pictures.title') }}</h1>
<div class="wrapper-check">
<div class="wrapper-img-icon">
<i class="bi bi-images img-icon"></i>
</div>
<div class="element-check">
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.share_pictures.photo_type1') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.share_pictures.photo_type2') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.share_pictures.photo_type3') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.share_pictures.photo_type4') }}</span
>
</div>
</div>
<p
class="upload-text"
v-html="
$t('pages.share_pictures.description', {
check: checkImg
})
"
/>
<p class="upload-text">
{{ $t('pages.share_pictures.footer_block') }}
</p>
<div v-if="!isLogged && authConf.enabled" class="wrapper-account">
<h4 class="account-subtitle">
{{ $t('pages.share_pictures.sub_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.share_pictures.user_account_button')"
type="external"
look="button button--blue"
:path-external="
getAuthRoute('auth/login', 'partager-des-photos')
"
/>
</div>
</div>
</div>
<div class="entry-image">
<div class="image">
<img
src="@/assets/images/upload.png"
:alt="$t('pages.share_pictures.alt_img_upload')"
/>
</div>
</div>
</div>
</section>
<div class="entry-button-down">
<Link
:href="hrefSection"
type="external"
:icon="icon"
look="button--rounded"
@trigger="triggerHref"
/>
</div>
<section id="sec-2" class="section-upload">
<div class="wrapper-upload">
<div class="entry-terminal">
<div class="terminal">
<Terminal
:text-upload="terminalText"
:text-install="terminalTextInstall"
/>
</div>
</div>
<div class="wrapper-upload-text">
<h2 class="upload-title">
{{ $t('pages.share_pictures.title_terminal') }}
</h2>
<p
class="upload-text"
v-html="$t('pages.share_pictures.description_terminal')"
></p>
<p
class="upload-text grey"
v-html="$t('pages.share_pictures.footer_description_terminal')"
></p>
<div class="wrapper-account">
<h4 class="account-subtitle">
{{ $t('pages.share_pictures.cli_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.share_pictures.button')"
type="external"
look="button button--blue"
path="https://gitlab.com/geovisio/cli"
/>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script lang="ts" setup>
import Link from '@/components/Link.vue'
import Terminal from '@/components/Terminal.vue'
import ShareCard from '@/components/share-pictures/ShareCard.vue'
import { useCookies } from 'vue3-cookies'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { getAuthRoute } from '@/utils/auth'
import authConfig from '../composables/auth'
const { cookies } = useCookies()
const { t } = useI18n()
const { authConf } = authConfig()
const route = useRoute()
let hrefSection = ref<string>('#sec-2')
let icon = ref<string>('bi bi-chevron-down')
const checkImg =
"<span style='background:white; padding: 5px 0.0525px;border-radius:50%; font-size:8px;'>✔</span>"
const isLogged = computed((): boolean => !!cookies.get('user_id'))
const terminalText = computed((): string => {
const url = import.meta.env.VITE_API_URL
? import.meta.env.VITE_API_URL
: 'https://panoramax.ign.fr/'
return t('pages.share_pictures.terminal_text', { url })
})
const terminalTextInstall = computed((): string =>
t('pages.share_pictures.terminal_install')
)
function triggerHref(): void {
icon.value =
route.hash === '#sec-2' ? 'bi bi-chevron-down' : 'bi bi-chevron-up'
hrefSection.value = route.hash === '#sec-2' ? '#sec-1' : '#sec-2'
}
</script>
<style lang="scss" scoped>
.entry-page {
margin: auto;
background-color: var(--beige-pale);
padding: toRem(2) 5%;
}
.information-wrapper {
display: flex;
align-items: center;
padding: toRem(4);
background-color: var(--white);
border-radius: toRem(1);
}
.information {
width: 50%;
}
.information-image {
margin: auto;
}
.page-title {
@include text(h1);
color: var(--blue-dark);
}
.page-description {
color: var(--grey-semi-dark);
white-space: pre-wrap;
}
.card-list {
display: flex;
padding: toRem(4);
background-color: var(--white);
border-radius: toRem(1);
margin-top: toRem(2);
}
.card-list-item {
margin-right: toRem(3);
&:last-child {
margin-right: toRem(0);
}
}
.upload-title {
@include text(h1);
}
.account-subtitle {
@include text(h4);
}
.entry-link {
margin-top: toRem(1);
}
.upload-text {
@include text(m-regular);
margin-top: toRem(5);
}
.wrapper-check {
position: relative;
width: fit-content;
padding: toRem(3);
background-color: var(--white);
border-radius: toRem(1.4);
font-size: toRem(1.6);
margin-top: toRem(8);
}
.wrapper-img-icon {
background-color: var(--black);
border-radius: 50%;
position: absolute;
height: toRem(5.5);
width: toRem(5.5);
display: flex;
justify-content: center;
align-items: center;
font-size: toRem(3);
top: toRem(-3);
left: 50%;
transform: translate(-50%);
}
.img-icon {
color: var(--white);
}
.element-check {
display: flex;
flex-direction: column;
margin-top: toRem(3);
}
.block-check {
margin-bottom: toRem(1);
display: flex;
}
.check-border {
display: flex;
justify-content: center;
align-items: center;
width: toRem(2.2);
height: toRem(2.2);
border-radius: 50%;
font-size: toRem(0.8);
background: var(--white);
border: toRem(0.1) solid var(--black);
margin-right: toRem(0.5);
}
.block-check:last-child {
margin-bottom: 0;
}
.section-upload {
height: 100%;
padding-right: toRem(2);
padding-left: toRem(2);
display: flex;
flex-direction: column;
justify-content: center;
min-height: 80vh;
}
.section-upload:first-child {
background-color: var(--beige-pale);
}
.wrapper-upload {
display: flex;
align-items: initial;
justify-content: center;
}
.wrapper-upload-text {
width: 40%;
white-space: pre-wrap;
}
.grey {
color: var(--grey-semi-dark);
}
.wrapper-account {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
margin-top: toRem(3);
padding-top: toRem(1);
border-top: toRem(0.1) solid #e6e6e6;
}
.upload-button {
display: flex;
margin-top: toRem(3);
}
.image {
background-color: var(--white);
border-radius: toRem(1);
padding: toRem(1);
border: 1px solid var(--black);
width: 75%;
height: fit-content;
}
.entry-image {
display: flex;
justify-content: center;
width: 35%;
overflow: hidden;
margin-left: toRem(6);
}
.entry-terminal {
margin-left: 0;
margin-right: toRem(6);
}
.entry-image img {
width: 100%;
border-radius: toRem(1);
}
.entry-button-down {
z-index: 1;
position: fixed;
right: toRem(2);
bottom: calc(20vh - #{toRem(10.5)});
}
@media (max-width: toRem(102.4)) {
.information-wrapper {
flex-direction: column;
}
.information {
width: 100%;
}
.card-list {
flex-wrap: wrap;
}
.card-list-item {
width: calc(50% - #{toRem(2)});
margin-right: 0;
&:nth-child(1),
&:nth-child(3) {
margin-right: toRem(2);
}
}
.section-upload {
height: initial;
}
.wrapper-upload {
flex-direction: column-reverse;
padding-top: toRem(6);
padding-bottom: toRem(6);
}
.wrapper-upload-text {
width: 100%;
}
.entry-image {
display: none;
}
.entry-terminal {
width: 100%;
justify-content: initial;
}
.terminal {
width: 100%;
margin-top: toRem(4);
}
}
@media (max-width: toRem(76.8)) {
.card-list-item {
width: 100%;
margin-bottom: toRem(2);
&:nth-child(1),
&:nth-child(3) {
margin-right: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.entry-page {
padding-top: toRem(11);
}
.entry-image {
width: 60%;
margin-right: 0;
margin-left: 0;
}
.entry-terminal {
width: 100%;
}
.wrapper-upload-text {
margin-left: 0;
margin-right: 0;
}
}
@media (max-width: toRem(50)) {
.entry-page {
min-height: calc(100vh - #{toRem(11)});
padding-top: toRem(11);
}
.entry-image {
width: 100%;
padding-right: 0;
padding-left: 0;
}
.image {
width: 100%;
}
.wrapper-upload-text {
margin-right: 0;
padding-right: 0;
padding-left: 0;
}
.upload-text {
margin-bottom: toRem(3);
}
.wrapper-account {
padding-top: toRem(3);
flex-direction: column;
}
.account-subtitle {
margin-bottom: toRem(2);
}
.entry-button-down {
display: none;
}
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<main class="entry-page">
<div class="entry-banner">
<TooltipBanner />
</div>
<section class="upload-section">
<div class="form-upload">
<h1 class="settings-title">{{ $t('pages.upload.title') }}</h1>
<p class="settings-text">{{ $t('pages.upload.text') }}</p>
<form v-if="!isLoading" @submit.prevent="uploadPicture">
<InputUpload
:text="$t('pages.upload.input_label')"
:text-second-part="$t('pages.upload.import_word')"
:text-picture-type="$t('pages.upload.import_type')"
accept="image/jpeg"
data-test="input-add-pictures"
@trigger="addPictures"
/>
<div class="footer-form">
<span v-if="fileUploaded" class="number-file-text">{{
fileUploaded
}}</span>
<span v-else class="number-file-text">{{
t('pages.upload.no_uploaded_files')
}}</span>
<Button
:text="$t('pages.upload.button_text')"
:disabled="!pictures || pictures.length < 1"
type="submit"
look="button button--blue"
/>
</div>
</form>
<UploadLoader
v-else
:load-percentage="loadPercentage"
:load-text-size="loadTextSize"
:is-loaded="isLoaded"
:uploaded-sequences="uploadedSequences"
:pictures-count="picturesCount"
@triggerNewUpload="triggerNewUpload"
/>
</div>
<div class="form-upload">
<h1 class="settings-title">{{ $t('pages.upload.title_uploading') }}</h1>
<div v-if="!uploadedSequences.length" class="uploading-information">
<img src="@/assets/images/uploading.png" class="uploading-img" />
<p class="settings-text">
{{ $t('pages.upload.no_picture_uploading_text') }}
</p>
</div>
<template v-for="(sequence, i) in uploadedSequences">
<ImportedSection
v-if="i === 0"
:index="i"
:sequence="sequence"
:pictures-count="picturesCount"
:upload-errors="sequence.picturesOnError"
:upload-pictures="sequence.pictures"
/>
</template>
</div>
</section>
<template v-for="(sequence, i) in uploadedSequences">
<section v-if="i !== 0" class="information-section">
<ImportedSection
:index="i"
:sequence="sequence"
:pictures-count="picturesCount"
:upload-errors="sequence.picturesOnError"
:upload-pictures="sequence.pictures"
/>
</section>
</template>
</main>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useI18n } from 'vue-i18n'
import InputUpload from '@/components/InputUpload.vue'
import Button from '@/components/Button.vue'
import ImportedSection from '@/components/upload/ImportedSection.vue'
import TooltipBanner from '@/components/upload/TooltipBanner.vue'
import UploadLoader from '@/components/upload/UploadLoader.vue'
import type { sequenceInterface } from './interfaces/UploadPicturesView'
import { formatDate } from '@/utils/dates'
import {
createAPictureToASequence,
createASequence
} from '@/views/utils/upload/request'
import { formatPictureSize, formatTextSize } from '@/views/utils/upload/index'
const { t } = useI18n()
let pictures = ref<FileList | []>([])
let picturesCount = ref<number>(0)
let isLoading = ref<boolean>(false)
let isLoaded = ref<boolean>(false)
let uploadingPictures = ref<FileList | []>([])
let uploadedSequences = ref<sequenceInterface[] | []>([])
let picturesUploadingSize = ref<number>(0)
let picturesToUploadSize = ref<number>(0)
let loadPercentage = ref<string>('0%')
let loadTextSize = ref<string>('0 Mo')
watch(isLoading, () => {
if (isLoading.value) {
window.onbeforeunload = function (e) {
e = e || window.event
if (e) e.returnValue = 'Sure?'
return 'Sure?'
}
} else {
window.onbeforeunload = null
}
})
onUnmounted(() => {
window.onbeforeunload = null
})
onBeforeRouteLeave((to, from, next) => {
if (isLoading.value) {
const answer = window.confirm(t('pages.upload.leave_message'))
if (answer) return next()
return next(false)
}
next()
})
const fileUploaded = computed<string>(() => {
return t('pages.upload.uploaded_files', { count: picturesCount.value })
})
function picturesToUploadSizeText(): void {
let fullSize = 0
for (let i = 0; i < pictures.value.length; i++) {
fullSize += pictures.value[i].size
}
picturesToUploadSize.value = fullSize
loadTextSize.value = formatTextSize(fullSize, formatPictureSize(fullSize))
}
function calcPercentage(): void {
if (picturesUploadingSize.value && picturesToUploadSize.value) {
loadPercentage.value = `${(
(picturesUploadingSize.value / picturesToUploadSize.value) *
100
).toFixed(0)}%`
}
}
function triggerNewUpload(): void {
isLoading.value = false
picturesCount.value = 0
}
function addPictures(value: FileList): void {
pictures.value = value
picturesCount.value = pictures.value.length
picturesUploadingSize.value = 0
picturesToUploadSize.value = 0
loadPercentage.value = '0%'
loadTextSize.value = '0 Mo'
}
function setPictureSizeValue(sequence: sequenceInterface, size: number): void {
picturesUploadingSize.value = picturesUploadingSize.value + size
sequence.pictureSize = formatTextSize(
picturesUploadingSize.value,
formatPictureSize(picturesUploadingSize.value)
)
}
async function uploadPicture(): Promise<void> {
if (!pictures.value || !pictures.value.length) {
return
}
isLoaded.value = false
isLoading.value = true
picturesToUploadSizeText()
const picturesToUpload = [...pictures.value]
const sequenceTitle = `${t('pages.upload.sequence_title')}${formatDate(
new Date(),
'Do MMMM YY, hh:mm:ss'
)}`
const { data } = await createASequence(sequenceTitle)
const sequence: sequenceInterface = {
title: sequenceTitle,
id: data.id,
pictures: [],
picturesOnError: [],
pictureCount: pictures.value.length,
pictureSize: ''
}
uploadedSequences.value = [sequence, ...uploadedSequences.value]
let i = 0
for (let el of picturesToUpload) {
const body = new FormData()
i++
body.append('position', i.toString())
body.append('picture', el)
try {
const pictureUploaded = await createAPictureToASequence(data.id, body)
const pictures = { ...pictureUploaded.data, name: el.name }
setPictureSizeValue(sequence, el.size)
sequence.pictures = [...sequence.pictures, pictures]
const index = uploadedSequences.value.findIndex(
(item: sequenceInterface) => item.id === data.id
)
uploadedSequences.value.splice(index, 1)
uploadedSequences.value = [sequence, ...uploadedSequences.value]
calcPercentage()
} catch (err: any) {
setPictureSizeValue(sequence, el.size)
const picturesOnError = {
message: err.response.data.message,
name: el.name
}
sequence.picturesOnError = [...sequence.picturesOnError, picturesOnError]
calcPercentage()
}
}
isLoaded.value = true
uploadingPictures.value = []
pictures.value = []
}
</script>
<style scoped lang="scss">
ul {
padding-left: 0;
margin-bottom: 0;
}
.entry-page {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--beige-pale);
min-height: calc(100vh - #{toRem(8)});
padding-top: toRem(2);
padding-bottom: toRem(2);
overflow: hidden;
}
.entry-banner {
width: 90%;
margin-bottom: toRem(2);
}
.upload-section {
display: flex;
width: 90%;
}
.information-section {
width: 90%;
background-color: var(--white);
border-radius: toRem(1);
margin-top: toRem(2);
padding: toRem(2);
}
.uploading-information {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.uploading-img {
height: toRem(20);
margin-bottom: toRem(2);
}
.form-upload {
height: toRem(70);
padding: toRem(2);
width: 50%;
background-color: var(--white);
border-radius: toRem(1);
&:first-child {
margin-right: toRem(1);
}
&:last-child {
margin-left: toRem(1);
}
}
.settings-title {
color: var(--blue-dark);
@include text(h3);
margin-bottom: toRem(2);
}
.settings-text {
color: var(--grey-semi-dark);
margin-bottom: toRem(2);
@include text(s-regular);
}
.footer-form {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-top: toRem(3);
}
.number-file-text {
margin-bottom: toRem(2);
color: var(--blue-dark);
@include text(s-regular);
}
::-webkit-scrollbar-track {
background: var(--grey-pale);
}
::-webkit-scrollbar-thumb {
background: var(--blue);
border-radius: toRem(5);
}
@media (max-width: toRem(76.8)) {
.upload-section {
flex-direction: column;
}
.form-upload {
width: 100%;
&:first-child,
&:last-child {
margin: toRem(0);
}
&:first-child {
margin-bottom: toRem(2);
}
}
.information-section {
flex-direction: column;
}
}
@media (max-width: toRem(50)) {
.entry-page {
margin-top: toRem(11);
overflow-y: hidden;
}
.upload-section,
.information-section {
width: 100%;
padding-right: toRem(2);
padding-left: toRem(2);
}
.information-section {
width: 90%;
}
}
</style>

View File

@@ -1,344 +0,0 @@
<template>
<main class="entry-page">
<section id="sec-1" class="section-upload">
<div class="wrapper-upload">
<div class="wrapper-upload-text">
<h1 class="upload-title">{{ $t('pages.upload.title') }}</h1>
<div class="wrapper-check">
<div class="wrapper-img-icon">
<i class="bi bi-images img-icon"></i>
</div>
<div class="element-check">
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type1') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type2') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type3') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type4') }}</span
>
</div>
</div>
<p
class="upload-text"
v-html="
$t('pages.upload.description', {
check: checkImg
})
"
/>
<p class="upload-text">{{ $t('pages.upload.footer_block') }}</p>
<div v-if="!isLogged && authConf.enabled" class="wrapper-account">
<h4 class="account-subtitle">
{{ $t('pages.upload.sub_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.upload.user_account_button')"
type="external"
look="button white"
:path="getAuthRoute('auth/login', 'partager-des-photos')"
/>
</div>
</div>
</div>
<div class="entry-image">
<div class="image">
<img
src="@/assets/images/upload.png"
:alt="$t('pages.upload.alt_img_upload')"
/>
</div>
</div>
</div>
</section>
<div class="entry-button-down">
<Link
:href="hrefSection"
type="external"
:icon="icon"
look="button--rounded"
@trigger="triggerHref"
/>
</div>
<section id="sec-2" class="section-upload">
<div class="wrapper-upload">
<div class="entry-terminal">
<div class="terminal">
<Terminal
:text-upload="$t('pages.upload.terminal_text')"
:text-install="terminalTextInstall"
/>
</div>
</div>
<div class="wrapper-upload-text">
<h2 class="upload-title">{{ $t('pages.upload.title_terminal') }}</h2>
<p
class="upload-text"
v-html="$t('pages.upload.description_terminal')"
></p>
<p
class="upload-text grey"
v-html="$t('pages.upload.footer_description_terminal')"
></p>
<div class="wrapper-account">
<h4 class="account-subtitle">
{{ $t('pages.upload.cli_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.upload.button')"
type="external"
look="button"
path="https://gitlab.com/geovisio/cli"
/>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script lang="ts" setup>
import Link from '@/components/Link.vue'
import Terminal from '@/components/Terminal.vue'
import { useCookies } from 'vue3-cookies'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { getAuthRoute } from '@/utils/auth'
import authConfig from '../composables/auth'
const { cookies } = useCookies()
const { t } = useI18n()
const { authConf } = authConfig()
const route = useRoute()
let hrefSection = ref<string>('#sec-2')
let icon = ref<string>('bi bi-chevron-down')
const checkImg =
"<span style='background:white; padding: 0.5rem 0.525rem;border-radius:50%; font-size:0.8rem;'>✔</span>"
const isLogged = computed((): boolean => !!cookies.get('user_id'))
const terminalTextInstall = computed((): string =>
t('pages.upload.terminal_install')
)
function triggerHref(): void {
icon.value =
route.hash === '#sec-2' ? 'bi bi-chevron-down' : 'bi bi-chevron-up'
hrefSection.value = route.hash === '#sec-2' ? '#sec-1' : '#sec-2'
}
</script>
<style lang="scss" scoped>
.upload-title {
@include text(h1);
}
.account-subtitle {
@include text(h4);
}
.entry-link {
margin-top: 1rem;
}
.upload-text {
@include text(m-regular);
margin-top: 5rem;
}
.wrapper-check {
position: relative;
width: fit-content;
padding: 3rem;
background-color: var(--white);
border-radius: 1.5rem;
font-size: 1.6rem;
margin-top: 8rem;
}
.wrapper-img-icon {
background-color: var(--black);
border-radius: 50%;
position: absolute;
height: 5.5rem;
width: 5.5rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
top: -3rem;
left: 50%;
transform: translate(-50%);
}
.img-icon {
color: var(--white);
}
.element-check {
display: flex;
flex-direction: column;
margin-top: 3rem;
}
.block-check {
margin-bottom: 1rem;
display: flex;
}
.check-border {
display: flex;
justify-content: center;
align-items: center;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
font-size: 0.8rem;
background: var(--white);
border: 0.1rem solid var(--black);
margin-right: 0.5rem;
}
.block-check:last-child {
margin-bottom: 0;
}
.section-upload {
height: 100%;
padding-right: 2rem;
padding-left: 2rem;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 80vh;
}
.section-upload:first-child {
background-color: rgba(236, 236, 236, 0.5);
}
.wrapper-upload {
display: flex;
align-items: initial;
justify-content: center;
}
.wrapper-upload-text {
width: 40%;
white-space: pre-wrap;
}
.grey {
color: var(--grey-semi-dark);
}
.wrapper-account {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 3rem;
padding-top: 1rem;
border-top: 0.1rem solid #e6e6e6;
}
.upload-button {
display: flex;
margin-top: 3rem;
}
.image {
background-color: var(--white);
border-radius: 1rem;
padding: 1rem;
border: 1px solid black;
width: 75%;
height: fit-content;
}
.entry-image {
display: flex;
justify-content: center;
width: 35%;
overflow: hidden;
margin-left: 6rem;
}
.entry-terminal {
margin-left: 0;
margin-right: 6rem;
}
.entry-image img {
width: 100%;
border-radius: 1rem;
}
.entry-button-down {
z-index: 1;
position: fixed;
right: 2rem;
bottom: calc(20vh - 10.5rem);
}
@media (max-width: 1024px) {
.section-upload {
height: initial;
}
.wrapper-upload {
flex-direction: column-reverse;
padding-top: 6rem;
padding-bottom: 6rem;
}
.wrapper-upload-text {
width: 100%;
}
.entry-image {
display: none;
}
.entry-terminal {
width: 100%;
justify-content: initial;
}
.terminal {
width: 100%;
margin-top: 4rem;
}
}
@media (max-width: 768px) {
.entry-page {
padding-top: 11rem;
}
.entry-image {
width: 60%;
margin-right: 0;
margin-left: 0;
}
.entry-terminal {
width: 100%;
}
.wrapper-upload-text {
margin-left: 0;
margin-right: 0;
}
}
@media (max-width: 500px) {
.entry-page {
min-height: calc(100vh - 11rem);
padding-top: 11rem;
}
.entry-image {
width: 100%;
padding-right: 0;
padding-left: 0;
}
.image {
width: 100%;
}
.wrapper-upload-text {
margin-right: 0;
padding-right: 0;
padding-left: 0;
}
.upload-text {
margin-bottom: 3rem;
}
.wrapper-account {
padding-top: 3rem;
flex-direction: column;
}
.account-subtitle {
margin-bottom: 2rem;
}
.entry-button-down {
display: none;
}
}
</style>

View File

@@ -1,11 +1,19 @@
export interface ResponseUserPhotoInterface {
assets: { thumb: { href: string }; hd: { href: string } }
properties: { created: Date; 'geovisio:status': string }
properties: { datetime: Date; 'geovisio:status': string }
id: string
bbox: number[]
}
export interface ResponseUserPhotoLinksInterface {
href: string
rel: string
title?: string
type: string
}
export interface UserSequenceInterface {
id: string
title: string
description: string
license: string
@@ -16,14 +24,16 @@ export interface UserSequenceInterface {
duration: string
camera: string
cameraModel: string
status: string
extent: { temporal: { interval: [Date[]] }; spatial: { bbox: string[] } }
}
export interface ResponseUserSequenceInterface extends UserSequenceInterface {
extent: { temporal: { interval: [Date[]] }; spatial: { bbox: string[] } }
['stats:items']: { count: number }
summaries: {
['pers:interior_orientation']: [{ make: string; model: string }]
}
'geovisio:status': string
}
export interface CheckboxInterface {

View File

@@ -9,7 +9,7 @@ export interface LinkInterface {
['geovisio:status']: string
}
export interface ExtentInterface {
export interface ExtentLinkInterface {
extent: { temporal: { interval: [Date[]] } }
['stats:items']: { count: number }
title: string

View File

@@ -0,0 +1,12 @@
export interface sequenceInterface {
title: string
id: string
pictures: any[]
picturesOnError: uploadErrorInterface[] | []
pictureCount: number
pictureSize: string
}
export interface uploadErrorInterface {
message: string
name: string
}

View File

@@ -1,8 +1,14 @@
export interface ViewerMapInterface {
map: {
startWide: boolean
style: object
maxZoom: number
export interface OptionalViewerMapInterface {
fetchOptions?: {
credentials: string
}
picId?: string
}
export interface ViewerMapInterface extends OptionalViewerMapInterface {
map: {
startWide: boolean
style?: object | string
maxZoom: number
}
}

View File

@@ -0,0 +1,61 @@
import type {
ResponseUserPhotoInterface,
ResponseUserPhotoLinksInterface
} from '@/views/interfaces/MySequenceView'
function imageStatus(imageStatus: string, sequenceStatus: string): string {
if (sequenceStatus === 'hidden') return sequenceStatus
return imageStatus
}
function scrollIntoSelected(
id: string,
userPhotos: ResponseUserPhotoInterface[]
): void {
const itemPosition = userPhotos.map((el) => el.id).indexOf(id)
const elementTarget = document.querySelector(`#photo${itemPosition}`)
if (elementTarget) elementTarget.scrollIntoView()
}
function photoToDeleteOrPatchSelected(
item: ResponseUserPhotoInterface,
imagesToDelete: string[]
): boolean {
return imagesToDelete.includes(item.id)
}
function spliceIntoChunks(arr: string[], chunkSize: number) {
const res = []
arr = ([] as string[]).concat(...arr)
while (arr.length) {
res.push(arr.splice(0, chunkSize))
}
return res
}
function formatPaginationItems(
items: [ResponseUserPhotoLinksInterface]
): ResponseUserPhotoLinksInterface[] {
const filterItems = items.filter(
(el: ResponseUserPhotoLinksInterface) =>
el.rel === 'first' ||
el.rel === 'last' ||
el.rel === 'next' ||
el.rel === 'prev'
)
return filterItems.map((el: ResponseUserPhotoLinksInterface) => {
if (el.rel === 'first') return { ...el, rel: 'double-left' }
if (el.rel === 'last') return { ...el, rel: 'double-right' }
if (el.rel === 'next') return { ...el, rel: 'right' }
if (el.rel === 'prev') return { ...el, rel: 'left' }
return el
})
}
export {
imageStatus,
scrollIntoSelected,
photoToDeleteOrPatchSelected,
spliceIntoChunks,
formatPaginationItems
}

View File

@@ -0,0 +1,73 @@
import axios from 'axios'
import type {
ResponseUserPhotoInterface,
ResponseUserPhotoLinksInterface,
ResponseUserSequenceInterface
} from '@/views/interfaces/MySequenceView'
function deleteACollection(collectionId: string | string[]): Promise<unknown> {
return axios.delete(`api/collections/${collectionId}`)
}
function patchACollection(
collectionId: string | string[],
visible: string
): Promise<unknown> {
return axios.patch(`api/collections/${collectionId}`, { visible: visible })
}
function deleteACollectionItem(
collectionId: string | string[],
itemId: string
): Promise<unknown> {
return axios.delete(`api/collections/${collectionId}/items/${itemId}`)
}
function patchACollectionItem(
isVisible: string,
collectionId: string | string[],
itemId: string
): Promise<unknown> {
return axios.patch(`api/collections/${collectionId}/items/${itemId}`, {
visible: isVisible
})
}
async function fetchCollectionItems(
collectionId: string | string[],
limit: string,
fullUrl?: string
): Promise<{
data: {
features: [ResponseUserPhotoInterface]
links: [ResponseUserPhotoLinksInterface]
}
}> {
if (fullUrl) return await axios.get(fullUrl)
return await axios.get(`api/collections/${collectionId}/items${limit}`)
}
async function fetchCollectionItemsWithFullUrl(fullUrl: string): Promise<{
data: {
features: [ResponseUserPhotoInterface]
links: [ResponseUserPhotoLinksInterface]
}
}> {
return await axios.get(fullUrl)
}
async function fetchCollection(collectionId: string | string[]): Promise<{
data: ResponseUserSequenceInterface
}> {
return await axios.get(`api/collections/${collectionId}`)
}
export {
deleteACollectionItem,
patchACollectionItem,
fetchCollectionItems,
fetchCollection,
deleteACollection,
patchACollection,
fetchCollectionItemsWithFullUrl
}

View File

@@ -0,0 +1,8 @@
function formatPictureSize(size: number): number {
return Math.floor(Math.log(size) / Math.log(1024))
}
function formatTextSize(size: number, i: number): string {
const sizes = ['0', 'Ko', 'Mo', 'Go', 'To', 'Po', 'Eo', 'Zo', 'o']
return `${parseFloat((size / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`
}
export { formatPictureSize, formatTextSize }

View File

@@ -0,0 +1,11 @@
import axios from 'axios'
function createASequence(title: string): Promise<any> {
return axios.post('api/collections', { title: title })
}
async function createAPictureToASequence(id: string, body: any): Promise<any> {
return await axios.post(`api/collections/${id}/items`, body)
}
export { createASequence, createAPictureToASequence }

View File

@@ -8,8 +8,10 @@ export default defineConfig({
server: {
host: true,
port: 5173,
strictPort: true,
hmr: {
port: 9000
clientPort: 5173,
overlay: false
}
},
base: '/',
@@ -17,7 +19,8 @@ export default defineConfig({
css: {
preprocessorOptions: {
scss: {
additionalData: '@import "@/assets/font-size.scss";'
additionalData:
'@import "@/assets/font-size.scss"; @import "@/assets/rem-calc.scss";'
}
}
},

28
vitest.config.js Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => {
return tag.startsWith('router-link')
}
}
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'vue-i18n': 'vue-i18n/dist/vue-i18n.cjs.js'
}
},
test: {
deps: {
inline: ['moment']
}
}
})

2921
yarn.lock

File diff suppressed because it is too large Load Diff