152 Commits

Author SHA1 Message Date
Adrien Pavie
9854ac9afd Update file mapAndViewer.ts 2023-09-04 13:10:58 +00:00
Adrien Pavie
d72f01bbe0 Merge branch 'feature/docker2' into 'develop'
Improve Dockerfile

See merge request geovisio/website!83
2023-08-31 08:26:07 +00:00
Adrien Pavie
f9d1d154e0 Improve Dockerfile 2023-08-31 08:26:07 +00:00
Jean Andreani
d3a83f5157 Merge branch 'feature/gvs-213' into 'develop'
Web viewer update to 2.1.3, minor docs updates

See merge request geovisio/website!82
2023-08-30 11:01:52 +00:00
Adrien Pavie
c551ebdb21 Web viewer update to 2.1.3, minor docs updates 2023-08-30 11:01:52 +00:00
Andreani Jean
8f7e35dc43 Release 2.1.0 2023-08-29 14:56:25 +02:00
Jean Andreani
e0b17ae423 Merge branch 'fix-url-upload-responsive-sequences' into 'develop'
fix css my sequences

See merge request geovisio/website!81
2023-08-29 12:13:22 +00:00
Andreani Jean
876d0f1683 fix css my sequences 2023-08-29 14:08:02 +02:00
Jean Andreani
86c0e59c14 Merge branch 'fix-url-upload-responsive-sequences' into 'develop'
fix url upload to /envoyer + fix css responsive

See merge request geovisio/website!80
2023-08-29 10:24:34 +00:00
Andreani Jean
25c5833e26 fix url upload to /envoyer + fix css responsive 2023-08-29 12:21:42 +02:00
Jean Andreani
9e4b1f87f4 Merge branch 'feat-add-sort-file' into 'develop'
Feat add sorting for file upload

See merge request geovisio/website!79
2023-08-29 09:51:05 +00:00
Jean Andreani
35d95a85f8 Feat add sorting for file upload 2023-08-29 09:51:05 +00:00
Andreani Jean
6383fafcbf add fix button 2023-08-28 15:04:32 +02:00
Jean Andreani
c64604e4b7 Merge branch 'fix-authconf' into 'main'
fix-authconf

See merge request geovisio/website!78
2023-08-28 09:37:15 +00:00
Jean Andreani
197cdba0ca fix-authconf 2023-08-28 09:37:14 +00:00
Jean Andreani
456704f449 Merge branch 'feat/upload-interface' into 'main'
Feat/upload interface

See merge request geovisio/website!70
2023-08-28 09:23:20 +00:00
Jean Andreani
7f570e4086 Feat/upload interface 2023-08-28 09:23:19 +00:00
Adrien Pavie
0d39d522a5 Merge branch 'feature/gvs-212' into 'develop'
Update GeoVisio to 2.1.2

See merge request geovisio/website!77
2023-08-20 20:22:57 +00:00
Adrien Pavie
b708543df1 Update GeoVisio to 2.1.2 2023-08-20 22:18:36 +02:00
Adrien Pavie
fe8112356e Merge branch 'feature/docker' into 'main'
Add Dockerfile and CI Docker Hub deployment (fixes #16)

Closes #16

See merge request geovisio/website!74
2023-08-16 08:09:09 +00: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
Adrien Pavie
92fc0302a4 Add Dockerfile and CI Docker Hub deployment (fixes #16) 2023-08-03 09:02:44 +02: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
Andreani Jean
a9ecd0b2a4 Release 0.1.0 2023-07-04 22:23:15 +02:00
Andreani Jean
9b704e2275 fix tests 2023-07-04 22:16:22 +02:00
Andreani Jean
f9d385f3d9 fix router guards 2023-07-04 21:27:23 +02:00
Andreani Jean
6a75b53245 fix auth guard 2023-07-04 15:26:13 +02:00
Andreani Jean
a2d793e448 fix userPhotos 2023-07-04 12:14:01 +02:00
Andreani Jean
ae80e1b4bd wip 2023-07-04 12:09:49 +02:00
Andreani Jean
764e355295 wip 2023-07-04 11:21:04 +02:00
Andreani Jean
c110d60f46 wip 2023-07-04 11:18:19 +02:00
Andreani Jean
52c2ea5d79 wip 2023-07-04 11:00:18 +02:00
Andreani Jean
d4e61b82fb add click outside menu 2023-07-04 10:02:04 +02:00
Andreani Jean
e2a67eba90 test new guard router 2023-07-04 09:27:32 +02:00
Andreani Jean
a31d076721 test new guard router 2023-07-04 09:02:04 +02:00
Andreani Jean
c5ff54e1cd test 2023-07-03 23:48:26 +02:00
Andreani Jean
d5862cc021 fix header links 2023-07-03 23:02:30 +02:00
Andreani Jean
d71c0ee835 fix ts error 2023-07-03 22:43:58 +02:00
Andreani Jean
972615bd12 fix header + router guard 2023-07-03 17:46:00 +02:00
Andreani Jean
ef1bd0105b fix responsive css 2023-07-03 15:35:17 +02:00
Andreani Jean
ceb0034131 fix css sequence responsive 2023-07-03 15:19:14 +02:00
Andreani Jean
d86426ed33 fix some css 2023-07-03 11:59:21 +02:00
Andreani Jean
9c76f4925d fix some tests 2023-07-03 11:36:36 +02:00
Andreani Jean
74543334c6 fix wording 2023-07-03 11:19:23 +02:00
Andreani Jean
6bddda9e17 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
4ece6669d6 add status sequence 2023-07-03 11:19:23 +02:00
Andreani Jean
94cf617a4f fix sequences 2023-07-03 11:19:23 +02:00
Andreani Jean
d1f240dcce test getItems after call 2023-07-03 11:19:23 +02:00
Andreani Jean
d1207e656b some fix 2023-07-03 11:19:23 +02:00
Andreani Jean
f5a936fad6 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
43838a4331 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
198fe69508 log 2023-07-03 11:19:23 +02:00
Andreani Jean
99709594fe test 2023-07-03 11:19:23 +02:00
Andreani Jean
e82e8b6841 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
671664d84f wip 2023-07-03 11:19:23 +02:00
Andreani Jean
e8eb3a462c wip 2023-07-03 11:19:23 +02:00
Andreani Jean
9cdfd16dc0 check with for of 2023-07-03 11:19:23 +02:00
Andreani Jean
78586932c4 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
33c777bfc6 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
8c265cf403 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
2d8b35fd88 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
5cf899ae4d wip 2023-07-03 11:19:23 +02:00
Andreani Jean
57f01bbafb wip 2023-07-03 11:19:23 +02:00
Andreani Jean
aecf7a41a6 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
2ffa09c622 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
e32a3682b9 wip 2023-07-03 11:19:23 +02:00
Andreani Jean
5778387706 log debug 2023-07-03 11:19:23 +02:00
Andreani Jean
f69aba17ea test log 2023-07-03 11:19:23 +02:00
Andreani Jean
165ddef725 fix ts 2023-07-03 11:19:23 +02:00
Andreani Jean
2b5f515bf5 some fixs after user testing 2023-07-03 11:19:23 +02:00
Andreani Jean
e99bd3f39b wip 2023-07-03 11:19:22 +02:00
Andreani Jean
39d2839bb4 fix tests 2023-07-03 11:19:18 +02:00
Andreani Jean
f72d1f0177 ip 2023-07-03 11:18:35 +02:00
Andreani Jean
2e9936be3e rebase main 2023-07-03 11:18:35 +02:00
Andreani Jean
70987bd363 edit button control 2023-07-03 11:18:35 +02:00
Andreani Jean
423b77f217 fix typescript error getAuthRoute 2023-07-03 11:18:35 +02:00
Andreani Jean
ed1e3afe53 edit changelog + add test sequence list 2023-07-03 11:18:35 +02:00
Andreani Jean
a4e56aa8ae fix shift selection 2023-07-03 11:18:35 +02:00
Adrien Pavie
4b92e8230c Handle next_url callbacks for localhost 2023-07-03 11:18:35 +02:00
Andreani Jean
b601aa7c2a fix type 2023-07-03 11:18:35 +02:00
Andreani Jean
4e600d2907 add loading 2023-07-03 11:18:34 +02:00
Andreani Jean
bbd372b672 fix waiting process img button + add toast success & error 2023-07-03 11:18:34 +02:00
Andreani Jean
6aff4dc3c8 add scroll into viex 2023-07-03 11:18:34 +02:00
Andreani Jean
836c8bf3d3 return value mounted 2023-07-03 11:18:34 +02:00
Andreani Jean
0c5b3a306e fix id error 2023-07-03 11:18:34 +02:00
Andreani Jean
ad5e83a4f4 add filter for only ready element 2023-07-03 11:18:34 +02:00
Andreani Jean
e8b8921cc0 some css button disable 2023-07-03 11:18:34 +02:00
Andreani Jean
aa4b93d3d5 fix border img 2023-07-03 11:18:34 +02:00
Andreani Jean
7d97442de9 fix hidden status"
git push
2023-07-03 11:18:34 +02:00
Andreani Jean
5a8d8d2ab9 fix style disable 2023-07-03 11:18:34 +02:00
Andreani Jean
deaffaeef4 await 2023-07-03 11:18:34 +02:00
Andreani Jean
63c1b869fa wip 2023-07-03 11:18:34 +02:00
Andreani Jean
761aaac1e1 test without await 2023-07-03 11:18:34 +02:00
Andreani Jean
e09db186e7 good api route 2023-07-03 11:18:34 +02:00
Andreani Jean
67e3a646e5 remove id test 2023-07-03 11:18:34 +02:00
Andreani Jean
c4b2fae7c5 factoring 2023-07-03 11:18:33 +02:00
Andreani Jean
d957d2b67f me/catalogue without credentials 2023-07-03 11:17:24 +02:00
antoine-de
a497b81d88 use creds with axios on every calls in mysequence 2023-07-03 11:17:24 +02:00
antoine-de
d83c1f9195 use credentials and user's catalog 2023-07-03 11:17:24 +02:00
Andreani Jean
e011eea200 add no sequence text 2023-07-03 11:17:24 +02:00
Andreani Jean
7937900637 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
7540f10400 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
8a57342d75 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
995fa0be27 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
ffb4d6d0ae wip 2023-07-03 11:17:24 +02:00
Andreani Jean
4a2bc26bda testing hidde 2023-07-03 11:17:24 +02:00
Andreani Jean
4ce0751ce4 add new version 2023-07-03 11:17:24 +02:00
Andreani Jean
d63f367bf4 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
98c471118b wip 2023-07-03 11:17:24 +02:00
Andreani Jean
210f2abd54 fix ts errors 2023-07-03 11:17:24 +02:00
Andreani Jean
8e05e793d6 add patch to test 2023-07-03 11:17:24 +02:00
Andreani Jean
e092e1f064 wip 2023-07-03 11:17:24 +02:00
Andreani Jean
7e233f1cb3 wip 2023-07-03 11:17:22 +02:00
Andreani Jean
c913711c9a test 2023-07-03 11:16:44 +02:00
Adrien Pavie
529bae421b Update yarn.lock 2023-06-30 17:56:33 +02:00
Adrien Pavie
451d7afb49 Merge branch 'feature/maj-geovisio' into 'main'
Update file package.json

See merge request geovisio/website!60
2023-06-30 15:24:55 +00:00
Adrien Pavie
e5cf0197df Update file package.json 2023-06-30 14:55:07 +00:00
Jean Andreani
96971cc4b1 Merge branch 'fix/burger-beta-text' into 'main'
fix: add beta text + remove burger menu

See merge request geovisio/website!59
2023-06-19 15:26:22 +00:00
Jean Andreani
ad039d0480 fix: add beta text + remove burger menu 2023-06-19 15:26:22 +00:00
antoine-de
1c1689268b Merge branch 'update_viewer_2_0_5' into 'main'
upgrade to geovisio viewer 2.0.5

See merge request geovisio/website!58
2023-06-15 14:34:38 +00:00
antoine-de
84a6aa0c77 upgrade to geovisio viewer 2.0.5 2023-06-15 15:08:36 +02:00
Jean Andreani
061b3ab3b3 Merge branch 'feature/docs' into 'main'
Update docs

See merge request geovisio/website!56
2023-06-14 15:21:31 +00:00
Adrien Pavie
c5f9a6da06 Update docs 2023-06-14 15:21:31 +00:00
antoine-de
1e509c48c2 Merge branch 'update_viewer_2_0' into 'main'
update geovisio viewer to latest 2.0.2 version

See merge request geovisio/website!55
2023-06-08 16:24:38 +00:00
antoine-de
6d3112ea0e update geovisio viewer to latest 2.0.2 version 2023-06-08 16:24:38 +00:00
98 changed files with 7201 additions and 9775 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
docs/
.git/
.idea/
dist/
node_modules/
*.md
LICENSE

2
.env
View File

@@ -1,2 +0,0 @@
VITE_API_URL=https://geovisio-backend-dev.osc-fr1.scalingo.io/
VITE_ENV=dev

View File

@@ -29,7 +29,8 @@ module.exports = {
rules: {
'vue/require-default-prop': 'off',
'prettier/prettier': 'error',
'@typescript-eslint/no-namespace': 'off'
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-non-null-assertion': 'off'
// override/add rules settings here, such as:
// 'vue/no-unused-vars': 'error'
}

5
.gitignore vendored
View File

@@ -90,5 +90,6 @@ sw.*
*.swp
# Cypress generated screen and videos files
src/tests/cypress/screenshot/
src/tests/cypress/videos/
cypress/screenshot/
cypress/videos/
*.cy.ts.mp4

View File

@@ -5,6 +5,12 @@ stages:
variables:
CYPRESS_CACHE_FOLDER: '$CI_PROJECT_DIR/cache/Cypress'
DOCKER_BUILDKIT: 1 # use buildkit for better performance
DOCKER_DRIVER: overlay2 # better docker driver to avoid copying too many files on each run
GITLAB_REGISTRY: registry.gitlab.com # We use docker.io for official images and gitlab's registry to store temporary images
IMAGE_NAME: geovisio/api
CI_IMAGE_CACHE: $GITLAB_REGISTRY/$IMAGE_NAME:build_cache
DOCKER_TLS_CERTDIR: ""
before_script:
## chmod is unfortunately currently mandatory : https://github.com/nodejs/docker-node/issues/661
@@ -34,6 +40,8 @@ test:e2e:
script:
- yarn install
- ./node_modules/.bin/cypress install
- echo "VITE_API_URL=https://geovisio-proxy-dev.osc-fr1.scalingo.io/" > .env
- echo "VITE_ENV=dev" >> .env
- PORT=5173 yarn start &
- yarn test:e2e
coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
@@ -54,5 +62,72 @@ deploy:
CI: 1
script:
- yarn install
- yarn upgrade geovisio@develop
- yarn build
deploy:develop:
rules:
# run job only for fork that have the credentials to pull images from the gitlab-registry
# and only for merge on 'develop' branch
- if: $CI_DEPLOY_PASSWORD == null || $CI_DEPLOY_USER == null
when: never
- if: $CI_COMMIT_REF_SLUG == "develop"
stage: Deploy
image: docker:latest
services:
- docker:dind
before_script:
# login to the gitlab docker registry to use the cache and to publish
- echo $CI_DEPLOY_PASSWORD | docker login -u $CI_DEPLOY_USER --password-stdin $GITLAB_REGISTRY
- docker buildx create --use --name "geovisio-image-builder" --driver=docker-container # use docker-container driver to be able to publish a full cache
# login to dockerhub
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
# build image using repository as cache
- docker buildx build
--cache-from "type=registry,ref=$CI_IMAGE_CACHE"
--cache-to "type=registry,mode=max,ref=$CI_IMAGE_CACHE"
--tag "$CI_REGISTRY_IMAGE:develop"
--label "org.opencontainers.image.title=$CI_PROJECT_TITLE"
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.created=$CI_JOB_STARTED_AT"
--label "org.opencontainers.image.revision=$CI_COMMIT_SHORT_SHA"
--load
--progress=plain
.
# publish image to dockerhub with the develop tag
- docker push "$CI_REGISTRY_IMAGE:develop"
deploy:latest:
# we consider that tag always land on main
# and they always should publish a tagged image and the `latest` docker image
only:
- tags
stage: Deploy
image: docker:latest
services:
- docker:dind
before_script:
# login to the gitlab docker registry to use the cache and to publish
- echo $CI_DEPLOY_PASSWORD | docker login -u $CI_DEPLOY_USER --password-stdin $GITLAB_REGISTRY
- docker buildx create --use --name "geovisio-image-builder" --driver=docker-container # use docker-container driver to be able to publish a full cache
# login to dockerhub
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
script:
# build image using repository as cache
- docker buildx build
--cache-from "type=registry,ref=$CI_IMAGE_CACHE"
--cache-to "type=registry,mode=max,ref=$CI_IMAGE_CACHE"
--tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
--tag "$CI_REGISTRY_IMAGE:latest"
--label "org.opencontainers.image.title=$CI_PROJECT_TITLE"
--label "org.opencontainers.image.url=$CI_PROJECT_URL"
--label "org.opencontainers.image.created=$CI_JOB_STARTED_AT"
--label "org.opencontainers.image.revision=$GIT_DESCRIBE"
--load
--progress=plain
.
# publish image to dockerhub
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
- docker push $CI_REGISTRY_IMAGE:latest

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Before _0.1.0_, website development was on rolling release, meaning there are no version tags.
## [Unreleased]
### Changed
- GeoVisio web viewer upgraded to 2.1.3, [with alls its changes embedded](https://gitlab.com/geovisio/web-viewer/-/blob/develop/CHANGELOG.md?ref_type=heads#213-2023-08-30).
- Dockerfile creates smaller and faster containers, using pre-built website and Nginx for HTTP serving.
## [2.1.0] - 2023-08-29
### Added
- A new page `/envoyer` to upload picture with an interface ([#13](https://gitlab.com/geovisio/website/-/issues/13)) :
- the user can upload multiples pictures with the interface
- the pictures are sorted by name
- the user can see all the pictures uploaded and all the errors
### Changed
- Website releases now follow the synced `MAJOR.MINOR` API version rule, meaning that any version >= 2.1 of the website will be compatible with corresponding [GeoVisio API](https://gitlab.com/geovisio/api) version.
### Fixed
- fix a bug in the header hidden sub menu when authentication is not with keycloak
## [0.1.0] - 2023-07-04
### Added
- A new page `/mes-sequences` to access to a list of sequences for a logged user ([#14](https://gitlab.com/geovisio/website/-/issues/14)) :
- the user can see all his sequences
- the user can filter sequences
- the user can enter to a specific sequence
- A new page `/sequence/:id` to access to a sequence of photos for a logged user ([#14](https://gitlab.com/geovisio/website/-/issues/14)) :
- the user can see the sequence on the map and move on the map from photos to photos
- the user can see information about the sequence
- the user can see all the sequence's photos
- the user can disable and delete one or many photo(s) of the sequence
### Changed
- Header have now a new entry `Mes photos` when the user is logged to access to the sequence list
- The router guard for logged pages has been changed to not call the api to check the token
[Unreleased]: https://gitlab.com/geovisio/website/-/compare/2.1.0...develop
[2.1.0]: https://gitlab.com/geovisio/website/-/compare/0.1.0...2.1.0
[0.1.0]: https://gitlab.com/geovisio/website/-/commits/0.1.0

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
#--------------------------------------------------------------
#- Build image
#-
FROM node:18.16.0-alpine AS build
WORKDIR /opt/geovisio
# Import dependencies files
COPY package.json yarn.lock ./
# Install NodeJS dependencies
RUN yarn install --frozen-lockfile
# Import source code
COPY static ./static
COPY src ./src
COPY *.json *.js *.ts *.html ./
# Replace env variables by placeholder for dynamic change on container start
ENV VITE_INSTANCE_NAME=DOCKER_VITE_INSTANCE_NAME
ENV VITE_API_URL=DOCKER_VITE_API_URL
ENV VITE_TILES=DOCKER_VITE_TILES
# Build code
ENV PORT=3000
RUN yarn deploy
#--------------------------------------------------------------
#- Final image
#-
FROM nginx:1-alpine
RUN apk add bash
# Retrieve files from build
COPY --from=build /opt/geovisio/dist /usr/share/nginx/html
# Add Docker scripts and Nginx conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/docker-entrypoint.sh /etc/nginx/docker-entrypoint.sh
RUN chmod +x /etc/nginx/docker-entrypoint.sh
# Define env variables defaults
ENV VITE_INSTANCE_NAME="GeoVisio/Docker"
ENV VITE_API_URL="https://panoramax.openstreetmap.fr"
ENV VITE_TILES="https://tile-vect.openstreetmap.fr/styles/basic/style.json"
# Start Nginx
EXPOSE 3000
ENTRYPOINT ["/etc/nginx/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]

119
README.md
View File

@@ -1,109 +1,60 @@
# panoramax-website
# ![GeoVisio](https://gitlab.com/geovisio/api/-/raw/develop/images/logo_full.png)
Welcome to the Panoramax website documentation !
[Panoramax](http://panoramax.ign.fr/) is a website where you can upload a lots of photos to see them in map web viewer based on [Geovisio](https://gitlab.com/geovisio).
__GeoVisio__ is a complete solution for storing and __serving your own 📍📷 geolocated pictures__ (like [StreetView](https://www.google.com/streetview/) / [Mapillary](https://mapillary.com/)).
## Technologies
➡️ __Give it a try__ at [panoramax.ign.fr](https://panoramax.ign.fr/) or [geovisio.fr](https://geovisio.fr/viewer) !
- Frontend website made in [Vue 3](https://vuejs.org/guide/introduction.html)
- Project use [Vite](https://vitejs.dev/guide/) who offer a fast development server and an optimized compilation for production (like webpack)
- The style is made with CSS/SASS and the [bootstrap library](https://getbootstrap.com/)
- [Typescript](https://www.typescriptlang.org/) used to type
- [Jest](https://jestjs.io/fr/) used for unit testing
## 📦 Components
## Configuration
GeoVisio is __modular__ and made of several components, each of them standardized and ♻️ replaceable.
All the commands and packages used are available in the `package.json` file.
![GeoVisio architecture](https://gitlab.com/geovisio/api/-/raw/develop/images/big_picture.png)
You can change the vite server configuration in the `vite.config.ts` file. See [Vite Configuration Reference](https://vitejs.dev/config/) if you need.
All of them are 📖 __open-source__ and available online:
## Project Setup
| 🌐 Server | 💻 Client |
|:-----------------------------------------------------------------------:|:----------------------------------------------------:|
| [API](https://gitlab.com/geovisio/api) | [Website](https://gitlab.com/geovisio/website) |
| [Blur API](https://gitlab.com/geovisio/blurring) | [Web viewer](https://gitlab.com/geovisio/web-viewer) |
| [GeoPic Tag Reader](https://gitlab.com/geovisio/geo-picture-tag-reader) | [Command line](https://gitlab.com/geovisio/cli) |
**You need to have [Nodejs installed](https://nodejs.org/en/download)**
Node version : >=18.13.0
**You need to have [Npm installed](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)**
# 💻 GeoVisio Website
You can use npm or [yarn](https://yarnpkg.com/) as package manager
This repository only contains __the web front-end of GeoVisio__.
Install all dependencies :
Note that the 📷 __web viewer__ (component showing pictures and their location on a map) is in [a separate, dedicated repository](https://gitlab.com/geovisio/web-viewer).
```sh
npm install
```
## ⚙️ Features
or
The website offers these functionalities:
```sh
yarn install
```
- Display of pictures and their location (using the embed [web viewer](https://gitlab.com/geovisio/web-viewer))
- Handle user authentication and account management
- Show simple to read documentation
### Compile and Hot-Reload for Development
## 🕮 Documentation
Launch your dev server :
[A full documentation](./docs/) is available to help you through the install, setup and usage of the GeoVisio website.
```sh
npm run dev
```
## 💁 Contributing
or
Pull requests are welcome. For major changes, please open an [issue](https://gitlab.com/geovisio/website/-/issues) first to discuss what you would like to change.
```sh
yarn dev
```
## 🤗 Special thanks
### Run Unit Tests with [Vitest](https://vitest.dev/)
![Sponsors](https://gitlab.com/geovisio/api/-/raw/develop/images/sponsors.png)
```sh
npm run test:unit
```
GeoVisio was made possible thanks to a group of ✨ __amazing__ people ✨ :
or
- __[GéoVélo](https://geovelo.fr/)__ team, for 💶 funding initial development and for 🔍 testing/improving software
- __[Carto Cité](https://cartocite.fr/)__ team (in particular Antoine Riche), for 💶 funding improvements on viewer (map browser, flat pictures support)
- __[La Fabrique des Géocommuns (IGN)](https://www.ign.fr/institut/la-fabrique-des-geocommuns-incubateur-de-communs-lign)__ for offering long-term support and funding the [Panoramax](https://panoramax.fr/) initiative and core team (Camille Salou, Mathilde Ferrey, Christian Quest, Antoine Desbordes, Jean Andreani, Adrien Pavie)
- Many _many_ __wonderful people__ who worked on various parts of GeoVisio or core dependencies we use : 🧙 Stéphane Péneau, 🎚 Albin Calais & Cyrille Giquello, 📷 [Damien Sorel](https://www.strangeplanet.fr/), Pascal Rhod, Nick Whitelegg...
- __[Adrien Pavie](https://pavie.info/)__, for ⚙️ initial development of GeoVisio
- And you all ✨ __GeoVisio users__ for making this project useful !
```sh
yarn test:unit
```
### Lint with [ESLint](https://eslint.org/)
## ⚖️ License
```sh
npm run lint
```
or
```sh
yarn lint
```
### Build in Production
In your production app, you must set some env variables [like the .env.example file here](https://gitlab.com/geovisio/website/-/blob/main/.env.example)
```sh
npm install
npm run build
npm run start
```
or
```sh
yarn install
yarn build
yarn start
```
## Instance customization
### Wordings
- All the wordings of the website are on this [locale file](https://gitlab.com/geovisio/website/-/blob/main/src/locales/fr.json)
- You can change the title `"title": "Instance Panoramax IGN"` of your instance on the [locale file](https://gitlab.com/geovisio/website/-/blob/main/src/locales/fr.json)
- In the same [locale file](https://gitlab.com/geovisio/website/-/blob/main/src/locales/fr.json) you can change the meta data wordings inside the `"meta": {}` object
- You can change the instance name inside the documentation of the page /partager-des-photos for the keys `"terminal_text_logged"` and `"terminal_text_not_logged"` on the [locale file](https://gitlab.com/geovisio/website/-/blob/main/src/locales/fr.json)
### Images
- If you want to change the logo in the header you can replace the logo.jpeg in the [assets/images folder](https://gitlab.com/geovisio/geovisio_website/-/tree/main/src/assets/images) file by your own jpeg logo with the same file name
- You can change the favicon [inside the static folder](https://gitlab.com/geovisio/website/-/tree/main/static)
Copyright (c) GeoVisio team 2022-2023, [released under MIT license](./LICENSE).

View File

@@ -12,11 +12,6 @@ export default defineConfig({
setupNodeEvents(on, config) {
// implement node event listeners here
},
baseUrl: 'http://localhost:5173',
supportFile: 'src/tests/cypress/support/e2e.{js,jsx,ts,tsx}',
specPattern: 'src/tests/cypress/**/*.cy.{js,jsx,ts,tsx}',
fixturesFolder: 'src/tests/cypress/fixtures',
videosFolder: 'src/tests/cypress/videos',
screenshotsFolder: 'src/tests/cypress/screenshot'
baseUrl: 'http://localhost:5173'
}
})

View File

@@ -1,6 +1,6 @@
describe('In the login page', () => {
it('type in the form to login', () => {
cy.visit('https://geovisio-backend-dev.osc-fr1.scalingo.io/api/auth/login')
cy.visit('https://geovisio-proxy-dev.osc-fr1.scalingo.io/api/auth/login')
cy.get('#password').type('coucouc')
})
})

View File

@@ -1,4 +1,4 @@
describe('In the upload page', () => {
describe('In the login 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": "À propos"
}

View File

@@ -0,0 +1,16 @@
#!/bin/bash
ROOT_DIR=/usr/share/nginx/html
DOCKER_VARS=(VITE_INSTANCE_NAME VITE_API_URL VITE_TILES)
echo "Setting env variables in web files"
for file in $ROOT_DIR/assets/*.js $ROOT_DIR/index.html; do
echo "Processing $file...";
for i in ${!DOCKER_VARS[@]}; do
sed -i "s|DOCKER_${DOCKER_VARS[i]}|${!DOCKER_VARS[i]}|g" $file
done
done
echo "GeoVisio website is now ready !"
exec "$@"

22
docker/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
events {
worker_connections 1024;
}
http {
include mime.types;
sendfile on;
server {
listen 3000;
listen [::]:3000;
resolver 127.0.0.11;
autoindex off;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
gzip_static on;
}
}

29
docs/01_Start.md Normal file
View File

@@ -0,0 +1,29 @@
# GeoVisio Website hands-on guide
![GeoVisio logo](https://gitlab.com/geovisio/api/-/raw/develop/images/logo_full.png)
Welcome to GeoVisio __Website__ documentation ! It will help you through all phases of setup, run and develop on GeoVisio Website.
__Note that__ this only covers the Website / front-end component, if you're looking for docs on another component, you may go to [this page](https://gitlab.com/geovisio) instead.
Also, if at some point you're lost or need help, you can contact us through [issues](https://gitlab.com/geovisio/website/-/issues) or by [email](mailto:panieravide@riseup.net).
## Architecture
The website relies on the following technologies and components:
- Frontend website made in [Vue 3](https://vuejs.org/guide/introduction.html)
- Project use [Vite](https://vitejs.dev/guide/) who offer a fast development server and an optimized compilation for production (like webpack)
- The style is made with CSS/SASS and the [bootstrap library](https://getbootstrap.com/)
- [Typescript](https://www.typescriptlang.org/) used to type
- [Jest](https://jestjs.io/fr/) used for unit testing
## All the docs
You might want to dive into docs :
- [Install and setup](./02_Setup.md)
- [Change the settings](./03_Settings.md)
- [Work on the code](./09_Develop.md)

82
docs/02_Setup.md Normal file
View File

@@ -0,0 +1,82 @@
# Setup
GeoVisio website can be installed through classic method, or using Docker.
__Contents__
[[_TOC_]]
## Classic install
### System requirements
**You need to have [Nodejs installed](https://nodejs.org/en/download)**
Node version : >=18.13.0
**You need to have [Npm installed](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)**
You can use npm or [yarn](https://yarnpkg.com/) as package manager
### Install
The website can be installed locally by retrieving this repository and installing dependencies:
```sh
# Retrieve source code
git clone https://gitlab.com/geovisio/website.git
cd website/
# Install dependencies
npm install
```
### Build for production
Before building, you need to define a bit of settings. At least, you have to create a `.env` file and edit its content.
```sh
cp env.example .env
```
More details about settings [can be found in docs here](./03_Settings.md).
Then, building for production can be done with these commands:
```sh
npm run build
PORT=3000 npm run start
```
The website is now available at [localhost:3000](http://localhost:3000).
## Docker setup
The [Docker](https://docs.docker.com/get-docker/) deployment is a really convenient way to have a Geovisio website running in an easy and fast way. Note that this setup documentation only covers __GeoVisio front-end__ (website), if you also need an API running, please refer to [Docker API deployment](https://gitlab.com/geovisio/api/-/blob/develop/docs/14_Running_Docker.md).
You can use the provided __Docker Hub__ `geovisio/website:latest` image directly:
```bash
docker run \
-e VITE_API_URL="https://your.geovisio.api/" \
-e VITE_INSTANCE_NAME="My Own GeoVisio" \
-e VITE_TILES="https://your.geovisio.api/vector/tiles/style.json" \
-p 3000:3000 \
--name geovisio-website \
-d \
geovisio/website:latest
```
This will run a container bound on [localhost:3000](http://localhost:3000).
You can also build the image from the local source with:
```bash
docker build -t geovisio/website:latest .
```
## Next steps
You can check out [the available settings for your instance](./03_Settings.md).

44
docs/03_Settings.md Normal file
View File

@@ -0,0 +1,44 @@
# Settings
Many things can be customized in your GeoVisio Website.
## Basic settings
Low-level settings can be changed through the `.env` file. An example is given in `env.example` file.
Available parameters are:
- `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
- `VITE_ENV`: `dev`
More settings are available [in official Vite documentation](https://vitejs.dev/guide/env-and-mode.html#env-files)
Note that you can also change the _Vite_ server configuration in the `vite.config.ts` file. See [Vite Configuration Reference](https://vitejs.dev/config/) if you need.
## Wording customization
GeoVisio website can be customized to have wording reflecting your brand, licence and other elements.
All the wordings of the website are on this [locale file](./src/locales/fr.json). In there, you might want to change:
- The website title (properties `title` and `meta.title`)
- The description (property `meta.description`)
- Links to help pages:
- `upload.description`
- `upload.footer_description_terminal`
## Visuals
The following images can be changed to make the website more personal:
- Logo: [`src/assets/images/logo.jpeg`](../src/assets/images/logo.jpeg)
- Favicon: [`static/favicon.ico`](../static/favicon.ico)
## Next steps
You may be interested [in developing on the website](./09_Develop.md).

43
docs/09_Develop.md Normal file
View File

@@ -0,0 +1,43 @@
# Work on the code
## Available commands
Note that all the commands and packages used are available in the `package.json` file.
### Compile and Hot-Reload for Development
Launch your dev server :
```sh
npm run dev
```
or
```sh
yarn dev
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
or
```sh
yarn test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
or
```sh
yarn lint
```

24
docs/90_Releases.md Normal file
View File

@@ -0,0 +1,24 @@
# Make a release
The web site uses [semantic versioning](https://semver.org/) for its release numbers.
__Note__ : make sure that versions are in-sync with other Website components. Each component can have different `PATCH` versions, but compatibility __must__ be ensured between `MAJOR.MINOR` versions.
Run these commands in order to issue a new release:
```bash
git checkout develop
vim package.json # Change version
npm run doc
vim CHANGELOG.md # Replace unreleased to version number and update versions links (at bottom)
git add *
git commit -m "Release x.x.x"
git tag -a x.x.x -m "Release x.x.x"
git push origin develop
git checkout main
git merge develop
git push origin main --tags
```

View File

@@ -1,13 +1,12 @@
{
"name": "geovisio-website",
"version": "0.0.0",
"version": "2.1.0",
"engines": {
"node": "18.16.0"
},
"private": true,
"scripts": {
"dev": "vite",
"scalingo-prebuild": "yarn upgrade geovisio@develop",
"start": "vite --port $PORT",
"build": "run-p type-check build-only",
"preview": "vite preview",
@@ -22,10 +21,13 @@
},
"dependencies": {
"@popperjs/core": "^2.11.6",
"@vueuse/core": "^10.2.1",
"axios": "^1.2.3",
"bootstrap": "^5.2.3",
"bootstrap-icons": "^1.10.3",
"geovisio": "^2.0.2-develop-33c2d8bd",
"geovisio": "2.1.3",
"moment": "^2.29.4",
"pinia": "^2.1.4",
"vue": "^3.2.45",
"vue-axios": "^3.5.2",
"vue-eslint-parser": "^9.1.0",
@@ -36,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,12 @@ 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
? authConf.user_profile.url
: ''
"
/>
<RouterView />
</template>

BIN
src/assets/.DS_Store vendored

Binary file not shown.

View File

@@ -1,44 +1,48 @@
@mixin text($size) {
@if $size == h1 {
font-weight: normal;
font-size: 4rem;
font-size: toRem(4);
@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 == 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: toRem(0.9);
font-weight: normal;
}
}

Binary file not shown.

BIN
src/assets/images/thumb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,5 +1,4 @@
html {
font-size: 62.5%; /* 1rem = 10px */
height: -webkit-fill-available;
}
body {
@@ -25,27 +24,35 @@ h5 {
padding: 0;
list-style: none;
}
:root {
--white: #ffffff;
--black: #181818;
--black-pale: #1b1a17;
--red: #f70000;
--red-pale: #ff726f;
--grey: #e6e6e6;
--grey-dark: #808080;
--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;
--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;
}

Binary file not shown.

View File

@@ -1,39 +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,56 +1,133 @@
<template>
<button
:disabled="isLoading || disabled"
type="button"
:type="type"
:class="[look, 'default', { disabled: disabled || isLoading }]"
@click="$emit('trigger')"
:class="['default', look]"
>
<i v-if="icon" :class="[icon, 'icon']"></i>
{{ text }}
<span v-if="text.length" class="text">{{ text }}</span>
<span v-if="tooltip && tooltip.length" class="tooltip-button">{{
tooltip
}}</span>
</button>
</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: '' },
look: { type: String, default: '' }
tooltip: { type: String, default: '' },
look: { type: String, default: '' },
type: { type: String as PropType<TypeInterface>, default: 'button' }
})
</script>
<style lang="scss" scoped>
@media (min-width: 764px) {
@media (min-width: toRem(76.8)) {
.default:hover {
opacity: 0.7;
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: toRem(2);
}
&:hover .tooltip-button {
visibility: visible;
}
}
.button--black {
height: 4.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 {
border: toRem(0.1) solid var(--white);
background-color: var(--black);
color: var(--white);
}
.button--red {
color: var(--red);
background-color: var(--white);
border: toRem(0.1) solid var(--red);
.icon {
margin-right: 0;
font-size: toRem(1.4);
color: var(--red);
}
.text {
margin-left: toRem(1);
}
}
.button--white {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 0.1rem solid var(--white);
background-color: var(--black);
color: var(--white);
color: var(--black);
background-color: var(--white);
border: toRem(0.1) solid var(--black);
.icon {
font-size: toRem(1.4);
color: var(--black);
margin-right: 0;
}
.text {
margin-left: toRem(1);
}
}
.no-text {
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: toRem(1.4);
color: var(--grey-semi-dark);
}
}
.link--red {
color: var(--red);
background-color: var(--white);
.icon {
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 {
@@ -59,12 +136,34 @@ 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;
}
}
.tooltip-button {
background-color: var(--black);
color: var(--white);
text-align: center;
border-radius: toRem(0.5);
padding: toRem(0.5) toRem(1);
position: absolute;
bottom: -100%;
visibility: hidden;
width: toRem(18);
right: 0;
@include text(xss-regular);
}
.disabled {
color: var(--grey-pale);
border-color: var(--grey-pale);
cursor: not-allowed;
.icon {
color: var(--grey-pale);
}
}
</style>

View File

@@ -1,80 +1,101 @@
<template>
<header class="header">
<div class="responsive">
<div class="responsive beta">
<beta-text />
</div>
<nav class="nav">
<div class="wrapper-logo">
<div class="wrapper-logo desktop">
<Link
:image="{ url: 'logo.jpeg', alt: $t('general.header.alt_logo') }"
:text="title($t('general.header.title'))"
:route="{ name: 'home' }"
/>
</div>
<div class="wrapper-logo responsive">
<Link
:image="{
url: 'logo.jpeg',
alt: $t('general.header.alt_logo')
}"
path="/"
:route="{ name: 'home' }"
/>
<div class="desktop">
<beta-text />
</div>
</div>
<div class="wrapper-entries">
<ul :class="['nav-list', { 'mobile-menu-open': !menuIsClosed }]">
<li class="nav-list-item desktop">
<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.contribute_text')"
icon="bi bi-upload"
look="button white"
path="/partager-des-photos"
:text="$t('general.header.sequences_text')"
icon="bi bi-images"
:route="{ name: 'my-sequences' }"
@click.native="closeModal"
/>
</li>
<li v-if="userProfileUrl.length" class="logged-link">
<Link
:route="{ name: 'my-information' }"
icon="bi bi-person"
:text="$t('general.header.my_information_text')"
@click.native="closeModal"
/>
</li>
<li class="logged-link">
<Link
:route="{ name: 'my-settings' }"
icon="bi bi-gear"
:text="$t('general.header.my_settings_text')"
@click.native="closeModal"
/>
</li>
<li class="logged-link">
<Link
type="external"
icon="bi bi-power"
:text="$t('general.header.logout_text')"
:path-external="getAuthRoute('auth/logout', route.path)"
@click.native="closeModal"
/>
</li>
</ul>
<div class="wrapper-right-entries">
<div class="responsive">
<div v-if="isLogged && authEnabled">
<Link
:text="$t('general.header.contribute_text_responsive')"
icon="bi bi-upload"
look="button white"
path="/partager-des-photos"
:text="$t('general.header.upload_text')"
look="button button--blue"
:route="{ name: 'upload-pictures' }"
@click.native="closeModal"
/>
</div>
<div v-if="authEnabled" class="item-with-sub">
<div>
<Link
type="external"
icon="bi bi-person-circle"
:look="isLogged ? 'disable-mobile' : ''"
:path="userUrl"
:text="userName"
:text="$t('general.header.contribute_text')"
:route="{ name: 'share-pictures' }"
@click.native="closeModal"
/>
<i v-if="isLogged" class="chevron bi bi-chevron-up"></i>
<div v-if="isLogged" class="sub-nav-block">
<div v-if="userProfileUrl" class="logged-link">
<Link
path="mes-informations"
:text="$t('general.header.my_information_text')"
/>
</div>
<div class="logged-link">
<Link
path="mes-parametres"
:text="$t('general.header.my_settings_text')"
/>
</div>
<div class="logged-link">
<Link
type="external"
:path="logoutUrl"
:text="$t('general.header.logout_text')"
/>
</div>
</div>
</div>
<button
v-if="isLogged && authEnabled"
class="menu-burger"
:aria-label="ariaLabel"
@click="toggleMenu"
>
<i v-if="!menuIsClosed" class="cross bi bi-x-lg"></i>
<i v-else class="bi bi-list"></i>
<div v-if="isLogged" class="item-with-sub">
<span>{{ userName }}</span>
</div>
<div v-else>
<i v-if="!menuIsClosed" class="cross bi bi-x-lg"></i>
<i v-else class="bi bi-list"></i>
</div>
</button>
<div v-else-if="!isLogged && authEnabled">
<Link
type="external"
icon="bi bi-person-circle"
:path-external="getAuthRoute('auth/login', route.path)"
/>
</div>
</div>
</div>
</nav>
@@ -83,9 +104,12 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { useCookies } from 'vue3-cookies'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { getAuthRoute } from '@/utils/auth'
import title from '@/utils/index'
import Link from '@/components/Link.vue'
import BetaText from '@/components/BetaText.vue'
@@ -94,11 +118,17 @@ 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
}
function toggleMenu(): void {
menuIsClosed.value = !menuIsClosed.value
}
@@ -108,17 +138,12 @@ const ariaLabel = computed((): string =>
? t('general.header.burger_menu_aria_label_open')
: t('general.header.burger_menu_aria_label_closed')
)
const logoutUrl = computed(
(): string =>
`${import.meta.env.VITE_API_URL}api/auth/logout?next_url=${route.path}`
)
const userUrl = computed((): string =>
isLogged.value
? ''
: `${import.meta.env.VITE_API_URL}api/auth/login?next_url=${route.path}`
)
const userName = computed((): string =>
isLogged.value ? cookies.get('user_name') : t('general.header.login_text')
cookies!
.get('user_name')
.match(/\b(\w)/g)!
.join('')
.toUpperCase()
)
</script>
@@ -126,13 +151,13 @@ 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;
}
@@ -152,124 +177,121 @@ const userName = computed((): string =>
.wrapper-logo p {
@include text(m-r-regular);
margin-bottom: 0;
margin-left: 1rem;
margin-left: toRem(1);
position: relative;
}
.nav-list {
display: flex;
align-items: center;
margin-bottom: 0;
}
.item-with-sub {
position: relative;
display: flex;
align-items: center;
margin-left: 1rem;
}
.item-with-sub:hover .sub-nav-block {
display: block;
}
.item-with-sub:hover .chevron {
transform: rotate(180deg);
}
.chevron {
margin-left: 0.5rem;
}
.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;
justify-content: center;
padding: 1rem 1rem 1.5rem;
}
.logged-link:first-child {
padding-top: 1.5rem;
}
.logged-link:nth-child(2) {
padding-bottom: 1.5rem;
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 {
margin-right: toRem(2);
}
}
@media (max-width: 768px) {
.cross {
font-size: toRem(2);
}
.item-with-sub {
margin-right: toRem(1.5);
}
.nav {
align-items: center;
padding: toRem(1.5);
}
.nav-list {
display: none;
flex-direction: column;
justify-content: center;
align-items: initial;
position: absolute;
width: toRem(20);
top: toRem(8);
right: 0;
z-index: 2;
background-color: var(--white);
box-shadow: 0 toRem(0.2) toRem(0.4) rgb(0 0 0 / 10%);
padding-left: 0;
padding-top: toRem(1);
padding-bottom: toRem(1);
border-radius: toRem(1);
}
.menu-burger {
display: block;
background-color: transparent;
border: none;
width: toRem(2.5);
font-size: toRem(2.5);
padding: 0;
.item-with-sub {
@include text(s-regular);
display: flex;
justify-content: center;
align-items: center;
background-color: var(--blue);
color: var(--white);
height: toRem(3);
width: toRem(3);
border-radius: 50%;
margin-right: 0;
}
}
.menu-open {
display: flex;
}
@media (max-width: toRem(50)) {
.header {
flex-direction: column;
height: 11rem;
height: toRem(11);
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 2;
background: var(--white);
z-index: 4;
}
.cross {
font-size: 2rem;
}
.item-with-sub {
margin-right: 1.5rem;
.nav-list {
top: toRem(11);
width: 100%;
}
.desktop {
display: none;
}
.responsive {
width: 100%;
display: block;
}
.nav {
align-items: center;
padding: 1.5rem;
}
.nav-list {
display: none;
flex-direction: column;
justify-content: center;
align-items: initial;
position: fixed;
width: 100%;
height: 20rem;
top: 11rem;
left: 0;
z-index: 2;
background-color: var(--white);
box-shadow: 0 0.2rem 0.4rem rgb(0 0 0 / 10%);
}
.menu-burger {
display: block;
background-color: transparent;
border: none;
font-size: 2.5rem;
padding: 0;
}
.mobile-menu-open {
display: flex;
}
.nav-list-item {
margin-bottom: 2.5rem;
}
}
@media (max-width: 500px) {
.item-with-sub {
margin-right: 1rem;
.beta {
width: 100%;
.instance-beta {
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div :class="status">
<div class="wrapper-image">
<button
:class="[{ selected }, 'button-image-item']"
:disabled="status === 'waiting-for-process'"
type="button"
@click="$emit('trigger')"
>
<div
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
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>
</div>
<div
v-if="selectedOnMap && !selected"
class="icon-img pointer-map"
></div>
<div v-if="selected && !selectedOnMap" class="icon-img button-check">
<i class="bi bi-check-lg" />
</div>
<div
v-if="selected && selectedOnMap"
class="icon-img button-check-pointer"
>
<i class="bi bi-check-lg" />
</div>
<div v-if="status === 'waiting-for-process'" class="photo-info">
<span class="waiting info">{{
$t('pages.sequence.waiting_process')
}}</span>
</div>
<div v-else class="photo-info">
<span v-if="created" class="info"
><i class="bi bi-clock"></i> {{ created }}</span
>
<div class="button-info">
<Link
look="button button--white-blue no-text"
icon="bi bi-cloud-download-fill"
type="external"
target="_blank"
:path-external="hrefHd"
/>
</div>
</div>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import Link from '@/components/Link.vue'
defineProps({
created: { type: String, default: null },
href: { type: String, default: null },
hrefHd: { type: String, default: null },
selected: { type: Boolean, default: false },
selectedOnMap: { type: Boolean, default: false },
status: {
type: String,
validator: (value: string): boolean =>
['waiting-for-process', 'ready', 'hidden', ''].includes(value),
default: ''
}
})
</script>
<style lang="scss" scoped>
.button-image-item {
background-color: transparent;
border: none;
width: 100%;
padding: 0;
position: relative;
}
.selected {
border: toRem(0.1) solid var(--blue);
border-radius: toRem(0.5);
box-shadow: 0px 4px 4px 0px #00000040;
}
.wrapper-image {
display: flex;
}
.photo-img-wrapper {
display: flex;
justify-content: center;
align-items: center;
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-radius: toRem(0.5);
}
.icon-hidden {
color: var(--grey-dark);
position: absolute;
font-size: toRem(4);
}
.waiting-wrapper {
height: toRem(16);
display: flex;
justify-content: center;
align-items: center;
color: var(--blue);
}
.icon-waiting {
height: toRem(4);
font-size: toRem(4);
}
.icon-img {
top: toRem(1);
right: toRem(1);
background-color: var(--white);
border-radius: 50%;
position: absolute;
height: toRem(2);
width: toRem(2);
display: flex;
justify-content: center;
align-items: center;
font-size: toRem(1.3);
}
.pointer-map,
.button-check-pointer {
background-color: var(--orange);
}
.delete-checked {
opacity: 1;
}
.photo-info {
height: toRem(5);
display: flex;
justify-content: space-between;
align-items: center;
padding: toRem(1);
position: absolute;
bottom: 0;
width: 100%;
}
.waiting {
text-align: center;
width: 100%;
color: var(--black);
}
.info {
@include text(xs-r-regular);
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,
.info {
opacity: 0.3;
}
&:hover {
.photo-img,
.info {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="wrapper-checkbox">
<div class="input-checkbox">
<i v-if="isChecked && !isIndeterminate" class="icon bi bi-check-square" />
<i v-if="!isChecked && !isIndeterminate" class="icon bi bi-square" />
<i v-if="isIndeterminate && !isChecked" class="icon bi bi-dash-square" />
<input
id="checkbox"
v-model="inputValue"
type="checkbox"
@input="updateValue(!inputValue)"
class="input"
/>
</div>
<label v-if="label && label.length" for="checkbox" class="label">{{
label
}}</label>
</div>
</template>
<script lang="ts" setup>
import { ref, watchEffect } from 'vue'
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, 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 () => {
if (htmlCheckbox) {
htmlCheckbox.indeterminate = props.isIndeterminate
}
})
function updateValue(value: boolean): void {
if (htmlCheckbox) {
htmlCheckbox.indeterminate = false
}
inputValue.value = value
emit('trigger', { isChecked: value, isIndeterminate: false })
}
</script>
<style lang="scss" scoped>
.input-checkbox {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: toRem(2);
width: toRem(2);
}
.input {
-webkit-appearance: none;
-moz-appearance: none;
-ms-appearance: none;
appearance: none;
cursor: pointer;
position: absolute;
height: 100%;
width: 100%;
}
.icon {
font-size: toRem(2);
position: absolute;
color: var(--grey-semi-dark);
}
.wrapper-checkbox {
display: flex;
align-items: center;
}
.label {
cursor: pointer;
margin-left: toRem(0.5);
}
</style>

View File

@@ -0,0 +1,118 @@
<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')
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,62 +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);
text-decoration: none;
.icon {
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: 4.5rem;
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;
.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);
@@ -118,21 +143,29 @@ const titleImg = computed<string>(() =>
align-items: center;
border-radius: 50%;
padding: 0;
height: 4.5rem;
width: 4.5rem;
height: toRem(4);
width: toRem(4);
.icon {
color: var(--white);
font-size: 2.8rem;
font-size: toRem(2.8);
margin-right: 0;
}
}
@media (max-width: 500px) {
.no-text {
height: toRem(3);
width: toRem(3);
padding: 0;
.icon {
margin-right: 0.5rem;
margin: auto;
}
}
@media (max-width: toRem(50)) {
.icon {
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;

87
src/components/Loader.vue Normal file
View File

@@ -0,0 +1,87 @@
<template>
<div :class="['lds-ring', look, { loaded: isLoaded }]">
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</template>
<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 lang="scss">
.lds-ring {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
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;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

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

@@ -13,8 +13,8 @@
</div>
<div class="entry-button-terminal">
<Button
look="button--white"
:text="$t('pages.upload.button_copy')"
look="button--transparent"
:text="$t('pages.share_pictures.button_copy')"
:icon="clipboardIcon"
@trigger="copyText(textInstall)"
class="entry-button"
@@ -27,8 +27,8 @@
</div>
<div class="entry-button-terminal">
<Button
look="button--white"
:text="$t('pages.upload.button_copy')"
look="button--transparent"
: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;

70
src/components/Toast.vue Normal file
View File

@@ -0,0 +1,70 @@
<template>
<div :class="['toast-wrapper', look, { display: text.length }]">
<button class="button-close" @click="$emit('trigger')">
<i class="bi bi-x"></i>
</button>
<i v-if="look === 'error'" class="bi bi-exclamation-triangle"></i>
<i v-else class="bi bi-check-circle"></i>
<p v-if="text.length" class="toast-text">
{{ text }}
</p>
</div>
</template>
<script lang="ts" setup>
defineProps({
text: { type: String, default: '' },
look: {
type: String,
validator: (value: string): boolean =>
['success', 'error', ''].includes(value),
default: ''
}
})
</script>
<style lang="scss" scoped>
.toast-wrapper {
position: fixed;
right: 0;
bottom: toRem(2);
transform: translateX(100%);
display: flex;
justify-content: center;
align-items: center;
color: var(--white);
@include text(s-regular);
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: 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;
justify-content: center;
align-items: center;
}
.toast-text {
margin-bottom: 0;
margin-left: toRem(1);
}
.display {
transform: translateX(toRem(-3));
transition: transform 0.3s ease-in-out;
}
.error {
background-color: var(--red);
}
.success {
background-color: var(--green);
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<section :class="['information-section', { 'first-sequence': index === 0 }]">
<div class="uploaded-pictures">
<p v-if="index === 0" class="title-current-upload">
{{ $t('pages.upload.sequence_uploading_title') }}
</p>
<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>
</section>
</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">
.information-section {
display: flex;
margin-bottom: toRem(4);
padding-top: toRem(2);
padding-bottom: toRem(2);
width: 80%;
box-shadow: 0px 6.45694px 8.60925px rgba(0, 0, 0, 0.05);
}
.first-sequence {
background-color: var(--blue-pale);
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-left: toRem(2);
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: 0rem toRem(2) toRem(2);
overflow-y: auto;
max-height: toRem(35);
}
.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;
}
}
</style>

View File

@@ -0,0 +1,52 @@
<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(s-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,108 @@
<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;
margin-top: toRem(4);
}
.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

@@ -6,9 +6,9 @@ export default function authConfig() {
const authConf = ref<AuthConfigInterface>({})
async function getConfig(): Promise<object> {
const { data } = await axios.get(
`${import.meta.env.VITE_API_URL}api/configuration`
)
const { data } = await axios.get('api/configuration', {
withCredentials: false
})
return data.auth
}
onMounted(async () => (authConf.value = await getConfig()))

View File

@@ -1,15 +1,17 @@
{
"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_responsive": "Verser",
"contribute_text": "À propos",
"upload_text": "Contribuer",
"sequences_text": "Mes photos",
"alt_logo": "Logo de l'instance",
"title": "Instance Panoramax IGN",
"title": "Instance\nPanoramax",
"beta_text": "Version beta",
"logout_text": "Déconnexion",
"my_information_text": "Mes informations",
"my_settings_text": "Mes paramètres",
@@ -17,7 +19,8 @@
"burger_menu_aria_label_open": "Afficher le menu",
"burger_menu_aria_label_closed": "Masquer le menu"
},
"feature_not_available": "Fonctionnalité en cours de développement"
"error_text": "Une erreur est survenue",
"success_text": "Mise à jour réussie"
},
"pages": {
"home": {
@@ -26,9 +29,52 @@
"report_button_text": "Signaler la photo"
},
"settings": {
"title": "Mes Tokens"
"title": "Mes Tokens",
"setting_tooltip": "Afficher ou masquer le token"
},
"upload": {
"sequence": {
"sequence_published": "Publiée",
"sequence_waiting": "En cours de publication",
"sequence_hidden": "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 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",
"select_text": "Tout sélectionner",
"unselect_text": "Tout désélectionner",
"select_shift_text": "Sélectionnez plusieurs photos avec shift",
"waiting_process": "Photo en cours de traitement",
"no_image": "Aucune photo dans cette séquence"
},
"sequences": {
"title": "Mes séquences de photos",
"sequence_name": "Nom",
"sequence_photos": "Photos",
"sequence_date": "Prise de vue",
"sequence_status": "Statut",
"sequence_published": "✅ Publiée",
"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",
"sequence_deleted": "La séquence a bien été supprimée"
},
"share_pictures": {
"title": "Partagez vos photos",
"sub_title": "Un compte utilisateur est obligatoire pour partager des photos",
"photo_type1": "Des lieux visibles depuis la voie publique",
@@ -45,8 +91,28 @@
"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",
"input_label": "Déposez des photos dans la zone ou ",
"import_word": "importez",
"import_type": "Format JPEG uniquement",
"sequence_title": "Séquence du ",
"button_text": "Envoyer",
"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": "Nouvel envoi",
"leave_message": "⚠️ Attention, le téléchargement sera interrompu si vous quittez la page avant la fin."
}
}
}

View File

@@ -6,13 +6,16 @@ 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
const i18n = createI18n({
locale: 'fr',
@@ -30,6 +33,7 @@ globalCookiesConfig({
const app = createApp(App)
app.use(pinia)
app.use(i18n)
app.use(router)
app.use(VueAxios, axios)

View File

@@ -1,11 +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 UploadView from '../views/UploadView.vue'
import MySequencesView from '../views/MySequencesView.vue'
import MySequenceView from '../views/MySequenceView.vue'
import SharePicturesView from '../views/SharePicturesView.vue'
import UploadPicturesView from '../views/UploadPicturesView.vue'
const { cookies } = useCookies()
const routes: Array<RouteRecordRaw> = [
{
@@ -23,32 +31,68 @@ const routes: Array<RouteRecordRaw> = [
name: 'my-settings',
component: MySettingsView
},
{
path: '/mes-sequences',
name: 'my-sequences',
component: MySequencesView
},
{ path: '/sequence/:id', name: 'sequence', component: MySequenceView },
{
path: '/partager-des-photos',
name: 'upload',
component: UploadView
name: 'share-pictures',
component: SharePicturesView
},
{
path: '/envoyer',
name: 'upload-pictures',
component: UploadPicturesView
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
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'
router.beforeEach(async (to, from, next) => {
if (to.name === 'my-information' || to.name === 'my-settings') {
try {
const loginUrl = `${import.meta.env.VITE_API_URL}api/auth/login`
const isKeycloakLogout = await axios.get(loginUrl)
const isSiteLogin = !!cookies.get('user_id')
if (isKeycloakLogout || !isSiteLogin) {
const win: Window = window
win.location = `${loginUrl}?next_url=${from.path}`
} else next()
} catch (e) {
next()
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

@@ -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

@@ -1,10 +1,24 @@
import { vi, it, beforeEach, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
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 = {
get: vi.fn()
}
return {
useCookies: () => ({
cookies: mockCookies
})
}
})
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
@@ -15,86 +29,59 @@ const i18n = createI18n({
}
})
const router = createRouter({
history: createWebHistory(),
routes: []
})
describe('Template', () => {
beforeEach(async () => {
const VueRouter = await import('vue-router')
VueRouter.useRoute.mockReturnValueOnce({
path: 'my-path'
})
})
describe('When the user is not logged', () => {
it('should render the component with good wording keys', async () => {
import.meta.env.VITE_API_URL = 'api-url/'
const wrapper = shallowMount(Header, {
global: {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('general.header.contribute_text')
expect(wrapper.html()).contains(
'general.header.contribute_text_responsive'
)
})
it('should render the component login link', async () => {
import.meta.env.VITE_API_URL = 'api-url/'
const wrapper = shallowMount(Header, {
global: {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('path="api-url/api/auth/login')
expect(wrapper.html()).contains('api/auth/login')
})
})
describe('When the user is logged', () => {
beforeEach(async () => {
Object.defineProperty(document, 'cookie', {
value: 'user_id=abc123'
})
vi.resetModules()
import.meta.env.VITE_API_URL = 'api-url/'
})
it('should render the component with good wording keys', 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],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('general.header.contribute_text')
expect(wrapper.html()).contains(
'general.header.contribute_text_responsive'
)
expect(wrapper.html()).contains('general.header.my_information_text')
expect(wrapper.html()).contains('general.header.logout_text')
})
it('should render the component logout link', async () => {
const wrapper = shallowMount(Header, {
props: {
authEnabled: true,
userProfileUrl: 'profil'
},
global: {
plugins: [i18n],
mocks: {
$t: (msg) => msg
}
}
})
expect(wrapper.html()).contains('chevron bi bi-chevron-up')
expect(wrapper.html()).contains('auth/logout')
expect(wrapper.html()).contains('path="mes-informations"')
expect(wrapper.html()).contains('general.header.sequences_text')
expect(wrapper.html()).contains('general.header.my_settings_text')
})
})
})
@@ -111,7 +98,7 @@ describe('Methods', () => {
it('Menu should be closed by default', () => {
wrapper = shallowMount(Header, {
global: {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
@@ -120,20 +107,5 @@ describe('Methods', () => {
expect(wrapper.vm.menuIsClosed).toBe(true)
})
it('Should be open and close by clicking', async () => {
wrapper = shallowMount(Header, {
global: {
plugins: [i18n],
mocks: {
$t: (msg) => msg
}
}
})
await wrapper.find('.menu-burger').trigger('click')
expect(wrapper.vm.menuIsClosed).toBe(false)
await wrapper.find('.menu-burger').trigger('click')
expect(wrapper.vm.menuIsClosed).toBe(true)
})
})
})

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

@@ -1,11 +1,23 @@
import { test, describe, vi, expect } from 'vitest'
import { mount, shallowMount } from '@vue/test-utils'
import Link from '../../../components/Link.vue'
import { useI18n } from 'vue-i18n'
import { createI18n } from 'vue-i18n'
import { createRouter, createWebHistory } from 'vue-router'
import fr from '../../../locales/fr.json'
vi.mock('vue-i18n')
useI18n.mockReturnValue({
t: (tKey) => tKey
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
globalInjection: true,
legacy: false,
messages: {
fr
}
})
const router = createRouter({
history: createWebHistory(),
routes: []
})
const stubs = {
'router-link': {
@@ -13,37 +25,17 @@ const stubs = {
}
}
describe('Template', () => {
describe('Snapshot', () => {
test('Should match snapshot with external link', () => {
const wrapper = mount(Link, {
global: {
stubs
},
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, {
props: {
text: 'My-text',
path: 'my-path'
}
})
expect(wrapper.element).toMatchSnapshot()
})
})
describe('Props', () => {
test('should have default props', () => {
const wrapper = shallowMount(Link)
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n, router]
}
})
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('')
@@ -57,7 +49,8 @@ describe('Template', () => {
test('should render the component as a <a>', () => {
const wrapper = shallowMount(Link, {
global: {
stubs
stubs,
plugins: [i18n]
},
props: {
type: 'external'
@@ -68,7 +61,8 @@ describe('Template', () => {
test('should render an icon inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
stubs
stubs,
plugins: [i18n]
},
props: {
type: 'external',
@@ -80,7 +74,8 @@ describe('Template', () => {
test('should render the text inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
stubs
stubs,
plugins: [i18n]
},
props: {
type: 'external',
@@ -92,7 +87,8 @@ describe('Template', () => {
test('should render an image inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
stubs
stubs,
plugins: [i18n]
},
props: {
type: 'external',
@@ -109,7 +105,8 @@ describe('Template', () => {
test('should render the text inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
stubs
stubs,
plugins: [i18n]
},
props: {
type: 'external',
@@ -122,11 +119,18 @@ describe('Template', () => {
describe('When the component is an internal link', () => {
test('should render the component as an internal link', () => {
const wrapper = shallowMount(Link)
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n, router]
}
})
expect(wrapper.html()).contains('<router-link')
})
test('should render an icon inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n]
},
props: {
type: 'internal',
icon: 'my-icon'
@@ -136,14 +140,20 @@ describe('Template', () => {
})
test('should render the text inside the link', () => {
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n, router]
},
props: {
text: 'my-text'
}
})
expect(wrapper.html()).contains('my-text')
})
test('should render the text inside the link', () => {
test('should render the disabled attribute', () => {
const wrapper = shallowMount(Link, {
global: {
plugins: [i18n, router]
},
props: {
disabled: true
}

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

@@ -4,7 +4,7 @@ import { createI18n } from 'vue-i18n'
import Terminal from '../../../components/Terminal.vue'
import Button from '../../../components/Button.vue'
import fr from '../../../locales/fr.json'
import { updateClipboard } from '../../../utils/copyToClipboard'
import * as copyToClipboardModule from '../../../utils/copyToClipboard'
const i18n = createI18n({
locale: 'fr',
@@ -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, {
@@ -39,7 +39,7 @@ describe('Template', () => {
})
expect(wrapper.html()).contains('<button')
expect(wrapper.html()).contains('icon="bi bi-clipboard-plus"')
expect(wrapper.html()).contains('look="button--white"')
expect(wrapper.html()).contains('look="button--transparent"')
})
})
describe('Props', () => {
@@ -89,7 +89,7 @@ describe('Methods', () => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
const wrapper = mount(Terminal, {
global: {
@@ -101,15 +101,20 @@ describe('Methods', () => {
}
}
})
vi.mock('../../../utils/copyToClipboard')
updateClipboard.mockReturnValue(true)
const updateClipboardMock = vi.spyOn(
copyToClipboardModule,
'updateClipboard'
)
updateClipboardMock.mockReturnValue(true)
const spy = vi.spyOn(wrapper.vm, 'copyText')
const buttonWrapper = wrapper.findComponent(Button)
await buttonWrapper.vm.$emit('trigger')
expect(spy).toHaveBeenCalled()
expect(updateClipboard).toHaveBeenCalled()
expect(updateClipboardMock).toHaveBeenCalled()
expect(wrapper.vm.uploadIsCopied).toBe(true)
})
})

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,38 +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
class="default"
data-v-409c8661=""
title="My-text"
to="my-path"
>
<!--v-if-->
<!--v-if-->
<span
class="text"
data-v-409c8661=""
>
My-text
</span>
</router-link>
`;

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="Nouvel envoi"')
})
})
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="Nouvel envoi"')
expect(wrapper.html()).contains('Transfert terminé !')
expect(wrapper.html()).contains('2345 Mo/2345 Mo')
})
})
})
})

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

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

View File

@@ -0,0 +1,217 @@
import { it, describe, expect } from 'vitest'
import {
imageStatus,
photoToDeleteOrPatchSelected,
spliceIntoChunks,
formatPaginationItems
} from '../../views/utils/sequence/index'
import {
formatPictureSize,
formatTextSize,
sortByName
} 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')
})
})
describe('sortByName', () => {
it('should return the the list sorted by name', () => {
const list1 = [
{ name: 'd_1_ct.jpg' },
{ name: 'd_11_ct.jpg' },
{ name: 'd_2_ct.jpg' }
]
expect(sortByName(list1)).toEqual([
{ name: 'd_1_ct.jpg' },
{ name: 'd_2_ct.jpg' },
{ name: 'd_11_ct.jpg' }
])
const list2 = [{ name: 'A.jpg' }, { name: 'Z.jpg' }, { name: 'B.jpg' }]
expect(sortByName(list2)).toEqual([
{ name: 'A.jpg' },
{ name: 'B.jpg' },
{ name: 'Z.jpg' }
])
const list3 = [
{ name: 'CAM1_001.jpg' },
{ name: 'CAM2_002.jpg' },
{ name: 'CAM1_011.jpg' }
]
expect(sortByName(list3)).toEqual([
{ name: 'CAM1_001.jpg' },
{ name: 'CAM1_011.jpg' },
{ name: 'CAM2_002.jpg' }
])
})
})

View File

@@ -0,0 +1,210 @@
import { it, describe, expect, vi, beforeEach } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import MySequencesView from '../../../views/MySequencesView.vue'
import axios from 'axios'
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()
}))
vi.mock('axios')
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
globalInjection: true,
legacy: false,
messages: {
fr
}
})
const router = createRouter({
history: createWebHistory(),
routes: []
})
const mockResponseSequences = [
{
href: 'https://my-link',
rel: 'self',
type: 'application/json'
},
{
href: 'https://my-link',
id: 'my-id',
rel: 'child',
'stats:items': { count: 16 },
title: 'ma sequence 1',
extent: {
temporal: {
interval: [['2022-09-22T08:03:08+00:00', '2022-09-22T08:03:08+00:00']]
}
}
}
]
describe('Template', () => {
it('should render the view without sequences', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: { links: [] } })
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 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 () => {
vi.spyOn(axios, 'get').mockResolvedValue({
data: { links: mockResponseSequences }
})
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
}
}
})
wrapper.vm.isLoading = true
await wrapper.vm.$nextTick()
expect(wrapper.html()).contains('<loader')
})
})
describe('Methods', () => {
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']]
}
}
}
]
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, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
components: {
Button
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortAlpha')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-title"]'
)
await buttonWrapper.vm.$emit('trigger')
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, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
components: {
Button
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortNum')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-number"]'
)
await buttonWrapper.vm.$emit('trigger')
expect(spy).toHaveBeenCalledWith('num')
expect(wrapper.vm.userSequences[0]).toEqual(
mockResponseSequencesToSort[1]
)
})
it('should should sort sequences by date', async () => {
const wrapper = shallowMount(MySequencesView, {
global: {
plugins: [i18n, router, createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: (msg) => msg
},
components: {
Button
}
}
})
wrapper.vm.isLoading = false
await wrapper.vm.$nextTick()
const spy = vi.spyOn(wrapper.vm, 'sortNum')
const buttonWrapper = wrapper.findComponent(
'[data-test="button-sort-date"]'
)
await buttonWrapper.vm.$emit('trigger')
expect(spy).toHaveBeenCalledWith('date')
expect(wrapper.vm.userSequences[0]).toEqual(
mockResponseSequencesToSort[1]
)
})
})
})

View File

@@ -6,8 +6,7 @@ import axios from 'axios'
import fr from '../../../locales/fr.json'
import Button from '../../../components/Button.vue'
import { updateClipboard } from '../../../utils/copyToClipboard'
vi.mock('axios')
import * as copyToClipboardModule from '../../../utils/copyToClipboard'
const i18n = createI18n({
locale: 'fr',
@@ -37,8 +36,7 @@ const mockResponseTokens = [
describe('Template', () => {
describe('When all the tokens list are fetched', () => {
it('should render all the tokens hidden', async () => {
import.meta.env.VITE_API_URL = 'api-url/'
axios.get.mockResolvedValue({ data: mockResponseTokens })
vi.spyOn(axios, 'get').mockResolvedValue({ data: mockResponseTokens })
const wrapper = shallowMount(MySettingsView, {
global: {
plugins: [i18n],
@@ -48,16 +46,13 @@ describe('Template', () => {
}
})
await flushPromises()
expect(axios.get).toHaveBeenCalledWith(
`${import.meta.env.VITE_API_URL}api/users/me/tokens`
)
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" look="button--white"'
'icon="bi bi-clipboard-plus" disabled="false" isloading="false" text="pages.share_pictures.button_copy" tooltip="" look="button--white"'
)
})
})
@@ -129,7 +124,7 @@ describe('Methods', () => {
})
describe('copyText', () => {
it('calls copyText function on button click', async () => {
axios.get.mockResolvedValue({ data: mockResponseTokens })
vi.spyOn(axios, 'get').mockResolvedValue({ data: mockResponseTokens })
vi.mock('../../../utils/copyToClipboard')
beforeEach(() => {
vi.useFakeTimers()
@@ -150,7 +145,11 @@ describe('Methods', () => {
})
await flushPromises()
updateClipboard.mockReturnValue(true)
const updateClipboardMock = vi.spyOn(
copyToClipboardModule,
'updateClipboard'
)
updateClipboardMock.mockReturnValue(true)
const spy = vi.spyOn(wrapper.vm, 'copyText')
const buttonWrapper = wrapper.findComponent('[data-test="button-copy-0"]')
const tokenToCopy = 'token to copy'

View File

@@ -1,25 +1,18 @@
import { it, describe, expect } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import UploadView from '../../../views/UploadView.vue'
import { createI18n } from 'vue-i18n'
import fr from '../../../locales/fr.json'
import SharePicturesView from '../../../views/SharePicturesView.vue'
import i18n from '../config'
import { createRouter, createWebHistory } from 'vue-router'
const i18n = createI18n({
locale: 'fr',
fallbackLocale: 'fr',
globalInjection: true,
legacy: false,
messages: {
fr
}
const router = createRouter({
history: createWebHistory(),
routes: []
})
describe('Template', () => {
it('should render the view with the button link', async () => {
import.meta.env.VITE_API_URL = 'api-url/'
const wrapper = shallowMount(UploadView, {
const wrapper = shallowMount(SharePicturesView, {
global: {
plugins: [i18n],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg,
authConf: {
@@ -28,18 +21,17 @@ describe('Template', () => {
}
}
})
expect(wrapper.html()).contains('<link')
expect(wrapper.html()).contains('path="')
expect(wrapper.html()).contains('/auth/login?next_url=/partager-des-photos')
expect(wrapper.html()).contains('look="button"')
expect(wrapper.html()).contains('/auth/login')
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],
plugins: [i18n, router],
mocks: {
$t: (msg) => msg,
authConf: {
@@ -48,6 +40,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,132 @@
import { it, describe, expect, vi } from 'vitest'
import { shallowMount, mount, flushPromises } 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 * as createAPictureToASequence from '@/views/utils/upload/request'
import * as createASequence from '@/views/utils/upload/request'
import { formatDate } from '../../../utils/dates'
import * as sortByName from '../../../views/utils/upload/index'
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 sortByNameMock = vi.spyOn(sortByName, 'sortByName')
sortByNameMock.mockReturnValue([{}, {}])
const wrapperInputUpload = await wrapper.findComponent(InputUpload)
await wrapperInputUpload.trigger('trigger')
await wrapperInputUpload.vm.$emit('trigger', [{}, {}])
expect(spy).toHaveBeenCalledTimes(1)
expect(wrapper.html()).contains('2 fichiers')
})
describe('submit uploadPicture', () => {
it('should trigger to uploadPictures', async () => {
const wrapper = mount(UploadPicturesView, {
global: {
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
},
components: {
InputUpload
}
}
})
const spy = vi.spyOn(wrapper.vm, 'uploadPicture')
const wrapperInputUpload = wrapper.findComponent(InputUpload)
await wrapperInputUpload.trigger('trigger')
await wrapperInputUpload.vm.$emit('trigger', [{}, {}])
const buttonWrapper = await wrapper.find('[data-test="button-upload"]')
await buttonWrapper.trigger('submit.prevent')
expect(spy).toHaveBeenCalledTimes(1)
expect(wrapper.html()).contains('class="loader-percentage"')
expect(wrapper.html()).contains('class="lds-ring lg"')
})
})
describe('one sequence has been imported', () => {
it('should render a sequence with a list of uploaded pictures', async () => {
const wrapper = mount(UploadPicturesView, {
global: {
plugins: [i18n, router],
mocks: {
$t: (msg) => msg
}
}
})
const picture = {
lastModified: 1599133968750,
name: '100MSDCF_DSC02790.JPG',
size: 2345,
type: 'image/jpeg'
}
const spyASequence = vi.spyOn(createASequence, 'createASequence')
const spyPicture = vi.spyOn(
createAPictureToASequence,
'createAPictureToASequence'
)
const sortByNameMock = vi.spyOn(sortByName, 'sortByName')
sortByNameMock.mockReturnValue([picture])
const sequenceId = 'my-id'
spyASequence.mockReturnValue({ data: { id: sequenceId } })
spyPicture.mockReturnValue({ data: {} })
const sequenceTitle = `Séquence du ${formatDate(
new Date(),
'Do MMMM YY, hh:mm:ss'
)}`
const body = new FormData()
body.append('position', '1')
body.append('picture', picture)
const wrapperInputUpload = wrapper.findComponent(InputUpload)
await wrapperInputUpload.trigger('trigger')
await wrapperInputUpload.vm.$emit('trigger', [picture])
const buttonWrapper = await wrapper.find('[data-test="button-upload"]')
await buttonWrapper.trigger('submit.prevent')
expect(spyASequence).toHaveBeenCalledWith(sequenceTitle)
expect(spyPicture).toHaveBeenCalledWith(sequenceId, body)
expect(wrapper.html()).contains('class="uploaded-picture-list"')
expect(wrapper.html()).contains('class="uploaded-picture-item success"')
expect(wrapper.html()).contains(
'100MSDCF_DSC02790.JPG - pages.upload.uploaded_word'
)
expect(wrapper.html()).contains('<router-link')
expect(wrapper.html()).contains(
'class="default button button--white" title="pages.upload.sequence_link"'
)
})
})
})
})

8
src/utils/auth.ts Normal file
View File

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

16
src/utils/dates.ts Normal file
View File

@@ -0,0 +1,16 @@
import moment from 'moment'
import 'moment/dist/locale/fr'
function formatDate(date: Date, formatType: string): string {
const formatDate = moment(date)
return formatDate.locale('fr').format(formatType)
}
function durationCalc(duration1: Date, duration2: Date, type: string): number {
const duration = moment.duration(moment(duration1).diff(moment(duration2)))
if (type == 'hours') return duration.hours()
if (type == 'minutes') return duration.minutes()
return duration.seconds()
}
export { formatDate, durationCalc }

View File

@@ -1,3 +1,10 @@
export function img(name: string) {
export function img(name: string): string {
return new URL(`../assets/images/${name}`, import.meta.url).toString()
}
export function getPicId(): string {
return window.location.href.substring(
window.location.href.indexOf('pic=') + 4,
window.location.href.lastIndexOf('&')
)
}

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
}

60
src/utils/mapAndViewer.ts Normal file
View File

@@ -0,0 +1,60 @@
import axios from 'axios'
import type {
ViewerMapInterface,
OptionalViewerMapInterface
} from '@/views/interfaces/common'
import GeoVisio from 'geovisio'
async function fetchMapAndViewer(params?: OptionalViewerMapInterface) {
const tiles = import.meta.env.VITE_TILES
let paramsGeovisio: ViewerMapInterface = {
map: {
startWide: true,
}
}
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`,
paramsGeovisio
)
}
async function getIgnTiles(): Promise<object> {
const { data } = await axios.get(
'https://wxs.ign.fr/essentiels/static/vectorTiles/styles/PLAN.IGN/attenue.json'
)
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
}
export { fetchMapAndViewer }

BIN
src/views/.DS_Store vendored

Binary file not shown.

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">
@@ -18,11 +13,12 @@
</template>
<script lang="ts" setup>
import axios from 'axios'
import { useI18n } from 'vue-i18n'
import { onMounted, computed, ref } from 'vue'
import GeoVisio from 'geovisio'
import Button from '@/components/Button.vue'
import { fetchMapAndViewer } from '@/utils/mapAndViewer'
import { getPicId } from '@/utils/image'
import 'geovisio/build/index.css'
const { t } = useI18n()
let mapIsLoaded = ref<boolean>(false)
@@ -30,10 +26,10 @@ const mailtoPath = computed<string>(() => {
return `mailto:signalement.ign@panoramax.fr?subject=${t(
'pages.home.report_mail_subject',
{
id: getPitId()
id: getPicId()
}
)}&body=${t('pages.home.report_mail_body', {
id: getPitId(),
id: getPicId(),
link: encodeURIComponent(window.location.href)
})}`
})
@@ -42,63 +38,38 @@ function triggerMailto(): void {
window.location.href = mailtoPath.value
}
function getPitId(): string {
return window.location.href.substring(
window.location.href.indexOf('pic=') + 4,
window.location.href.lastIndexOf('&')
)
}
async function getIgnTiles(): Promise<object> {
const { data } = await axios.get(
'https://wxs.ign.fr/essentiels/static/vectorTiles/styles/PLAN.IGN/attenue.json'
)
data.sources.plan_ign.scheme = 'xyz'
data.sources.plan_ign.attribution = 'Données cartographiques : © IGN'
// Patch tms scheme to xyz to make it compatible for Maplibre GL JS / Mapbox GL JS
return data
}
onMounted(async () => {
try {
await new GeoVisio(
'viewer', // Div ID
`${import.meta.env.VITE_API_URL}api/search`, // STAC API search endpoint
{
map: {
startWide: true,
style: await getIgnTiles(),
maxZoom: 19
}
} // Viewer options
)
await fetchMapAndViewer()
mapIsLoaded.value = true
} catch (err) {
console.log(err)
}
})
</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(12);
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) {
@@ -112,7 +83,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

@@ -0,0 +1,870 @@
<template>
<main :class="['entry-page', { 'menu-is-open': menuIsOpen }]">
<div class="button-close">
<Button
look="no-text"
:icon="menuIsOpen ? 'bi bi-chevron-right' : 'bi bi-chevron-left'"
@trigger="menuIsOpen = !menuIsOpen"
/>
</div>
<section id="viewer" class="entry-viewer"></section>
<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="toggleMenu"
>
<div class="wrapper-title">
<span :class="[sequence.status, 'sequence-status']">{{
sequenceStatus
}}</span>
<h1 class="title desktop">
{{ sequence.title }}
</h1>
</div>
<i :class="headerPanelIsOpen ? 'bi bi-dash' : 'bi bi-plus'"></i>
</button>
<h1 class="title responsive">
{{ sequence.title }}
</h1>
</div>
<div class="wrapper-button">
<Button
: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="
sequence.status === 'ready' ? 'bi bi-eye-slash' : 'bi bi-eye'
"
class="disable-button"
@trigger="patchCollection"
/>
<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
:class="[sequence.status, 'collapse py-2 show']"
id="collapseTarget"
>
<div class="block-collapse">
<div class="wrapper-info-top">
<span v-if="sequence.created"
>{{ $t('pages.sequence.created') }}
{{ formatDate(new Date(sequence.created), 'Do MMMM YY') }}</span
>
<span v-if="sequence.duration"
>{{ $t('pages.sequence.duration') }}
{{ sequence.duration }}</span
>
<span v-if="sequence.taken"
>{{ $t('pages.sequence.taken') }}
{{ formatDate(sequence.taken, 'Do MMMM YY') }}</span
>
</div>
<div class="wrapper-info-top">
<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 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="pictures && pictures.length" class="photos-wrapper">
<div class="delete-all" ref="deleteAll">
<div class="wrapper-select">
<InputCheckbox
: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="
picturesToDeleteStatus === 'hidden' ||
imagesSelectedHaveDifferentStatus
? 'bi bi-eye'
: 'bi bi-eye-slash'
"
:tooltip="$t('pages.sequence.hide_photo_tooltip')"
:disabled="
!picturesToDelete.length || sequence.status === 'hidden'
"
@trigger="patchOrDeleteCollectionItems('PATCH')"
/>
<div class="button-hidde">
<Button
look="button--red"
icon="bi bi-trash"
:tooltip="$t('pages.sequence.delete_photo_tooltip')"
:disabled="!picturesToDelete.length"
@trigger="patchOrDeleteCollectionItems('DELETE')"
/>
</div>
</div>
</div>
<ul class="photo-list">
<li
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.datetime, 'HH:mm:ss')"
:selected="photoToDeleteOrPatchSelected(item, picturesToDelete)"
:selected-on-map="itemSelected === item.id"
: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 look="sm" :is-loaded="false" />
</div>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect, computed } from 'vue'
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 'geovisio/build/index.css'
import { durationCalc, formatDate } from '@/utils/dates'
import {
deleteACollectionItem,
patchACollectionItem,
fetchCollectionItems,
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,
ResponseUserSequenceInterface
} from './interfaces/MySequenceView'
const { t } = useI18n()
const route = useRoute()
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 isLoading = ref<boolean>(false)
let viewer = ref()
const collapseMenu = ref<HTMLDivElement>()
const deleteAll = ref<HTMLDivElement>()
const menuHeight = ref<string>()
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)
}
})
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']
}
return 'hidden'
})
const imagesSelectedHaveDifferentStatus = computed((): boolean => {
function filterByStatus(status: string): ResponseUserPhotoInterface[] {
return fullImagesToDelete().filter((el) => {
return el.properties['geovisio:status'] === status
})
}
return (
filterByStatus('hidden').length > 0 && filterByStatus('ready').length > 0
)
})
const isIndeterminate = computed(
(): boolean =>
!!picturesToDelete.value.length &&
!!sequence.value &&
pictures.value.length !== picturesToDelete.value.length
)
const selectedText = computed((): string =>
picturesToDelete.value.length === pictures.value.length
? t('pages.sequence.unselect_text')
: t('pages.sequence.select_text')
)
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`
}
}, 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 pictures.value.filter((el) => picturesToDelete.value.includes(el.id))
}
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
? (picturesToDelete.value = pictures.value
.filter(
(el) => el.properties['geovisio:status'] !== 'waiting-for-process'
)
.map((el) => el.id))
: (picturesToDelete.value = [])
}
function selectPhotoToDeleteOrPatch(
item: ResponseUserPhotoInterface
): string[] {
document.addEventListener('keydown', function (evt) {
if (evt.key === 'Shift') {
isShiftPressed.value = true
}
})
document.addEventListener('keyup', function (evt) {
if (evt.key === 'Shift') {
isShiftPressed.value = false
}
})
if (isShiftPressed.value) {
const picturesIndex = pictures.value.findIndex((el) => el.id === item.id)
const picturesLastIndex = pictures.value.findIndex(
(el) => el.id === picturesToDelete.value[0]
)
const slicedUserPhotos = pictures.value.slice(
picturesLastIndex,
picturesIndex + 1
)
return (picturesToDelete.value = slicedUserPhotos.map((el) => el.id))
}
if (picturesToDelete.value.includes(item.id)) {
return (picturesToDelete.value = picturesToDelete.value.filter(
(el) => el !== item.id
))
}
return (picturesToDelete.value = [...picturesToDelete.value, item.id])
}
async function selectImageAndMove(
item: ResponseUserPhotoInterface
): Promise<void> {
selectPhotoToDeleteOrPatch(item)
if (
picturesToDelete.value.length < 2 &&
item.properties['geovisio:status'] === 'ready'
) {
const viewerMap = await viewer.value
viewerMap.goToPicture(item.id, sequence.value?.id)
itemSelected.value = item.id
await goToTheGoodPage(item.id)
scrollIntoSelected(item.id, pictures.value)
}
}
async function patchOrDeleteCollectionItems(
requestType: string
): Promise<void> {
if (
requestType === 'DELETE' &&
!confirm(t('pages.sequence.confirm_pictures_dialog'))
)
return
isLoading.value = true
toastText.value = ''
const chunksItems = spliceIntoChunks(picturesToDelete.value, 4)
try {
let items: unknown[] = []
if (imagesSelectedHaveDifferentStatus.value) {
for (let el of chunksItems) {
items = [
...items,
...(await Promise.all(
el.map(async (ele) => {
if (requestType === 'PATCH') {
return await patchACollectionItem('true', route.params.id, ele)
}
return await deleteACollectionItem(route.params.id, ele)
})
))
]
}
} else {
for (let el of chunksItems) {
items = [
...items,
...(await Promise.all(
el.map(async (ele) => {
if (requestType === 'PATCH') {
const imageToDelete = pictures.value.find(
(elem) => elem.id === ele
)
const isVisible =
imageToDelete?.properties['geovisio:status'] === 'ready'
? 'false'
: 'true'
return await patchACollectionItem(
isVisible,
route.params.id,
ele
)
}
return await deleteACollectionItem(route.params.id, ele)
})
))
]
}
}
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) {
sequenceStore.addToastText(t('general.error_text'), 'error')
isLoading.value = false
picturesToDelete.value = []
}
}
</script>
<style lang="scss" scoped>
.responsive {
display: none;
}
.entry-page {
display: flex;
}
.entry-viewer {
width: 50vw;
position: relative;
height: calc(100vh - #{toRem(8)});
font-size: 137.5%;
}
.menu-right {
width: 50vw;
height: calc(100vh - #{toRem(8)});
overflow: hidden;
box-shadow: 0px 4px 20px 0px #00000033;
}
.wrapper-loader {
display: flex;
justify-content: center;
align-items: center;
}
.wrapper-title {
display: flex;
align-items: center;
}
.wrapper-button {
display: flex;
align-items: center;
}
.disable-button {
margin-right: toRem(1);
}
.collapse {
&:first-child {
@include text(s-regular);
color: var(--grey-dark);
margin-bottom: toRem(1);
}
&.hidden {
opacity: 0.4;
}
}
.block-collapse {
display: flex;
}
.wrapper-info-top {
@include text(s-regular);
color: var(--grey-dark);
}
.wrapper-info-top {
display: flex;
flex-direction: column;
margin-top: toRem(1);
&:first-child {
border-right: toRem(0.1) solid var(--grey-dark);
padding-right: toRem(2);
}
&:nth-child(2) {
padding-left: toRem(2);
}
}
.title {
@include text(h4);
color: var(--grey-dark);
margin-right: toRem(1);
margin-bottom: 0;
}
.button-close {
display: none;
}
.menu-top {
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 {
@include text(xs-r-regular);
border-radius: toRem(3);
padding: toRem(0.5) toRem(1);
margin-right: toRem(1);
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;
padding: 0;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: toRem(1);
}
.bi-plus,
.bi-dash {
color: var(--grey-dark);
font-size: toRem(3);
}
.photos-wrapper {
padding: toRem(1) 0 toRem(2) toRem(1);
height: calc(100vh - v-bind(menuHeight));
}
.delete-all {
display: flex;
justify-content: space-between;
align-items: center;
margin-left: toRem(1);
margin-right: toRem(2);
margin-bottom: toRem(1);
@include text(xs-r-regular);
}
.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: toRem(1);
margin-left: toRem(1);
}
.action-buttons {
display: flex;
align-items: center;
}
.photo-list {
display: flex;
flex-wrap: wrap;
overflow-y: auto;
height: 100%;
width: 100%;
padding: 0;
}
.photo-item {
width: calc(33% - #{toRem(2)});
height: fit-content;
margin: toRem(1);
border-radius: toRem(0.5);
background-color: var(--grey);
}
.no-photo {
@include text(s-regular);
text-align: center;
margin-top: toRem(10);
color: var(--grey-dark);
}
.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;
}
.block-collapse {
flex-direction: column;
}
.wrapper-info-top {
&:first-child,
&:nth-child(2) {
padding-right: 0;
padding-left: 0;
border-right: none;
}
&:nth-child(2) {
margin-top: 0;
}
}
.photo-item {
width: calc(50% - #{toRem(2)});
}
}
@media (max-width: toRem(76.8)) {
.desktop {
display: none;
}
.responsive {
display: initial;
}
.title {
margin-right: 0;
margin-bottom: toRem(1);
}
.wrapper-button {
flex-direction: column;
align-items: initial;
}
.disable-button {
margin-right: 0;
margin-bottom: toRem(1);
}
.button-collapse {
width: 100%;
justify-content: space-between;
margin-bottom: 0;
}
.photo-item {
width: 100%;
margin-right: 0;
}
.wrapper-info-top {
width: 100%;
margin-right: 0;
}
.wrapper-button {
margin-top: toRem(1);
}
.photo-list {
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: toRem(50)) {
.entry-page {
height: calc(100vh - #{toRem(11)});
overflow: hidden;
}
.entry-viewer {
width: 100vw;
position: fixed;
z-index: 1;
}
.menu-right {
padding-top: toRem(11);
height: 100%;
position: fixed;
top: 0;
right: 0;
z-index: 0;
width: 80vw;
background-color: var(--white);
}
.menu-top {
width: 0vw;
}
.button-close {
position: absolute;
right: 0;
top: toRem(22);
z-index: 3;
height: toRem(5);
display: flex;
align-items: center;
justify-content: center;
border-top-left-radius: toRem(0.5);
border-bottom-left-radius: toRem(0.5);
background-color: var(--white);
border: toRem(0.1) solid var(--black);
}
.menu-top {
padding: toRem(1);
}
.menu-is-open {
.menu-right {
z-index: 3;
}
.menu-top {
width: auto;
}
.button-close {
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

@@ -0,0 +1,364 @@
<template>
<main class="entry-page">
<h1 class="sequences-title">{{ $t('pages.sequences.title') }}</h1>
<ul v-if="!isLoading" class="sequence-list">
<li class="sequence-item">
<div class="sequence-header-item"></div>
<div class="sequence-header-item">
<Button
:text="$t('pages.sequences.sequence_name')"
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-title"
@trigger="sortAlpha('title')"
/>
</div>
<div class="sequence-header-item">
<Button
:text="$t('pages.sequences.sequence_photos')"
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-number"
@trigger="sortNum('num')"
/>
</div>
<div class="sequence-header-item">
<Button
:text="$t('pages.sequences.sequence_date')"
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-date"
@trigger="sortNum('date')"
/>
</div>
<div class="sequence-header-item">
<Button
:text="$t('pages.sequences.sequence_status')"
look="link--grey"
icon="bi bi-arrow-down-up"
data-test="button-sort-date"
@trigger="sortAlpha('geovisio:status')"
/>
</div>
</li>
<li
v-if="userSequences.length"
v-for="item in userSequences"
class="sequence-item"
>
<router-link
class="button-item"
:to="{
name: 'sequence',
params: { id: item.id }
}"
>
<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>
{{ item.title }}
</span>
</div>
<div>
<i class="bi bi-images"></i>
<span>
{{ item['stats:items'].count }}
</span>
</div>
<div>
<span>
{{
formatDate(
item.extent.temporal.interval[0][0],
'Do MMMM YYYY HH:mm:ss'
)
}}
</span>
</div>
<div>
<span>{{ sequenceStatus(item['geovisio:status']) }}</span>
</div>
</router-link>
</li>
<div v-else class="no-sequence">
<p class="no-sequence-text">
{{ $t('pages.sequences.no_sequences_text') }}
</p>
<Link
:text="$t('general.header.upload_text')"
look="button"
: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,
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')
return t('pages.sequences.sequence_waiting')
return t('pages.sequences.sequence_hidden')
}
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)
}
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)
}
})
</script>
<style lang="scss" scoped>
.entry-page {
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: toRem(4);
}
.sequence-list {
box-shadow: 0px 2px 30px 0px #0000000f;
border-radius: toRem(2);
padding: 0;
}
.sequence-item {
@include text(s-regular);
border: none;
display: flex;
justify-content: center;
margin: auto;
background-color: var(--blue-pale);
&:last-child {
border-bottom-right-radius: toRem(2);
border-bottom-left-radius: toRem(2);
.button-item {
border-bottom-right-radius: toRem(2);
border-bottom-left-radius: toRem(2);
}
}
&:first-child {
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) {
background-color: var(--white);
}
}
.wrapper-title {
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: toRem(0.5);
position: relative;
}
.button-item {
display: flex;
align-items: center;
width: 100%;
padding: toRem(2) toRem(3);
background-color: transparent;
border: none;
text-decoration: none;
& > * {
padding: toRem(1);
text-align: initial;
width: 31%;
color: var(--black);
}
> :first-child {
color: var(--blue);
width: toRem(6);
}
& > :first-child {
padding: 0;
margin-right: toRem(2);
}
& > :nth-child(2) {
color: var(--blue);
}
&:hover {
background-color: var(--blue);
& > * {
color: var(--white);
}
}
}
.bi-images {
margin-right: toRem(0.5);
}
.sequence-header-item {
width: 31%;
&:first-child {
margin-right: toRem(2);
}
&:first-child {
width: toRem(6);
}
}
.no-sequence {
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: toRem(4);
}
.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: toRem(2);
padding-left: toRem(2);
padding-top: toRem(14);
min-height: calc(100vh - #{toRem(11)});
}
.button-item,
.sequence-item:first-child {
padding-right: toRem(1);
padding-left: toRem(1);
}
}
@media (max-width: toRem(50)) {
.button-item {
flex-direction: column;
align-items: center;
& > * {
text-align: center;
width: 100%;
}
.wrapper-thumb {
margin-right: 0;
}
}
.sequence-item:first-child {
display: none;
}
.sequence-item {
border-top-right-radius: toRem(1);
border-top-left-radius: toRem(1);
}
}
</style>

View File

@@ -9,7 +9,8 @@
<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'
"
@@ -25,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'
@@ -75,9 +76,7 @@ async function copyText(i: number): Promise<void> {
}
onMounted(async () => {
try {
const data = await axios.get(
`${import.meta.env.VITE_API_URL}api/users/me/tokens`
)
const data = await axios.get('api/users/me/tokens')
userTokens.value = data.data
} catch (err) {
console.log(err)
@@ -87,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;
@@ -121,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;
@@ -139,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;
@@ -163,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

@@ -3,7 +3,7 @@
<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>
<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>
@@ -11,41 +11,45 @@
<div class="element-check">
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type1') }}</span
>{{ $t('pages.share_pictures.photo_type1') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type2') }}</span
>{{ $t('pages.share_pictures.photo_type2') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type3') }}</span
>{{ $t('pages.share_pictures.photo_type3') }}</span
>
<span class="block-check"
><span class="check-border"></span
>{{ $t('pages.upload.photo_type4') }}</span
>{{ $t('pages.share_pictures.photo_type4') }}</span
>
</div>
</div>
<p
class="upload-text"
v-html="
$t('pages.upload.description', {
$t('pages.share_pictures.description', {
check: checkImg
})
"
/>
<p class="upload-text">{{ $t('pages.upload.footer_block') }}</p>
<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.upload.sub_title') }}
{{ $t('pages.share_pictures.sub_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.upload.user_account_button')"
:text="$t('pages.share_pictures.user_account_button')"
type="external"
look="button white"
:path="loginUrl"
look="button button--blue"
:path-external="
getAuthRoute('auth/login', 'partager-des-photos')
"
/>
</div>
</div>
@@ -54,7 +58,7 @@
<div class="image">
<img
src="@/assets/images/upload.png"
:alt="$t('pages.upload.alt_img_upload')"
:alt="$t('pages.share_pictures.alt_img_upload')"
/>
</div>
</div>
@@ -74,30 +78,32 @@
<div class="entry-terminal">
<div class="terminal">
<Terminal
:text-upload="$t('pages.upload.terminal_text')"
:text-upload="terminalText"
:text-install="terminalTextInstall"
/>
</div>
</div>
<div class="wrapper-upload-text">
<h2 class="upload-title">{{ $t('pages.upload.title_terminal') }}</h2>
<h2 class="upload-title">
{{ $t('pages.share_pictures.title_terminal') }}
</h2>
<p
class="upload-text"
v-html="$t('pages.upload.description_terminal')"
v-html="$t('pages.share_pictures.description_terminal')"
></p>
<p
class="upload-text grey"
v-html="$t('pages.upload.footer_description_terminal')"
v-html="$t('pages.share_pictures.footer_description_terminal')"
></p>
<div class="wrapper-account">
<h4 class="account-subtitle">
{{ $t('pages.upload.cli_title') }}
{{ $t('pages.share_pictures.cli_title') }}
</h4>
<div class="entry-link">
<Link
:text="$t('pages.upload.button')"
:text="$t('pages.share_pictures.button')"
type="external"
look="button"
look="button button--blue"
path="https://gitlab.com/geovisio/cli"
/>
</div>
@@ -115,6 +121,7 @@ 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()
@@ -124,17 +131,17 @@ 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>"
"<span style='background:white; padding: 5px 0.0525px;border-radius:50%; font-size:8px;'>✔</span>"
const loginUrl = computed<string>(
() =>
`${
import.meta.env.VITE_API_URL
}api/auth/login?next_url=/partager-des-photos`
)
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.upload.terminal_install')
t('pages.share_pictures.terminal_install')
)
function triggerHref(): void {
icon.value =
@@ -151,32 +158,32 @@ function triggerHref(): void {
@include text(h4);
}
.entry-link {
margin-top: 1rem;
margin-top: toRem(1);
}
.upload-text {
@include text(m-regular);
margin-top: 5rem;
margin-top: toRem(5);
}
.wrapper-check {
position: relative;
width: fit-content;
padding: 3rem;
padding: toRem(3);
background-color: var(--white);
border-radius: 1.5rem;
font-size: 1.6rem;
margin-top: 8rem;
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: 5.5rem;
width: 5.5rem;
height: toRem(5.5);
width: toRem(5.5);
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
top: -3rem;
font-size: toRem(3);
top: toRem(-3);
left: 50%;
transform: translate(-50%);
}
@@ -186,31 +193,31 @@ function triggerHref(): void {
.element-check {
display: flex;
flex-direction: column;
margin-top: 3rem;
margin-top: toRem(3);
}
.block-check {
margin-bottom: 1rem;
margin-bottom: toRem(1);
display: flex;
}
.check-border {
display: flex;
justify-content: center;
align-items: center;
width: 2.2rem;
height: 2.2rem;
width: toRem(2.2);
height: toRem(2.2);
border-radius: 50%;
font-size: 0.8rem;
font-size: toRem(0.8);
background: var(--white);
border: 0.1rem solid var(--black);
margin-right: 0.5rem;
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: 2rem;
padding-left: 2rem;
padding-right: toRem(2);
padding-left: toRem(2);
display: flex;
flex-direction: column;
justify-content: center;
@@ -229,26 +236,26 @@ function triggerHref(): void {
white-space: pre-wrap;
}
.grey {
color: var(--grey-dark);
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;
margin-top: toRem(3);
padding-top: toRem(1);
border-top: toRem(0.1) solid #e6e6e6;
}
.upload-button {
display: flex;
margin-top: 3rem;
margin-top: toRem(3);
}
.image {
background-color: var(--white);
border-radius: 1rem;
padding: 1rem;
border: 1px solid black;
border-radius: toRem(1);
padding: toRem(1);
border: 1px solid var(--black);
width: 75%;
height: fit-content;
}
@@ -257,30 +264,30 @@ function triggerHref(): void {
justify-content: center;
width: 35%;
overflow: hidden;
margin-left: 6rem;
margin-left: toRem(6);
}
.entry-terminal {
margin-left: 0;
margin-right: 6rem;
margin-right: toRem(6);
}
.entry-image img {
width: 100%;
border-radius: 1rem;
border-radius: toRem(1);
}
.entry-button-down {
z-index: 1;
position: fixed;
right: 2rem;
bottom: calc(20vh - 10.5rem);
right: toRem(2);
bottom: calc(20vh - #{toRem(10.5)});
}
@media (max-width: 1024px) {
@media (max-width: toRem(102.4)) {
.section-upload {
height: initial;
}
.wrapper-upload {
flex-direction: column-reverse;
padding-top: 6rem;
padding-bottom: 6rem;
padding-top: toRem(6);
padding-bottom: toRem(6);
}
.wrapper-upload-text {
width: 100%;
@@ -294,12 +301,12 @@ function triggerHref(): void {
}
.terminal {
width: 100%;
margin-top: 4rem;
margin-top: toRem(4);
}
}
@media (max-width: 768px) {
@media (max-width: toRem(76.8)) {
.entry-page {
padding-top: 11rem;
padding-top: toRem(11);
}
.entry-image {
width: 60%;
@@ -314,10 +321,10 @@ function triggerHref(): void {
margin-right: 0;
}
}
@media (max-width: 500px) {
@media (max-width: toRem(50)) {
.entry-page {
min-height: calc(100vh - 11rem);
padding-top: 11rem;
min-height: calc(100vh - #{toRem(11)});
padding-top: toRem(11);
}
.entry-image {
width: 100%;
@@ -333,14 +340,14 @@ function triggerHref(): void {
padding-left: 0;
}
.upload-text {
margin-bottom: 3rem;
margin-bottom: toRem(3);
}
.wrapper-account {
padding-top: 3rem;
padding-top: toRem(3);
flex-direction: column;
}
.account-subtitle {
margin-bottom: 2rem;
margin-bottom: toRem(2);
}
.entry-button-down {
display: none;

View File

@@ -0,0 +1,266 @@
<template>
<main class="entry-page">
<section class="upload-section">
<h1 class="settings-title">{{ $t('pages.upload.title') }}</h1>
<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"
data-test="button-upload"
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"
/>
</section>
<ImportedSection
v-for="(sequence, i) in uploadedSequences"
:index="i"
:sequence="sequence"
:pictures-count="picturesCount"
:upload-errors="sequence.picturesOnError"
:upload-pictures="sequence.pictures"
/>
</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 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,
sortByName
} from '@/views/utils/upload/index'
const { t } = useI18n()
let pictures = ref<File[] | []>([])
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 {
const files = sortByName([].slice.call(value))
pictures.value = files
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;
justify-content: center;
align-items: center;
}
.upload-section {
width: 80%;
padding: toRem(4);
margin-top: toRem(6);
margin-bottom: toRem(4);
box-shadow: 0px 6.45694px 8.60925px rgba(0, 0, 0, 0.05);
}
.settings-title {
text-align: center;
@include text(h1);
margin-bottom: toRem(4);
}
.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 {
width: toRem(2);
}
::-webkit-scrollbar-track {
background: var(--grey-pale);
}
::-webkit-scrollbar-thumb {
background: var(--blue);
border-radius: toRem(5);
}
@media (max-width: toRem(76.8)) {
.entry-page {
overflow-x: hidden;
}
.information-section {
flex-direction: column;
}
}
@media (max-width: toRem(50)) {
.upload-section {
margin-top: toRem(15);
width: 100%;
padding-right: toRem(2);
padding-left: toRem(2);
}
.information-section {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,42 @@
export interface ResponseUserPhotoInterface {
assets: { thumb: { href: string }; hd: { href: 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
created: string
taken: Date
location: string
imageCount: number
duration: string
camera: string
cameraModel: string
status: string
extent: { temporal: { interval: [Date[]] }; spatial: { bbox: string[] } }
}
export interface ResponseUserSequenceInterface extends UserSequenceInterface {
['stats:items']: { count: number }
summaries: {
['pers:interior_orientation']: [{ make: string; model: string }]
}
'geovisio:status': string
}
export interface CheckboxInterface {
isChecked: boolean
isIndeterminate: boolean
}

View File

@@ -0,0 +1,16 @@
export interface LinkInterface {
id: string
href: string
rel: string
title: string
type: string
extent: { temporal: { interval: [Date[]] } }
['stats:items']: { count: number }
['geovisio:status']: string
}
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

@@ -0,0 +1,14 @@
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,16 @@
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]}`
}
function sortByName(fileList: File[]): File[] {
return fileList.sort((a: File, b: File) =>
a.name.localeCompare(b.name, navigator.languages[0] || navigator.language, {
numeric: true,
ignorePunctuation: true
})
)
}
export { formatPictureSize, formatTextSize, sortByName }

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

@@ -10,7 +10,8 @@ export default defineConfig({
port: 5173,
strictPort: true,
hmr: {
port: 9000
clientPort: 5173,
overlay: false
}
},
base: '/',
@@ -18,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']
}
}
})

File diff suppressed because it is too large Load Diff

3495
yarn.lock

File diff suppressed because it is too large Load Diff