This commit is contained in:
Jérémy Munsch 2025-08-10 12:00:35 +02:00
commit 0526ecc057
No known key found for this signature in database
GPG key ID: C47336D38CCC8FAC
392 changed files with 24372 additions and 0 deletions

20
.editorconfig Normal file
View file

@ -0,0 +1,20 @@
# EditorConfig is awesome: https://editorconfig.org
# top-most EditorConfig file
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

8
.env.example Normal file
View file

@ -0,0 +1,8 @@
# - APP VARS
PUBLIC_APP_NAME="Z3"
# - BACKEND VARS
BACKEND_URL=http://localhost
# Size in KB (51200Ko => 50Mo)
BODY_SIZE_LIMIT=51200

287
.gitignore vendored Normal file
View file

@ -0,0 +1,287 @@
# Created by https://www.toptal.com/developers/gitignore/api/node,svelte,visualstudiocode,vercel,intellij+all,dotenv
# Edit at https://www.toptal.com/developers/gitignore?templates=node,svelte,visualstudiocode,vercel,intellij+all,dotenv
### dotenv ###
.env
### Intellij+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### Intellij+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Svelte ###
# gitignore template for the SvelteKit, frontend web component framework
# website: https://kit.svelte.dev/
.svelte-kit/
package
### Vercel ###
.vercel
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# End of https://www.toptal.com/developers/gitignore/api/node,svelte,visualstudiocode,vercel,intellij+all,dotenv
# Output
.output
.vercel
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# VSCODE personal launch
.vscode/launch.json
.vscode/tasks.json

42
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,42 @@
image: quay.io/buildah/stable
stages:
- release
- build
build-staging:
stage: build
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:staging
before_script:
- buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- buildah bud -t "$IMAGE_TAG" .
- buildah push "$IMAGE_TAG"
only:
- main
branch-release:
image: docker.io/bitnami/git
variables:
GIT_STRATEGY: clone
stage: release
script:
# create branch ; if the tag is v2024.01, the branch will be rel/2024.01 (without the v)
- git switch -c rel/${CI_COMMIT_TAG:1}
- git push origin rel/${CI_COMMIT_TAG:1}
rules:
# only for tags like v2024.01, v2024.01.01, v2024.01-1, v2024.01.01-1
- if: $CI_COMMIT_TAG =~ /^v\d{4}\.\d{2}(\.\d{2})?(-\d+)?$/
build-release:
stage: build
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:latest
before_script:
- buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
script:
- buildah bud -t "$IMAGE_TAG" .
- buildah push "$IMAGE_TAG"
only:
- /^rel\//

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

6
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,6 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": [
"dbaeumer.vscode-eslint"
]
}

66
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,66 @@
{
"typescript.experimental.updateImportsOnPaste": true,
"[html]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "vscode.html-language-features",
"editor.formatOnSave": true
},
"[scss]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "stylelint.vscode-stylelint",
"editor.formatOnSave": true
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always",
"source.organizeImports": "always"
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.formatOnSave": true
},
"[svelte]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.formatOnSave": true
},
"svelte.plugin.svelte.format.enable": false,
"svelte.plugin.typescript.codeActions.enable": false,
"svelte.plugin.css.completions.enable": true,
"svelte.enable-ts-plugin": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"svelte",
"typescript"
],
"html.format.unformattedContentDelimiter": "<!--noformat-->",
"html.customData": [
"./node_modules/@shoelace-style/shoelace/dist/vscode.html-custom-data.json"
],
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#b1d38b",
"activityBar.background": "#b1d38b",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#4683b9",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#15202b99",
"sash.hoverBorder": "#b1d38b",
"statusBar.background": "#98c566",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#7fb444",
"statusBarItem.remoteBackground": "#98c566",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#98c566",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#98c56699",
"titleBar.inactiveForeground": "#15202b99"
},
"peacock.color": "#98c566"
}

188
CHANGELOG.md Normal file
View file

@ -0,0 +1,188 @@
## 1.1.0 (2025-03-27)
### Features
* Add download btn on statistics graphics ([1106898](https://gitlab.pegase.tech/z3/z3-frontend/commit/1106898adb9bebb78e78eb86a478b66939c76bcd))
* Add most used referrers by brand ([8ce2db7](https://gitlab.pegase.tech/z3/z3-frontend/commit/8ce2db771d3c880ed55989b6c54ac8342d48194e))
* Add most used source by brand statistic ([53a7a29](https://gitlab.pegase.tech/z3/z3-frontend/commit/53a7a296fd6e361f9626be86b21d51e63c794d98))
* WIP Application Statistics with associated help section ([c30e2d5](https://gitlab.pegase.tech/z3/z3-frontend/commit/c30e2d56d0a2067b718a9ae8f8ec3ccf61f5a0b1))
### Bug Fixes
* :bug: BrandService: Prevent requesting more brands on getAll if returned brands amount is not equal to perPage ([324f5c8](https://gitlab.pegase.tech/z3/z3-frontend/commit/324f5c8700f1e3aa6556144cfce9d5265f391489))
* :bug: Fixed search links debounce ([ab0e60a](https://gitlab.pegase.tech/z3/z3-frontend/commit/ab0e60a76e50d9aed34fc79361eaa259d6e5f894))
* :lipstick: Fix link input search appeareance ([f4fac73](https://gitlab.pegase.tech/z3/z3-frontend/commit/f4fac73ac1ddd54ad57ddd8a266751c8457e2b2b))
* :lipstick: Fix some UI issues on indexes view background color of the action column ([488e522](https://gitlab.pegase.tech/z3/z3-frontend/commit/488e522c3db2a56cf29e13089af6b9e212c80e58))
* Add missing help page title translation for visits ([9f4bf8e](https://gitlab.pegase.tech/z3/z3-frontend/commit/9f4bf8e3af077f5d4631a6b617de019a59065a9d))
* Add missing padding X on login inputs ([75cf351](https://gitlab.pegase.tech/z3/z3-frontend/commit/75cf35167303de9dafd38206a668cbcffa605520))
* Add missing statistic canvas title for unique visitors per catalog ([6a0898d](https://gitlab.pegase.tech/z3/z3-frontend/commit/6a0898dc7ab6be017281d1545609dab01ea8be85))
* fully translate main menu ([e5f2fbc](https://gitlab.pegase.tech/z3/z3-frontend/commit/e5f2fbc1dee6c01d74ed1835b362e52d4dc0a806))
* link.viewAny page responsiveness ([bbb9a81](https://gitlab.pegase.tech/z3/z3-frontend/commit/bbb9a81d84477b7ef55aa6cfb0aefc12c1199857))
* remove unwanted padding on 'container-fluid' class ([ab5ab30](https://gitlab.pegase.tech/z3/z3-frontend/commit/ab5ab301f7d3dc41e259f929bc1391afc806e9cf))
* tabs on link import and export pages ([72d08fa](https://gitlab.pegase.tech/z3/z3-frontend/commit/72d08fa786e9340ed9e8f4ff0b9f8bd5fe46ed1e))
* vite config __dirname was not found ([255c220](https://gitlab.pegase.tech/z3/z3-frontend/commit/255c2203311ae6ea44e67917de93a1447e9f2798))
* wrong previous button placements on sompte pages ([2ff463e](https://gitlab.pegase.tech/z3/z3-frontend/commit/2ff463eb16a0d18f238e6ad88070a37705b8ee84))
* Wrong title on top lists statistics ([4fababd](https://gitlab.pegase.tech/z3/z3-frontend/commit/4fababd03c36e1740f3528e5cf40c0c78ba3db2e))
## 1.0.5 (2025-03-05)
### Features
* :sparkles: Add FQDN to brand for crud actions ([42d4ef1](https://gitlab.pegase.tech/z3/z3-frontend/commit/42d4ef189e386147cd8776db8a827ab94ea33f01))
* Closes [#6](https://gitlab.pegase.tech/z3/z3-frontend/issues/6) add shortcut ton links from catalogs and tags ([ae560d2](https://gitlab.pegase.tech/z3/z3-frontend/commit/ae560d240d30afce9adddfa2d7b6a253b812c8e5))
* Introcude link validity third state ([839a5cc](https://gitlab.pegase.tech/z3/z3-frontend/commit/839a5cc5939893f68c26cc214d8da0b5dcb14a5b))
* Old values can now handle arrays ([9052568](https://gitlab.pegase.tech/z3/z3-frontend/commit/9052568779c8de589f63c03d2f133f443f549c23))
### Bug Fixes
* :bug: Closes [#3](https://gitlab.pegase.tech/z3/z3-frontend/issues/3) Missing user menu on mobile ([349c250](https://gitlab.pegase.tech/z3/z3-frontend/commit/349c250e74cefd7b0a5ecbeb2255d271a0a147ad))
* :bug: Closes [#7](https://gitlab.pegase.tech/z3/z3-frontend/issues/7) with wrong button tooltips ([2ba5112](https://gitlab.pegase.tech/z3/z3-frontend/commit/2ba51120936191962c68aedd19c5daef540ebf2b))
* :bug: Prevent double invalidate all on brand switch ([1d5f790](https://gitlab.pegase.tech/z3/z3-frontend/commit/1d5f7909457caad471b1d744303fadf82b6e5851))
* :bug: Refresh stats for each stat subpage on brand selector change ([05eaf2a](https://gitlab.pegase.tech/z3/z3-frontend/commit/05eaf2ab6581dc612abc0397b8fb0141a4a235d1))
* Closes [#2](https://gitlab.pegase.tech/z3/z3-frontend/issues/2) Fix all brands stats refresh on state change ([7124af5](https://gitlab.pegase.tech/z3/z3-frontend/commit/7124af501cf3bd12692eda924e00fd4ef05d4921))
* onCopyResource argument was url ([6843ce0](https://gitlab.pegase.tech/z3/z3-frontend/commit/6843ce093b327fde5e1228445cfed537c8bdbdda))
* Write better statistics wordings ([1807ab8](https://gitlab.pegase.tech/z3/z3-frontend/commit/1807ab8a890466d959b1043c36774048a827d9c1))
## 1.0.3 (2025-01-22)
### Features
* :sparkles: Allow to set ReportingSubscriptions for any user (Invalid links alerts) ([3536bc7](https://gitlab.pegase.tech/z3/z3-frontend/commit/3536bc7a127d5f2bc3a8317ca47000499f063628))
* :sparkles: Validate link feature ([b7452cf](https://gitlab.pegase.tech/z3/z3-frontend/commit/b7452cf3bd121558926ba225af92f3d3743ec463))
* Add initial Helper page ([400b5d4](https://gitlab.pegase.tech/z3/z3-frontend/commit/400b5d4376d01b07163ec37856fc9246ddb3e094))
* Added inital Help ([badfec9](https://gitlab.pegase.tech/z3/z3-frontend/commit/badfec9500b7dcadb935edd73ee58b9221ba1c1b))
* Properly display import information ([31073e7](https://gitlab.pegase.tech/z3/z3-frontend/commit/31073e77563720d27f171bf1ecbe9f3303b189cf))
### Bug Fixes
* :bug: Select a default active brand when user is admin ([7e196bc](https://gitlab.pegase.tech/z3/z3-frontend/commit/7e196bc7cdf454f27cdb69af57d65a88e30063fd))
## 1.0.2 (2024-12-10)
### Features
* Add loading and keep focus on link search ([3fa9ffe](https://gitlab.pegase.tech/z3/z3-frontend/commit/3fa9ffed9b8507194aad9d3ce7b47d6a61b83435))
* Allow to set tags while importing links ([79307c7](https://gitlab.pegase.tech/z3/z3-frontend/commit/79307c78f311d9ba974db45980d3434e03fc8e63))
### Bug Fixes
* :bug: Handle api error on hooks.erver load and clear session when calli backend ([e716787](https://gitlab.pegase.tech/z3/z3-frontend/commit/e716787c9a0ff49fb6fd69ffbe705873e29ded94))
* :lipstick: Fix menu responsiveness ([981f740](https://gitlab.pegase.tech/z3/z3-frontend/commit/981f7406c0dcc029b383a89419a5c8f5c55e2a1f))
* :lipstick: Fix some colors issues and change light theme ([1d3e6db](https://gitlab.pegase.tech/z3/z3-frontend/commit/1d3e6db56ae689b4b0af74fd0f1b974688b06b8c))
* :lipstick: page min with removal for Chrome compat with ascensors ([ba4c95a](https://gitlab.pegase.tech/z3/z3-frontend/commit/ba4c95a7ca43e90a24a20687fa346194e39872c5))
* correct password change rate limiter ([ef69420](https://gitlab.pegase.tech/z3/z3-frontend/commit/ef6942014bb9eaa67a7be3cf0cb105fab61ac630))
* Create link, change source input to text instead of url ([c6aa313](https://gitlab.pegase.tech/z3/z3-frontend/commit/c6aa313f91ff267e3951e233e3d756fdb87f91e9))
* Export: Prevent sveltekit error on navigating to an API URL ([19940ed](https://gitlab.pegase.tech/z3/z3-frontend/commit/19940ed2e4727f2cf09039bdb6f5ac4a9a92fa70))
* File import fix ([93dd594](https://gitlab.pegase.tech/z3/z3-frontend/commit/93dd59428226dd69049c95317968d5ab99832a7b))
* flash message circle that prevents navigating after a create or update ([c906d31](https://gitlab.pegase.tech/z3/z3-frontend/commit/c906d311e47cfe6475e6bea85593877941d18545))
* Import was returning wrong message ([175cb38](https://gitlab.pegase.tech/z3/z3-frontend/commit/175cb38a074a0f7d7fa445125fe6d0db7834d154))
* Link import correctly format output message ([2bba644](https://gitlab.pegase.tech/z3/z3-frontend/commit/2bba644669a5a4809e5bcd90c501a969c38f4a97))
* Remove forgotten password link on login screen ([1572b20](https://gitlab.pegase.tech/z3/z3-frontend/commit/1572b20a32d738a7e6e1c6ea60eabe19ef9497c2))
* reset forms after link import ([f01f776](https://gitlab.pegase.tech/z3/z3-frontend/commit/f01f7769d8a9a94f7b7ac70234e03755f4ac8b66))
* set first active brand available for newly loggedin user ([d235394](https://gitlab.pegase.tech/z3/z3-frontend/commit/d2353947e9d7686a6facc0ac7cd94cbc35ba7681))
* Statistics export was getting empty files ([ce520a6](https://gitlab.pegase.tech/z3/z3-frontend/commit/ce520a66b9260b0ec7a1cdb39e95d8dee611c88e))
## 1.0.1 (2024-12-08)
### Features
* :sparkles: Allo to assign any user some brands, these will be used to change the current app context ([1140c3b](https://gitlab.pegase.tech/z3/z3-frontend/commit/1140c3b01734ae397d0079259cf84c66bcced443))
* :sparkles: Allow user to change its password ([a77d1d3](https://gitlab.pegase.tech/z3/z3-frontend/commit/a77d1d322571906278da752beaff8ec64d0f071b))
* :sparkles: User password reset ([42cf00f](https://gitlab.pegase.tech/z3/z3-frontend/commit/42cf00f36ab067898eb20c6d05cd6487e476dfea))
* Add global statistics ([bf0f5b8](https://gitlab.pegase.tech/z3/z3-frontend/commit/bf0f5b897e368413d1912a0237265ec50c646be0))
* Display brand stats on home page ([7b23276](https://gitlab.pegase.tech/z3/z3-frontend/commit/7b23276c5e5587e3cfae995f16d12d3da45d2e87))
* Link statistics and export ([d12ee89](https://gitlab.pegase.tech/z3/z3-frontend/commit/d12ee89ac89acc212ef543d9e0131c15931b43b8))
### Bug Fixes
* :lipstick: Message component text was not responsive ([da4a927](https://gitlab.pegase.tech/z3/z3-frontend/commit/da4a9272d815848e1910d83198da23ea08bae26c))
* back navigation for import and export links ([2d8a530](https://gitlab.pegase.tech/z3/z3-frontend/commit/2d8a530c0e8e41ee40d95a26fdb97592ebe73212))
* Bearer token was a singleton ... ([faaa44f](https://gitlab.pegase.tech/z3/z3-frontend/commit/faaa44f97d2dbb997b81e93153e0281ebcb694f2))
* Brand crud navigation fixes ([466ed97](https://gitlab.pegase.tech/z3/z3-frontend/commit/466ed97e78aa221691694236b0905a8c4c33ae9d))
* Catalog crud fixes ([161b6c2](https://gitlab.pegase.tech/z3/z3-frontend/commit/161b6c2c5c63bbf0049e999ccbdaf55622e4bcbc))
* Set Accept-Language header to translate quarkus messages automatically ([956ef24](https://gitlab.pegase.tech/z3/z3-frontend/commit/956ef24ecf7d59dbd0b3f1bd49d582d52efbf9f2))
* Users.show Hide user delete button for and admin own account ([c703885](https://gitlab.pegase.tech/z3/z3-frontend/commit/c703885642a12ac4c1e16d8e51d283d3d4efce2e))
* Users.show Hide user reset form for user own profile ([0649c18](https://gitlab.pegase.tech/z3/z3-frontend/commit/0649c18134af26f8d3c577eb0b1b3fd0c7499ac5))
## 0.0.1 (2024-09-27)
## [1.0.5](https://gitlab.pegase.tech/z3/z3-frontend/compare/1.0.4...1.0.5) (2025-03-05)
### Features
* :sparkles: Add FQDN to brand for crud actions ([42d4ef1](https://gitlab.pegase.tech/z3/z3-frontend/commit/42d4ef189e386147cd8776db8a827ab94ea33f01))
* Old values can now handle arrays ([9052568](https://gitlab.pegase.tech/z3/z3-frontend/commit/9052568779c8de589f63c03d2f133f443f549c23))
### Bug Fixes
* onCopyResource argument was url ([6843ce0](https://gitlab.pegase.tech/z3/z3-frontend/commit/6843ce093b327fde5e1228445cfed537c8bdbdda))
* Write better statistics wordings ([1807ab8](https://gitlab.pegase.tech/z3/z3-frontend/commit/1807ab8a890466d959b1043c36774048a827d9c1))
## [1.0.4](https://gitlab.pegase.tech/z3/z3-frontend/compare/1.0.3...1.0.4) (2025-02-19)
### Features
* Introcude link validity third state ([839a5cc](https://gitlab.pegase.tech/z3/z3-frontend/commit/839a5cc5939893f68c26cc214d8da0b5dcb14a5b))
* Closes [#6](https://gitlab.pegase.tech/z3/z3-frontend/issues/6) add shortcut ton links from catalogs and tags ([ae560d2](https://gitlab.pegase.tech/z3/z3-frontend/commit/ae560d240d30afce9adddfa2d7b6a253b812c8e5))
### Bug Fixes
* :bug: Closes [#3](https://gitlab.pegase.tech/z3/z3-frontend/issues/3) Missing user menu on mobile ([349c250](https://gitlab.pegase.tech/z3/z3-frontend/commit/349c250e74cefd7b0a5ecbeb2255d271a0a147ad))
* :bug: Prevent double invalidate all on brand switch ([1d5f790](https://gitlab.pegase.tech/z3/z3-frontend/commit/1d5f7909457caad471b1d744303fadf82b6e5851))
* :bug: Refresh stats for each stat subpage on brand selector change ([05eaf2a](https://gitlab.pegase.tech/z3/z3-frontend/commit/05eaf2ab6581dc612abc0397b8fb0141a4a235d1))
* Closes [#2](https://gitlab.pegase.tech/z3/z3-frontend/issues/2) Fix all brands stats refresh on state change ([7124af5](https://gitlab.pegase.tech/z3/z3-frontend/commit/7124af501cf3bd12692eda924e00fd4ef05d4921))
* :bug: Closes [#7](https://gitlab.pegase.tech/z3/z3-frontend/issues/7) with wrong button tooltips ([2ba5112](https://gitlab.pegase.tech/z3/z3-frontend/commit/2ba51120936191962c68aedd19c5daef540ebf2b))
## [1.0.3](https://gitlab.pegase.tech/z3/z3-frontend/compare/1.0.2...1.0.3) (2025-01-22)
### Features
* :sparkles: Allow to set ReportingSubscriptions for any user (Invalid links alerts) ([3536bc7](https://gitlab.pegase.tech/z3/z3-frontend/commit/3536bc7a127d5f2bc3a8317ca47000499f063628))
* :sparkles: Validate link feature ([b7452cf](https://gitlab.pegase.tech/z3/z3-frontend/commit/b7452cf3bd121558926ba225af92f3d3743ec463))
* Add initial Helper page ([400b5d4](https://gitlab.pegase.tech/z3/z3-frontend/commit/400b5d4376d01b07163ec37856fc9246ddb3e094))
* Added inital Help ([badfec9](https://gitlab.pegase.tech/z3/z3-frontend/commit/badfec9500b7dcadb935edd73ee58b9221ba1c1b))
* Properly display import information ([31073e7](https://gitlab.pegase.tech/z3/z3-frontend/commit/31073e77563720d27f171bf1ecbe9f3303b189cf))
### Bug Fixes
* :bug: Select a default active brand when user is admin ([7e196bc](https://gitlab.pegase.tech/z3/z3-frontend/commit/7e196bc7cdf454f27cdb69af57d65a88e30063fd))
## [1.0.2](https://gitlab.pegase.tech/z3/z3-frontend/compare/1.0.1...1.0.2) (2024-12-10)
### Features
* Add loading and keep focus on link search ([3fa9ffe](https://gitlab.pegase.tech/z3/z3-frontend/commit/3fa9ffed9b8507194aad9d3ce7b47d6a61b83435))
* Allow to set tags while importing links ([79307c7](https://gitlab.pegase.tech/z3/z3-frontend/commit/79307c78f311d9ba974db45980d3434e03fc8e63))
### Bug Fixes
* :bug: Handle api error on hooks.erver load and clear session when calli backend ([e716787](https://gitlab.pegase.tech/z3/z3-frontend/commit/e716787c9a0ff49fb6fd69ffbe705873e29ded94))
* :lipstick: Fix menu responsiveness ([981f740](https://gitlab.pegase.tech/z3/z3-frontend/commit/981f7406c0dcc029b383a89419a5c8f5c55e2a1f))
* :lipstick: Fix some colors issues and change light theme ([1d3e6db](https://gitlab.pegase.tech/z3/z3-frontend/commit/1d3e6db56ae689b4b0af74fd0f1b974688b06b8c))
* :lipstick: page min with removal for Chrome compat with ascensors ([ba4c95a](https://gitlab.pegase.tech/z3/z3-frontend/commit/ba4c95a7ca43e90a24a20687fa346194e39872c5))
* correct password change rate limiter ([ef69420](https://gitlab.pegase.tech/z3/z3-frontend/commit/ef6942014bb9eaa67a7be3cf0cb105fab61ac630))
* Create link, change source input to text instead of url ([c6aa313](https://gitlab.pegase.tech/z3/z3-frontend/commit/c6aa313f91ff267e3951e233e3d756fdb87f91e9))
* Export: Prevent sveltekit error on navigating to an API URL ([19940ed](https://gitlab.pegase.tech/z3/z3-frontend/commit/19940ed2e4727f2cf09039bdb6f5ac4a9a92fa70))
* File import fix ([93dd594](https://gitlab.pegase.tech/z3/z3-frontend/commit/93dd59428226dd69049c95317968d5ab99832a7b))
* flash message circle that prevents navigating after a create or update ([c906d31](https://gitlab.pegase.tech/z3/z3-frontend/commit/c906d311e47cfe6475e6bea85593877941d18545))
* Import was returning wrong message ([175cb38](https://gitlab.pegase.tech/z3/z3-frontend/commit/175cb38a074a0f7d7fa445125fe6d0db7834d154))
* Link import correctly format output message ([2bba644](https://gitlab.pegase.tech/z3/z3-frontend/commit/2bba644669a5a4809e5bcd90c501a969c38f4a97))
* Remove forgotten password link on login screen ([1572b20](https://gitlab.pegase.tech/z3/z3-frontend/commit/1572b20a32d738a7e6e1c6ea60eabe19ef9497c2))
* reset forms after link import ([f01f776](https://gitlab.pegase.tech/z3/z3-frontend/commit/f01f7769d8a9a94f7b7ac70234e03755f4ac8b66))
* set first active brand available for newly loggedin user ([d235394](https://gitlab.pegase.tech/z3/z3-frontend/commit/d2353947e9d7686a6facc0ac7cd94cbc35ba7681))
* Statistics export was getting empty files ([ce520a6](https://gitlab.pegase.tech/z3/z3-frontend/commit/ce520a66b9260b0ec7a1cdb39e95d8dee611c88e))
## [1.0.1](https://gitlab.pegase.tech/z3/z3-frontend/compare/0.0.1...1.0.1) (2024-12-08)
### Features
* :sparkles: Allo to assign any user some brands, these will be used to change the current app context ([1140c3b](https://gitlab.pegase.tech/z3/z3-frontend/commit/1140c3b01734ae397d0079259cf84c66bcced443))
* :sparkles: Allow user to change its password ([a77d1d3](https://gitlab.pegase.tech/z3/z3-frontend/commit/a77d1d322571906278da752beaff8ec64d0f071b))
* :sparkles: User password reset ([42cf00f](https://gitlab.pegase.tech/z3/z3-frontend/commit/42cf00f36ab067898eb20c6d05cd6487e476dfea))
* Add global statistics ([bf0f5b8](https://gitlab.pegase.tech/z3/z3-frontend/commit/bf0f5b897e368413d1912a0237265ec50c646be0))
* Display brand stats on home page ([7b23276](https://gitlab.pegase.tech/z3/z3-frontend/commit/7b23276c5e5587e3cfae995f16d12d3da45d2e87))
* Link statistics and export ([d12ee89](https://gitlab.pegase.tech/z3/z3-frontend/commit/d12ee89ac89acc212ef543d9e0131c15931b43b8))
### Bug Fixes
* :lipstick: Message component text was not responsive ([da4a927](https://gitlab.pegase.tech/z3/z3-frontend/commit/da4a9272d815848e1910d83198da23ea08bae26c))
* back navigation for import and export links ([2d8a530](https://gitlab.pegase.tech/z3/z3-frontend/commit/2d8a530c0e8e41ee40d95a26fdb97592ebe73212))
* Bearer token was a singleton ... ([faaa44f](https://gitlab.pegase.tech/z3/z3-frontend/commit/faaa44f97d2dbb997b81e93153e0281ebcb694f2))
* Brand crud navigation fixes ([466ed97](https://gitlab.pegase.tech/z3/z3-frontend/commit/466ed97e78aa221691694236b0905a8c4c33ae9d))
* Catalog crud fixes ([161b6c2](https://gitlab.pegase.tech/z3/z3-frontend/commit/161b6c2c5c63bbf0049e999ccbdaf55622e4bcbc))
* Set Accept-Language header to translate quarkus messages automatically ([956ef24](https://gitlab.pegase.tech/z3/z3-frontend/commit/956ef24ecf7d59dbd0b3f1bd49d582d52efbf9f2))
* Users.show Hide user delete button for and admin own account ([c703885](https://gitlab.pegase.tech/z3/z3-frontend/commit/c703885642a12ac4c1e16d8e51d283d3d4efce2e))
* Users.show Hide user reset form for user own profile ([0649c18](https://gitlab.pegase.tech/z3/z3-frontend/commit/0649c18134af26f8d3c577eb0b1b3fd0c7499ac5))
## 0.0.1 (2024-09-27)

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY . .
# Needed for Vite to define the environment variables that are importable in the code
COPY .env.example .env
RUN npm install
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
RUN rm -rf ./*
COPY --from=build /app/package.json .
COPY --from=build /app/build .
ENV NODE_ENV=production
RUN npm install --omit=dev
# For uploading large files, set the body size limit to Infinity
# Size limit is delegated to SvelteKit handle function.
# https://svelte.dev/docs/kit/adapter-node#Environment-variables-BODY_SIZE_LIMIT
ENV BODY_SIZE_LIMIT=Infinity
CMD ["node", "index.js"]

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# Z3 – A link to the path
*Z3* est un raccourcisseur d'URL.
Le nom *Z3* est un clin d'œil à *Zelda 3 : A Link to the Past*, un jeu vidéo sorti en 1991 sur Super
Nintendo.
## Configuration
### APP NAME
- `PUBLIC_APP_NAME` est utilisé à des fins d'affichage.
### BACKEND
- L'url du backend doit être renseigné `BACKEND_URL`
Si il ne l'est pas ou si le backend n'est pas joignable sera génèrera une erreur `500` de la part de `Sveltekit`
### UPLOADS
**! Cette valeur dois être raccord avec la partie back (quarkus) !**
- La taille max des uploads peut être définie `BODY_SIZE_LIMIT` -> `BODY_SIZE_LIMIT=51200`
Elle est en `kilobytes`, example (`51200Ko` => `50Mo`)
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```

81
eslint.config.js Normal file
View file

@ -0,0 +1,81 @@
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...ts.configs.stylistic,
...svelte.configs['flat/recommended'],
stylistic.configs['recommended-flat'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['*.svelte', '**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'error',
// Security
'svelte/no-at-html-tags': 'error',
'svelte/no-target-blank': 'warn',
// Best pratices
'svelte/button-has-type': 'error',
'svelte/no-at-debug-tags': 'error',
'svelte/no-ignored-unsubscribe': 'error',
'svelte/no-immutable-reactive-statements': 'error',
'svelte/no-inline-styles': 'error',
'svelte/no-inspect': 'error',
'svelte/no-reactive-functions': 'error',
'svelte/no-reactive-literals': 'error',
'svelte/no-svelte-internal': 'error',
'svelte/no-unused-class-name': 'off',
'svelte/no-unused-svelte-ignore': 'warn',
'svelte/no-useless-mustaches': 'error',
'svelte/prefer-destructured-store-props': 'error',
'svelte/require-each-key': 'error',
'svelte/require-event-dispatcher-types': 'error',
'svelte/require-optimized-style-attribute': 'error',
'svelte/require-stores-init': 'error',
'svelte/valid-each-key': 'error',
// Styllistic
'svelte/derived-has-same-inputs-outputs': 'warn',
'svelte/first-attribute-linebreak': 'warn',
'svelte/html-closing-bracket-new-line': 'warn',
'svelte/html-closing-bracket-spacing': 'warn',
'svelte/html-quotes': 'warn',
'svelte/max-attributes-per-line': 'warn',
'svelte/mustache-spacing': 'warn',
'svelte/no-extra-reactive-curlies': 'warn',
'svelte/no-spaces-around-equal-signs-in-attribute': 'warn',
'svelte/prefer-class-directive': 'warn',
'svelte/prefer-style-directive': 'warn',
'svelte/shorthand-attribute': 'warn',
'svelte/shorthand-directive': 'warn',
'svelte/sort-attributes': 'warn',
'svelte/spaced-html-comment': 'warn',
// Extension
'svelte/no-trailing-spaces': 'warn',
// Svelte Kit
'svelte/no-navigation-without-base': 'warn',
'svelte/prefer-const': 'warn',
'svelte/no-useless-children-snippet': 'warn',
'svelte/consistent-selector-style': 'warn',
},
}
);

7740
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

81
package.json Normal file
View file

@ -0,0 +1,81 @@
{
"name": "z3-frontend",
"version": "1.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1",
"@stylistic/eslint-plugin": "^2.10.0",
"@sveltejs/adapter-node": "^5.2.11",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4",
"@types/color-string": "^1.5.5",
"eslint": "^9.18.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"postcss": "^8.5.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0",
"vite-plugin-checker": "^0.9.0",
"vite-plugin-svelte-checker": "^0.1.2",
"vitest": "^3.0.0"
},
"dependencies": {
"@humanspeak/svelte-markdown": "^0.7.12",
"@tailwindcss/typography": "^0.5.16",
"@zerodevx/svelte-toast": "^0.9.6",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-adapter-dayjs-4": "^1.0.4",
"chartjs-plugin-zoom": "^2.2.0",
"color-string": "^1.9.1",
"daisyui": "^5.0.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"focus-trap": "^7.6.1",
"i18next": "^24.0.5",
"jwt-decode": "^4.0.0",
"lucide-svelte": "^0.456.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"svelte-modals": "^2.0.0",
"sveltekit-i18n": "^2.4.2",
"sveltekit-rate-limiter": "^0.6.1",
"tailwindcss": "^4.0.7",
"wicg-inert": "^3.1.3",
"zod": "^3.23.8",
"zod-i18n-map": "^2.27.0"
},
"overrides": {
"@sveltejs/kit": {
"cookie": "1.0.2"
}
},
"browserslist": [
"defaults and fully supports es6-module",
"maintained node versions"
]
}

12
playwright.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
}
export default config

32
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,32 @@
import type { FlashMessage } from '$customType/FlashMessage.type'
import type Brand from '$lib/models/Brand.type'
import type JwtToken from '$lib/models/JwtToken.type'
import type User from '$lib/models/User.type'
declare global {
namespace App {
interface Error {
/** Redirect instructions for the global +error component */
redirect?: {
code: number
url: string
}
}
interface Locals {
/** User selected active brand */
activeBrand: Brand | undefined
/** User available brands */
brands: Brand[]
/** User current session */
session?: {
token: JwtToken
user: User
}
/** Messages that should be displayed once to the user */
flashMessages?: FlashMessage[]
}
}
}
export { }

47
src/app.html Normal file
View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Z3 - Link" />
<link rel="manifest" href="/site.webmanifest" />
<style>
body {
min-height: 100vh;
}
#svelte-container {
display: contents;
}
</style>
%sveltekit.head%
<script nonce="%sveltekit.nonce%">
if (document && localStorage && matchMedia) {
let auto = matchMedia('(prefers-color-scheme: auto)').matches
let dark = matchMedia('(prefers-color-scheme: dark)').matches
mode = auto ? 'auto' : (dark ? 'dark' : 'light')
mode = localStorage['z3-theme'] !== undefined ? localStorage['z3-theme'] : mode
document.documentElement.dataset['theme'] = mode
localStorage['z3-theme'] = mode
}
</script>
</head>
<body data-sveltekit-preload-data="tap">
<div id="svelte-container">
%sveltekit.body%
</div>
</body>
</html>

7
src/css/app.css Normal file
View file

@ -0,0 +1,7 @@
@import './tailwind.css';
@import './fonts.css';
@import './tailwind-custom.css';
@import './daisyui-custom.css';
@import './grid.css';

View file

@ -0,0 +1,51 @@
/* OVERLOADS */
a {
@apply link
}
.btn {
@apply no-underline;
}
.menu a {
@apply no-underline;
}
.input {
@apply bg-base-200/45;
}
.select {
@apply bg-base-200/45;
}
.toggle {
--tglbg: var(--fallback-b1, oklch(var(--b1)/0.90));
}
/* CUSTOM COMPONENTS */
/*
* Displays a Model
.display-entity
|_div
|__dt
|__dl
*/
.display-entity {
@apply divide-y divide-accent-content rounded-lg overflow-hidden;
> div {
@apply px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-4;
> dt {
@apply mb-0 font-extrabold;
}
> dd {
@apply mb-0 mt-1 text-sm/6 sm:col-span-2 sm:mt-0 sm:text-lg;
}
}
}

125
src/css/fonts.css Normal file
View file

@ -0,0 +1,125 @@
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-BoldItalic.woff2') format('woff2'),
url('/fonts/Ubuntu-BoldItalic.woff') format('woff');
font-weight: bold;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Mono';
src: url('/fonts/UbuntuMono-BoldItalic.woff2') format('woff2'),
url('/fonts/UbuntuMono-BoldItalic.woff') format('woff');
font-weight: bold;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-Light.woff2') format('woff2'),
url('/fonts/Ubuntu-Light.woff') format('woff');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Mono';
src: url('/fonts/UbuntuMono-Italic.woff2') format('woff2'),
url('/fonts/UbuntuMono-Italic.woff') format('woff');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Mono';
src: url('/fonts/UbuntuMono-Bold.woff2') format('woff2'),
url('/fonts/UbuntuMono-Bold.woff') format('woff');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-Medium.woff2') format('woff2'),
url('/fonts/Ubuntu-Medium.woff') format('woff');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-LightItalic.woff2') format('woff2'),
url('/fonts/Ubuntu-LightItalic.woff') format('woff');
font-weight: 300;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-Bold.woff2') format('woff2'),
url('/fonts/Ubuntu-Bold.woff') format('woff');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-Italic.woff2') format('woff2'),
url('/fonts/Ubuntu-Italic.woff') format('woff');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Sans';
src: url('/fonts/UbuntuSans-Regular.woff2') format('woff2'),
url('/fonts/UbuntuSans-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-Regular.woff2') format('woff2'),
url('/fonts/Ubuntu-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu';
src: url('/fonts/Ubuntu-MediumItalic.woff2') format('woff2'),
url('/fonts/Ubuntu-MediumItalic.woff') format('woff');
font-weight: 500;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Sans';
src: url('/fonts/UbuntuSans-Italic.woff2') format('woff2'),
url('/fonts/UbuntuSans-Italic.woff') format('woff');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Ubuntu Mono';
src: url('/fonts/UbuntuMono-Regular.woff2') format('woff2'),
url('/fonts/UbuntuMono-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}

96
src/css/grid.css Normal file
View file

@ -0,0 +1,96 @@
@reference "tailwindcss";
@utility container {
@apply max-w-full w-full mx-auto px-4;
@variant sm {
@apply max-w-7xl;
}
}
@utility container-fluid {
@apply w-full mx-auto px-0;
}
@utility row {
@apply flex flex-wrap;
> * {
@apply shrink w-full max-w-full ps-2 pe-2 mt-2;
}
}
@utility col {
flex: 1 0 0%;
}
@utility col-1 {
flex: 0 0 auto;
@apply w-1/12;
}
@utility col-2 {
flex: 0 0 auto;
@apply w-2/12;
}
@utility col-3 {
flex: 0 0 auto;
@apply w-3/12;
}
@utility col-4 {
flex: 0 0 auto;
@apply w-4/12;
}
@utility col-5 {
flex: 0 0 auto;
@apply w-5/12;
}
@utility col-6 {
flex: 0 0 auto;
@apply w-6/12;
}
@utility col-7 {
flex: 0 0 auto;
@apply w-7/12;
}
@utility col-8 {
flex: 0 0 auto;
@apply w-8/12;
}
@utility col-9 {
flex: 0 0 auto;
@apply w-9/12;
}
@utility col-10 {
flex: 0 0 auto;
@apply w-10/12;
}
@utility col-11 {
flex: 0 0 auto;
@apply w-11/12;
}
@utility col-12 {
flex: 0 0 auto;
@apply w-12/12;
}

View file

@ -0,0 +1,34 @@
@layer base {
:root {
}
}
@layer base {
#svelte-container > main {
min-height: 100vh;
}
p, span {
font-family: Ubuntu;
}
h1,.h1 {
@apply text-2xl mb-2;
}
h2,.h2 {
@apply text-xl mb-2;
}
h3,.h3 {
@apply text-lg mb-2;
}
h4,.h4 {
@apply text-base mb-2;
}
h5,.h5 {
@apply text-sm mb-1;
}
h6,.h6 {
@apply text-xs mb-1;
}
}

107
src/css/tailwind.css Normal file
View file

@ -0,0 +1,107 @@
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@plugin "daisyui" {
themes: corporate --default, dracula --prefersdark;
}
@theme {
--font-serif:
'Ubuntu', ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-sans:
'Ubuntu Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono:
'Ubuntu Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--font: var(--font-sans);
--default-font-family: var(--font);
--heading-font-family: var(--font-serif);
--monospace-font-family: var(--font-mono);
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-2xl: 1400px;
}
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@custom-variant light (&:where([data-theme=light], [data-theme=light] *));
@plugin "daisyui/theme" {
/* corporate */
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(93% 0 0);
--color-base-300: oklch(86% 0 0);
--color-base-content: oklch(22.389% 0.031 278.072);
--color-primary: oklch(58% 0.158 241.966);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(55% 0.046 257.417);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(60% 0.118 184.704);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(0% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(60% 0.126 221.723);
--color-info-content: oklch(100% 0 0);
--color-success: oklch(62% 0.194 149.214);
--color-success-content: oklch(100% 0 0);
--color-warning: oklch(85% 0.199 91.936);
--color-warning-content: oklch(0% 0 0);
--color-error: oklch(70% 0.191 22.216);
--color-error-content: oklch(0% 0 0);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
/* dracula */
name: "dark";
default: false;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(28.822% 0.022 277.508);
--color-base-200: oklch(26.805% 0.02 277.508);
--color-base-300: oklch(24.787% 0.019 277.508);
--color-base-content: oklch(97.747% 0.007 106.545);
--color-primary: oklch(75.461% 0.183 346.812);
--color-primary-content: oklch(15.092% 0.036 346.812);
--color-secondary: oklch(74.202% 0.148 301.883);
--color-secondary-content: oklch(14.84% 0.029 301.883);
--color-accent: oklch(83.392% 0.124 66.558);
--color-accent-content: oklch(16.678% 0.024 66.558);
--color-neutral: oklch(39.445% 0.032 275.524);
--color-neutral-content: oklch(87.889% 0.006 275.524);
--color-info: oklch(88.263% 0.093 212.846);
--color-info-content: oklch(17.652% 0.018 212.846);
--color-success: oklch(87.099% 0.219 148.024);
--color-success-content: oklch(17.419% 0.043 148.024);
--color-warning: oklch(95.533% 0.134 112.757);
--color-warning-content: oklch(19.106% 0.026 112.757);
--color-error: oklch(68.22% 0.206 24.43);
--color-error-content: oklch(13.644% 0.041 24.43);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}

107
src/error.html Normal file
View file

@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Z3 - Link" />
<link rel="manifest" href="/site.webmanifest" />
<title>%sveltekit.error.message%</title>
<style>
body {
--bg: white;
--fg: #222;
--divider: #ccc;
background: var(--bg);
color: var(--fg);
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.error {
padding: 1rem;
}
.error>h1 {
text-align: center;
font-size: 1rem;
}
.error>div {
display: flex;
justify-content: center;
align-items: center;
max-width: 32rem;
margin: 0 1rem;
}
.status {
font-weight: 200;
font-size: 3rem;
line-height: 1;
position: relative;
top: -0.05rem;
}
.message {
border-left: 1px solid var(--divider);
padding: 0 0 0 1rem;
margin: 0 0 0 1rem;
min-height: 2.5rem;
display: flex;
align-items: center;
}
.message h1 {
font-weight: 400;
font-size: 1em;
margin: 0;
}
@media (prefers-color-scheme: dark) {
body {
--bg: #222;
--fg: #ddd;
--divider: #666;
}
}
</style>
</head>
<body>
<div class="error">
<h1>Une erreur est survenue sur Z3</h1>
<div>
<span class="status">%sveltekit.status%</span>
<div class="message">
<h1>%sveltekit.error.message%</h1>
</div>
</div>
</div>
<script nonce="%sveltekit.nonce%">
const message = "%sveltekit.error.message%";
console.error(message);
</script>
</body>
</html>

22
src/hooks.client.ts Normal file
View file

@ -0,0 +1,22 @@
import { isHttpError, type HandleClientError } from '@sveltejs/kit'
import { toast } from '@zerodevx/svelte-toast'
export const handleError: HandleClientError = async ({ event, error }) => {
console.error(error)
console.error(event.route)
const message = 'Une erreur est survenue'
if (isHttpError(error) && error.status != 404) {
setTimeout(() => {
toast.push(`${message}, la page va être rechargée`, {
classes: ['bg-error'],
})
window.location.reload()
}, 5000)
}
return {
message,
error,
}
}

180
src/hooks.server.ts Normal file
View file

@ -0,0 +1,180 @@
import { redirect, type Cookies, type Handle, type HandleServerError } from '@sveltejs/kit'
import { toast } from '@zerodevx/svelte-toast'
import { get } from 'svelte/store'
import type { PinoLogger } from '$customType'
import type { FormFieldsOlds } from '$customType/forms'
import { Role } from '$lib/enums'
import type { Brand } from '$lib/models'
import { AuthenticationError, AuthorizationError, JwtAuthenticationError, NotFoundError, ValidationError } from '$lib/server/errors'
import { brandService, sessionService } from '$lib/server/services'
import type { SessionData } from '$lib/server/services/SessionService'
import { pinoLoggerStore } from '$lib/server/stores'
export const handle: Handle = async ({ event, resolve }) => {
const { locals, cookies, request } = event
const sessionData = sessionService.getSessionData(cookies)
if (sessionData === undefined) {
return await resolve(event)
}
const localUser = sessionData.session.getUser()
locals.session = {
token: sessionData.session.getToken(),
user: localUser,
}
await selectDefaultActiveBrand(locals, cookies, sessionData)
await flashOldFormInputs(request, cookies)
return await resolve(event)
}
export const handleError: HandleServerError = async ({ event, error, message }) => {
const pinoLogger: PinoLogger = get(pinoLoggerStore)
const { url, cookies } = event
let redirect: {
code: number
url: string
} | undefined = undefined
if (error instanceof AuthenticationError) {
// Remove all toasts
toast.pop(0)
// Remove all Flash messages
sessionService.getFlash(cookies).consumeMessages()
sessionService.getFlash(cookies).flashError('Vous avez été deconnecté')
sessionService.removeSession(sessionService.getSessionId(cookies))
pinoLogger.debug(error, 'Authentication error')
redirect = {
code: 301,
url: '/',
}
}
else if (error instanceof NotFoundError) {
pinoLogger.debug(`Not found error ${error.message}`)
}
else if (error instanceof JwtAuthenticationError) {
sessionService.getFlash(cookies).flashInfo(
`Votre session a expiré, veuillez vous reconnecter`,
)
pinoLogger.debug(`Session expired`)
redirect = {
code: 302,
url: '/',
}
}
else if (error instanceof AuthorizationError) {
message = 'Vous n\'êtes pas authorisé a effectuer cette action'
sessionService.getFlash(cookies).flashError(message)
pinoLogger.debug('Authorization error')
redirect = {
code: 403,
url: url.pathname,
}
}
else if (error instanceof ValidationError) {
sessionService.getFlash(cookies).flashWarning('Des données n´ont pas pu être validées')
sessionService.getFlashErrors(cookies).setErrors(error.getFieldErrors())
pinoLogger.debug('Validation error')
redirect = {
code: 422,
url: url.pathname,
}
}
else {
pinoLogger.error(error, 'Unhandled error')
}
return {
message,
redirect,
}
}
// -- PRIVATE FUNCTIONS
async function selectDefaultActiveBrand(locals: App.Locals, cookies: Cookies, sessionData: SessionData): Promise<void> {
try {
const session = sessionData.session, localUser = session.getUser()
const brands = await brandService.getAllBrands(cookies)
// Filter user accessible brands
locals.brands = brands.filter((brand) => {
if (
localUser.role === Role.ADMINISTRATOR
|| localUser.brands?.indexOf(brand.uuid) !== -1) {
return brand
}
return false
})
let activeBrand: Brand | undefined = sessionData?.session.getActiveBrand()
if (activeBrand === undefined) {
// Automatically select first available brand
const firstBrandVailable = localUser.role === Role.ADMINISTRATOR
? brands.at(0)
: brands.find(brand => localUser.brands?.indexOf(brand.uuid) !== -1)
if (firstBrandVailable) {
activeBrand = firstBrandVailable
sessionData?.session.setActiveBrand(firstBrandVailable)
}
}
locals.activeBrand = activeBrand
}
catch (e) {
if (e instanceof JwtAuthenticationError) {
get(pinoLoggerStore).debug('Authentication error')
sessionService.getFlash(cookies).flashInfo('Vous avez été deconecté')
sessionService.removeSession(sessionData.sessionId)
redirect(302, '/')
}
get(pinoLoggerStore).error('Unhandled error', e)
throw e
}
}
/**
* Flash current request inputs to reuse them in the next page show
*/
async function flashOldFormInputs(request: Request, cookies: Cookies): Promise<void> {
const oldFormData: FormFieldsOlds = {}
if (request.method === 'GET' || request.method === 'HEAD') {
return
}
// * Map old inputs into oldFormData
(await request.clone().formData()).entries()
.forEach((entry) => {
const isArray = entry[0].endsWith('[]')
const name = entry[0].replace(/\[\]$/, '')
const value = entry[1]
if (value instanceof File) {
return
}
if (isArray) {
const previousValue = oldFormData[name] ?? []
oldFormData[name] = [...previousValue, value]
return
}
oldFormData[name] = value
})
// * Flash old form values to repopulate inputs
sessionService.getFlashOlds(cookies).setOlds(oldFormData)
}

9
src/lib/client/index.ts Normal file
View file

@ -0,0 +1,9 @@
import { initPreferedTheme, setTheme, wantedTheme, type Theme } from './preferedTheme.svelte'
import { ToastType, Toaster } from './toaster'
export {
ToastType, Toaster,
setTheme, wantedTheme, initPreferedTheme as watchPreferedTheme,
type Theme
}

View file

@ -0,0 +1,49 @@
import { on } from "svelte/events"
// import { MediaQuery } from "svelte/reactivity"
export type Theme = 'auto' | 'light' | 'dark'
const preferedDefaultAutoTheme: Theme = 'auto'
// const preferedAutoTheme = new MediaQuery('prefers-color-scheme: dark');
let selectedTheme = $state<Theme | undefined>()
const currentTheme = $derived<Theme>(selectedTheme === undefined ? preferedDefaultAutoTheme : selectedTheme)
function onPreferedThemeChange(event: MediaQueryListEvent): void {
const newColorScheme = event.matches ? 'dark' : 'light'
setTheme(newColorScheme)
}
export function initPreferedTheme(): void {
const previouslySelectedTheme: Theme | undefined = localStorage['z3-theme']
if (previouslySelectedTheme !== undefined) {
setTheme(previouslySelectedTheme)
} else {
setTheme('dark')
}
setTheme(wantedTheme())
const hasMatchMedia = window.matchMedia
if (hasMatchMedia !== undefined) {
const mediaMatchDarkTheme = matchMedia('(prefers-color-scheme: dark)')
on(mediaMatchDarkTheme, 'change', onPreferedThemeChange)
}
}
export function wantedTheme(): Theme {
return currentTheme
}
export function setTheme(theme: Theme): void {
selectedTheme = theme
localStorage['z3-theme'] = currentTheme
document.documentElement.dataset['theme'] = currentTheme
}

View file

@ -0,0 +1,5 @@
import { validateLink } from './validateLink.svelte'
export {
validateLink,
}

View file

@ -0,0 +1,24 @@
import { invalidateAll } from '$app/navigation'
import type { LinkValidity } from '$lib/models'
import { Toaster } from '..'
export const validateLink = async (linkSource: string): Promise<LinkValidity> => {
const response = await fetch(`/api/links/${linkSource}/verify`, {
method: 'put',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
const linkValidity = await response.json() as LinkValidity
await invalidateAll()
if (linkValidity.isValid === true) {
Toaster.toastInfo('Le lien est valide')
}
if (linkValidity.isValid === false) {
Toaster.toastWarning('Le lien n\'est pas valide')
}
return linkValidity
}

View file

@ -0,0 +1,30 @@
import { writable } from 'svelte/store'
import type { Brand, ListOf } from '$lib/models'
import { Toaster } from '..'
export const initialData = {
list: [],
page: 1,
perPage: 1,
totalItems: 0,
totalPages: 1,
}
export const loadPaginatedBrands = async (page: number, perPage = 15, fetch: typeof window.fetch | null = null): Promise<ListOf<Brand>> => {
const res = await (fetch ?? window.fetch)(`/api/brands?page=${page}&per-page=${perPage}`)
if (!res.ok) {
const message = 'Failed to fetch brand data'
console.error(message)
Toaster.toastError('Echec du chargement des enseignes')
return initialData
}
const data = await res.json()
paginatedBrands.set(data)
return data
}
export const paginatedBrands = writable<ListOf<Brand>>(initialData)

View file

@ -0,0 +1,30 @@
import { writable } from 'svelte/store'
import type { Catalog, ListOf } from '$lib/models'
import { Toaster } from '..'
export const initialData = {
list: [],
page: 1,
perPage: 1,
totalItems: 0,
totalPages: 1,
}
export const loadPaginatedCatalogs = async (page: number, perPage = 15, fetch: typeof window.fetch | null = null): Promise<ListOf<Catalog>> => {
const res = await (fetch ?? window.fetch)(`/api/catalogs?page=${page}&per-page=${perPage}`)
if (!res.ok) {
const message = 'Failed to fetch catalog data'
console.error(message)
Toaster.toastError('Echec du chargement des catalogues')
return initialData
}
const data = await res.json()
paginatedCatalogs.set(data)
return data
}
export const paginatedCatalogs = writable<ListOf<Catalog>>(initialData)

View file

@ -0,0 +1,16 @@
import { writable, type Writable } from 'svelte/store'
import type { FormFieldsError } from '$customType/forms'
function createErrors(): Pick<Writable<FormFieldsError>, 'subscribe' | 'set'> & {
set: (errors: FormFieldsError) => void
} {
const store = writable<FormFieldsError>({})
return {
subscribe: store.subscribe,
set: store.set,
}
}
export const inputErrors = createErrors()

View file

@ -0,0 +1,26 @@
import { loadPaginatedBrands, paginatedBrands } from './brandStore.svelte'
import { loadPaginatedCatalogs, paginatedCatalogs } from './catalogStore.svelte'
import { inputErrors } from './errorsStore.svelte'
import flashMessages from './messagesStore.svelte'
import { breadcrumb } from './pageBreadcrumbStore.svelte'
import { title } from './pageTitleStore.svelte'
import { loadPaginatedTags, paginatedTags } from './tagStore.svelte'
export {
loadPaginatedBrands,
paginatedBrands,
}
export {
loadPaginatedCatalogs,
paginatedCatalogs,
}
export {
loadPaginatedTags,
paginatedTags,
}
export { breadcrumb, title }
export { flashMessages, inputErrors }

View file

@ -0,0 +1,21 @@
import { writable, type Writable } from 'svelte/store'
import type { FlashMessage } from '$customType'
function createFlashMessage(): Pick<Writable<FlashMessage[]>, 'subscribe' | 'set'> & {
setOne: (message: FlashMessage) => void
} {
const store = writable<FlashMessage[]>([])
return {
subscribe: store.subscribe,
setOne(message): void {
store.set([message])
},
set: store.set,
}
}
const flashMessages = createFlashMessage()
export default flashMessages

View file

@ -0,0 +1,19 @@
import { writable, type Writable } from 'svelte/store'
interface BreadCrumb {
previous?: false | string | { text: string, url: string }
current?: string
}
function createBreadcrumb(): Pick<Writable<BreadCrumb>, 'subscribe' | 'set'> {
const { subscribe, set } = writable({})
return {
subscribe,
set: (value): void => {
set(value)
},
}
}
export const breadcrumb = createBreadcrumb()

View file

@ -0,0 +1,16 @@
import { writable, type Writable } from 'svelte/store'
import { PUBLIC_APP_NAME } from '$env/static/public'
function createTitle(): Pick<Writable<string>, 'subscribe' | 'set'> {
const { subscribe, set } = writable('')
return {
subscribe,
set: (value): void => {
set(`${value} • ${PUBLIC_APP_NAME}`)
},
}
}
export const title = createTitle()

View file

@ -0,0 +1,30 @@
import { writable } from 'svelte/store'
import type { ListOf, Tag } from '$lib/models'
import { Toaster } from '..'
export const initialData = {
list: [],
page: 1,
perPage: 1,
totalItems: 0,
totalPages: 1,
}
export const loadPaginatedTags = async (page: number, perPage = 15, fetch: typeof window.fetch | null = null): Promise<ListOf<Tag>> => {
const res = await (fetch ?? window.fetch)(`/api/tags?page=${page}&per-page=${perPage}`)
if (!res.ok) {
const message = 'Failed to fetch tag data'
console.error(message)
Toaster.toastError('Echec du chargement des étiquettes')
return initialData
}
const data = await res.json()
paginatedTags.set(data)
return data
}
export const paginatedTags = writable<ListOf<Tag>>(initialData)

35
src/lib/client/toaster.ts Normal file
View file

@ -0,0 +1,35 @@
import { toast } from '@zerodevx/svelte-toast'
export enum ToastType {
DEFAULT = 'b3',
INFO = 'in',
WARNING = 'wa',
ERROR = 'er',
SUCCESS = 'suc',
}
export abstract class Toaster {
public static toast(message: string, type: ToastType): void {
toast.push(message, {
theme: {
'--toastBarBackground': `oklch(var(--${type}))`,
},
})
}
public static toastWarning(message: string): void {
this.toast(message, ToastType.WARNING)
}
public static toastInfo(message: string): void {
this.toast(message, ToastType.INFO)
}
public static toastSuccess(message: string): void {
this.toast(message, ToastType.SUCCESS)
}
public static toastError(message: string): void {
this.toast(message, ToastType.ERROR)
}
}

View file

@ -0,0 +1,28 @@
import type { ActionResult } from '@sveltejs/kit'
import { applyAction } from '$app/forms'
import { flashMessages, inputErrors } from '../stores'
export default function applyZ3Action(result: ActionResult): void {
applyAction(result)
if (result.type === 'failure') {
if (result.data?.['message']) {
flashMessages.setOne({
message: String(result.data?.['message']),
type: result.status === 422 ? 'warning' : 'error',
})
}
if (result.data?.['errors']) {
inputErrors.set(result.data?.['errors'])
}
}
if (result.type === 'success') {
if (result.data?.['message']) {
flashMessages.setOne({
message: String(result.data?.['message']),
type: result.data?.['type'] === 'warning' ? 'warning' : 'success',
})
}
inputErrors.set({})
}
}

View file

@ -0,0 +1,45 @@
import type { Policy } from '$customType'
import type { User } from '$lib/models'
import { BrandPolicy, UserPolicy } from './policies'
type PolicyList = Record<string, Policy>
class PolicyManager {
readonly #policies: PolicyList
constructor(policiesList: Record<string, PolicyList>) {
this.#policies = {}
Object.entries(policiesList).forEach((policiesListEntry) => {
const policyFamilyName = policiesListEntry[0],
policyFamilySuite = policiesListEntry[1]
Object.entries(policyFamilySuite).forEach((policySuiteEntry) => {
const policyName = `${policyFamilyName}:${policySuiteEntry[0]}`,
policy = policySuiteEntry[1]
this.#policies[policyName] = policy
})
})
}
public can(policy: string, user: User): boolean {
const resolvedPolicy = this.resolvePolicy(policy)
return resolvedPolicy.call(resolvedPolicy, user)
}
private resolvePolicy(policyName: string): Policy {
if (!this.#policies[policyName]) {
throw new Error(`Missing policy ${policyName}`)
}
return this.#policies[policyName]
}
}
const policies = new PolicyManager({
user: new UserPolicy().getPolicies(),
brand: new BrandPolicy().getPolicies(),
})
export default function can(policy: string, user: User): boolean {
return policies.can(policy, user)
}

View file

@ -0,0 +1,30 @@
import type { Policies, Policy } from '$customType'
import { Role } from '$lib/enums'
import type { User } from '$lib/models'
export default class BrandPolicy implements Policies {
getPolicies(): Record<string, Policy> {
return {
viewAny: this.viewAny,
create: this.create,
view: this.view,
update: this.update,
}
}
public viewAny(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public create(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public view(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public update(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
}

View file

@ -0,0 +1,45 @@
import type { Policies, Policy } from '$customType'
import { Role } from '$lib/enums'
import type { User } from '$lib/models'
export default class UserPolicy implements Policies {
getPolicies(): Record<string, Policy> {
return {
viewAny: this.viewAny,
create: this.create,
view: this.view,
update: this.update,
delete: this.delete,
reset: this.reset,
setLinksAlerts: this.setLinksAlerts,
}
}
public viewAny(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public create(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public view(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public update(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public delete(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public reset(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
public setLinksAlerts(auth: User): boolean {
return auth.role === Role.ADMINISTRATOR
}
}

View file

@ -0,0 +1,7 @@
import BrandPolicy from './BrandPolicy'
import UserPolicy from './UserPolicy'
export {
BrandPolicy,
UserPolicy,
}

View file

@ -0,0 +1,4 @@
import applyZ3Action from './applyZ3Action.tools'
import can from './authorization/can.tools'
export { applyZ3Action, can }

View file

@ -0,0 +1,37 @@
<script lang="ts">
import SvelteMarkdown from '@humanspeak/svelte-markdown'
import { t } from '$lib/tools'
interface Props {
pageUrl: string
}
const {
pageUrl,
}: Props = $props()
let source: string = $state(t.get('help.statement.loading'))
$effect(() => {
fetchPage(pageUrl)
})
// METHODS
async function fetchPage(pageUrl: string): Promise<void> {
source = t.get('chargement')
fetch(`/assets/${pageUrl}`)
.then(async (res) => {
if (res.ok) {
source = await res.text()
}
return source
}).catch(() => {
source = t.get('help.statement.loading_error')
})
}
</script>
<div class="prose">
<SvelteMarkdown {source} />
</div>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { onMount, type Component } from 'svelte'
import {
AlertCircle,
CircleHelp,
ClipboardCopy,
CheckCheck,
Eye, EyeClosed,
ChevronLeft, ChevronRight, ChevronDown, ChevronUp, ChevronsUpDown,
FileDown, FileUp,
List,
Sun, Moon, SunMoon,
TriangleAlert, CircleX, CircleCheck, Info,
User,
Link2,
Key,
Folder,
Menu,
Plus, Eraser, Pencil,
X,
Search,
LoaderCircle,
HelpCircleIcon,
ImageDown,
ZoomIn, ZoomOut, SearchX,
MoveRight, MoveLeft, MoveUp, MoveDown, Shrink, Expand
} from 'lucide-svelte'
import { t } from '$lib/tools';
export const Icons = {
AlertCircle,
CircleHelp,
ClipboardCopy,
CheckCheck,
Eye,
EyeClosed,
ChevronLeft, ChevronRight, ChevronDown, ChevronUp, ChevronsUpDown,
FileDown, FileUp,
List,
Sun, Moon, SunMoon,
TriangleAlert, CircleX, CircleCheck, Info,
User,
Link2,
Key,
Folder,
Menu,
Plus, Eraser, Pencil,
X,
Search,
LoaderCircle,
HelpCircleIcon,
ImageDown,
ZoomIn, ZoomOut, SearchX,
MoveRight, MoveLeft, MoveUp, MoveDown, Shrink, Expand
}
type IconName = keyof typeof Icons
interface Props {
name: IconName
size?: number | string
classList?: string | string[]
hidden?: boolean
title?: string
}
let {
name = $bindable(),
size = '1rem',
hidden = false,
title,
classList = [],
...others
}: Props = $props()
const LucideIcon = $derived(Icons[name] as unknown as Component)
onMount(() => {
if (!title) {
title = $t(`icons.${name}.title`)
}
})
</script>
<LucideIcon
aria-label={title}
{size}
{...others}
class={[
...[hidden ? 'hidden' : ''],
...typeof classList === 'string' ? [classList] : classList,
].join(' ')}
role="img"
/>

View file

@ -0,0 +1,264 @@
<script lang="ts">
import {
Chart,
Tooltip,
type ChartData,
type DefaultDataPoint,
} from 'chart.js'
import type { HTMLCanvasAttributes } from 'svelte/elements'
import { onMount } from 'svelte';
import 'chart.js/auto'
import 'chartjs-adapter-date-fns'
import { LucideIcon } from '$lib/components'
import { Str, t } from '$lib/tools'
import { wantedTheme, type Theme } from '$lib/client';
const hasDarkTheme = $derived<Theme>(wantedTheme());
const charColor = $derived<typeof Chart.defaults.color>(hasDarkTheme === 'dark' ? '#fff' : '#000')
type Data = {
label: string
serie: (number|null)[]
}
interface Props extends HTMLCanvasAttributes {
data: Data | Data[]
title: string
labels: string[]
keepAspectRatio?: boolean
hideEmptyLines?: boolean,
zoom?: boolean,
pan?: boolean,
pinch?: boolean,
padding?: number | {
left?: number
right?: number
top?: number
bottom?: number
}
}
const { title, data, labels, padding, hideEmptyLines: hideEmptyLines, pan, pinch, zoom, ...rest }: Props = $props()
let onFullScreen = $state<boolean>(false)
let containerElem: HTMLDivElement
let canvasElem: HTMLCanvasElement
let chart: Chart
const datasets = $derived<ChartData<'bar', DefaultDataPoint<'bar'>, string>['datasets']>(Array.isArray(data) ?
data.map(mapDataToChartData) : [mapDataToChartData(data)])
const chartJsData = $derived<ChartData<'bar', DefaultDataPoint<'bar'>, string>>({
datasets,
labels: labels
})
onMount(() => {
Chart.register(Tooltip)
if (typeof window !== "undefined") {
import("chartjs-plugin-zoom").then((plugin) => {
Chart.register(plugin.default);
});
}
})
$effect(() => {
Chart.defaults.color = charColor
chart = new Chart(canvasElem, {
type: 'bar',
data: chartJsData,
options: {
responsive: true,
// @ts-ignore
skipNull: hideEmptyLines ?? false,
layout: {
padding,
},
maintainAspectRatio: rest.keepAspectRatio ?? true,
plugins: {
zoom: {
pan: {
enabled: pan ?? false,
mode: 'xy',
},
limits: {
x: {
min: 'original',
max: 'original'
},
y: {
min: 'original',
max: 'original'
}
},
zoom: {
pinch: {
enabled: pinch ?? false
},
wheel: {
enabled: zoom ?? false,
modifierKey: 'ctrl'
},
mode: 'xy',
},
}
}
}
})
return (): void => {
chart.destroy()
}
})
// FUNCTIONS
function mapDataToChartData(data: Data): ChartData<'bar', DefaultDataPoint<'bar'>, string>['datasets'][0] {
return {
type: 'bar',
indexAxis: 'y',
label: data.label,
data: data.serie,
}
}
// EVENTS
function onZoomIn(): void {
chart.zoom(1.2)
}
function onZoomOut(): void {
chart.zoom(0.8)
}
function onZoomReset(): void {
chart.resetZoom()
}
function onPanLeft(): void {
chart.pan({
x: -100,
})
}
function onPanRight(): void {
chart.pan({
x: 100,
})
}
function onPanUp(): void {
chart.pan({
y: 100,
})
}
function onPanDown(): void {
chart.pan({
y: -100,
})
}
function onPanReset(): void {
onZoomReset()
}
function onExpand(): void {
containerElem.requestFullscreen().then(() => {
onFullScreen = true
}).catch(() => {
onFullScreen = false
})
}
function onQuitFullscreen(): void {
window.document.exitFullscreen().then(() => {
onFullScreen = false
})
}
function onPictureDownload() {
const base64PictureDataUrl = chart.toBase64Image()
const link = document.createElement('a')
const pictureTitle = Str.toKebabCase(`${title} ${Date.now()}`)
link.href = base64PictureDataUrl
link.download = `${pictureTitle}.png`
link.click()
}
</script>
<div
bind:this={containerElem}
class="bg-base-200 dark:bg-base-300 p-1 md:p-3 rounded-sm w-full h-full flex flex-col"
>
<div class="flex flex-row justify-between">
<div class="join">
{#if !onFullScreen}
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.expand'))}
onclick={onExpand}
><LucideIcon name="Expand" /></button>
{:else}
<button
class="join-item btn btn-sm btn-soft btn-"
title={Str.ucFirst($t('common.action.secondary'))}
onclick={onQuitFullscreen}
><LucideIcon name="X" /></button>
{/if}
<button
class="join-item btn btn-sm btn-soft btn-primary"
title={Str.ucFirst($t('common.action.download_element_picture'))}
onclick={onPictureDownload}
><LucideIcon name="ImageDown" /></button>
</div>
<div class="flex flex-row items-center">
<div class="join m-2">
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.zoom_in'))}
onclick={onZoomIn}
><LucideIcon name="ZoomIn" /></button>
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.zoom_out'))}
onclick={onZoomOut}
><LucideIcon name="ZoomOut" /></button>
<button
class="join-item btn btn-sm btn-soft btn-accent"
title={Str.ucFirst($t('common.action.zoom_reset'))}
onclick={onZoomReset}
><LucideIcon name="SearchX" /></button>
</div>
<div class="m-2 flex-col items-center hidden md:flex">
<button
class="join-item btn btn-sm btn-soft btn-secondary rounded-b-none"
title={Str.ucFirst($t('common.action.pan_up'))}
onclick={onPanUp}
><LucideIcon name="MoveUp" /></button>
<span class="join">
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.pan_left'))}
onclick={onPanRight}
><LucideIcon name="MoveLeft" /></button>
<button
class="join-item btn btn-sm btn-soft btn-accent"
title={Str.ucFirst($t('common.action.pan_reset'))}
onclick={onPanReset}
><LucideIcon name="Shrink" /></button>
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.pan_right'))}
onclick={onPanLeft}
><LucideIcon name="MoveRight" /></button>
</span>
<button
class="join-item btn btn-sm btn-soft btn-secondary rounded-t-none"
title={Str.ucFirst($t('common.action.pan_down'))}
onclick={onPanDown}
><LucideIcon name="MoveDown" /></button>
</div>
</div>
</div>
<div class="w-full basis-full min-h-36">
<canvas
{title}
bind:this={canvasElem}
{...rest}
></canvas>
</div>
</div>

View file

@ -0,0 +1,267 @@
<script lang="ts">
import {
Chart,
Tooltip,
type ChartData,
type DefaultDataPoint,
} from 'chart.js'
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import type { HTMLCanvasAttributes } from 'svelte/elements'
import 'chart.js/auto'
import 'chartjs-adapter-date-fns'
import { onMount } from 'svelte';
import { Str, t } from '$lib/tools';
import { LucideIcon } from '$lib/components';
type TimeXAxis = {
type: 'time'
time: {
unit: 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'
}
}
export type xAxisType = TimeXAxis
interface Props extends HTMLCanvasAttributes {
title: string
data: {
label: string
points: {x: number|Date, y: number}[]
}[]
keepAspectRatio?: boolean
xAxis?: xAxisType
zoom?: boolean,
pan?: boolean,
pinch?: boolean,
padding?: number | {
left?: number
right?: number
top?: number
bottom?: number
}
}
const { title, data, xAxis, padding, pan, pinch, zoom, ...rest }: Props = $props()
let onFullScreen = $state<boolean>(false)
let containerElem: HTMLDivElement
let canvasElem: HTMLCanvasElement
let chart: Chart
const chartJsData: ChartData<'line', DefaultDataPoint<'line'>, string> = $derived({
datasets: data.map(({ label, points }) => ({
label,
data: points.map(({ x, y }) => ({
// * Declare as number to avoid type error (Bad TS description from chartjs)
x: (x instanceof Date ? x.toISOString() : x) as number,
y
})),
fill: false,
tension: 0.1
})),
})
onMount(() => {
Chart.register(Tooltip)
if (typeof window !== "undefined") {
import("chartjs-plugin-zoom").then((plugin) => {
Chart.register(plugin.default);
});
}
})
$effect(() => {
chart = new Chart(canvasElem, {
type: 'line',
data: chartJsData,
options: {
responsive: true,
layout: {
padding,
},
maintainAspectRatio: rest.keepAspectRatio ?? true,
scales: {
x: xAxis
},
plugins: {
zoom: {
pan: {
enabled: pan ?? false,
mode: 'xy',
},
limits: {
x: {
min: 'original',
max: 'original'
},
y: {
min: 'original',
max: 'original'
}
},
zoom: {
pinch: {
enabled: pinch ?? false
},
wheel: {
enabled: zoom ?? false,
modifierKey: 'ctrl'
},
mode: 'xy',
},
}
}
}
})
return (): void => {
chart.destroy()
}
})
$effect(() => {
if (chart) {
chart.data = chartJsData
chart.update()
}
})
// EVENTS
function onZoomIn(): void {
chart.zoom(1.2)
}
function onZoomOut(): void {
chart.zoom(0.8)
}
function onZoomReset(): void {
chart.resetZoom()
}
function onPanLeft(): void {
chart.pan({
x: -100,
})
}
function onPanRight(): void {
chart.pan({
x: 100,
})
}
function onPanUp(): void {
chart.pan({
y: 100,
})
}
function onPanDown(): void {
chart.pan({
y: -100,
})
}
function onPanReset(): void {
onZoomReset()
}
function onExpand(): void {
containerElem.requestFullscreen().then(() => {
onFullScreen = true
}).catch(() => {
onFullScreen = false
})
}
function onQuitFullscreen(): void {
window.document.exitFullscreen().then(() => {
onFullScreen = false
})
}
function onPictureDownload() {
const base64PictureDataUrl = chart.toBase64Image()
const link = document.createElement('a')
const pictureTitle = Str.toKebabCase(`${title} ${Date.now()}`)
link.href = base64PictureDataUrl
link.download = `${pictureTitle}.png`
link.click()
}
</script>
<div
bind:this={containerElem}
class="bg-base-200/50 dark:bg-base-300 p-1 md:p-3 rounded-sm w-full h-full flex flex-col"
>
<div class="flex flex-row justify-between">
<div class="join">
{#if !onFullScreen}
<button
class="join-item btn btn-sm btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.expand'))}
onclick={onExpand}
><LucideIcon name="Expand" /></button>
{:else}
<button
class="join-item btn btn-sm btn-soft btn-"
title={Str.ucFirst($t('common.action.secondary'))}
onclick={onQuitFullscreen}
><LucideIcon name="X" /></button>
{/if}
<button
class="join-item btn btn-sm btn-soft btn-primary"
title={Str.ucFirst($t('common.action.download_element_picture'))}
onclick={onPictureDownload}
><LucideIcon name="ImageDown" /></button>
</div>
<div class="flex flex-row items-center">
<div class="join m-2">
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.zoom_in'))}
onclick={onZoomIn}
><LucideIcon name="ZoomIn" /></button>
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.zoom_out'))}
onclick={onZoomOut}
><LucideIcon name="ZoomOut" /></button>
<button
class="join-item btn btn-sm dark:btn-soft btn-accent"
title={Str.ucFirst($t('common.action.zoom_reset'))}
onclick={onZoomReset}
><LucideIcon name="SearchX" /></button>
</div>
<div class="m-2 flex-col items-center hidden md:flex">
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary rounded-b-none"
title={Str.ucFirst($t('common.action.pan_up'))}
onclick={onPanUp}
><LucideIcon name="MoveUp" /></button>
<span class="join">
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.pan_left'))}
onclick={onPanRight}
><LucideIcon name="MoveLeft" /></button>
<button
class="join-item btn btn-sm dark:btn-soft btn-accent"
title={Str.ucFirst($t('common.action.pan_reset'))}
onclick={onPanReset}
><LucideIcon name="Shrink" /></button>
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary"
title={Str.ucFirst($t('common.action.pan_right'))}
onclick={onPanLeft}
><LucideIcon name="MoveRight" /></button>
</span>
<button
class="join-item btn btn-sm dark:btn-soft btn-secondary rounded-t-none"
title={Str.ucFirst($t('common.action.pan_down'))}
onclick={onPanDown}
><LucideIcon name="MoveDown" /></button>
</div>
</div>
</div>
<div class="w-full basis-full min-h-36">
<canvas
{title}
bind:this={canvasElem}
{...rest}
></canvas>
</div>
</div>

View file

@ -0,0 +1,4 @@
import BarChart from './BarChart.svelte'
import LineChart from './LineChart.svelte'
export { BarChart, LineChart }

View file

@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import type { HexColor } from '$customType/value-objects'
import { Color } from '$lib/tools'
interface Props {
classList?: string | string[]
color: HexColor
children?: Snippet
}
const {
children,
color,
classList,
}: Props = $props()
let span: HTMLSpanElement
$effect(() => {
span.style.setProperty('--badge-color-bg', color)
span.style.setProperty('--badge-color-text', color.length ? Color.getTextColor(color) : '#000000')
})
</script>
<span
bind:this={span}
class={[
...['badge', 'badge-bg'],
...[classList ? (typeof classList === 'string' ? [classList] : classList) : []],
].join(' ')}
>{#if children !== undefined}{@render children()}{:else}{color}{/if}</span>
<style>
.badge-bg {
background-color: var(--badge-color-bg);
color: var(--badge-color-text);
}
</style>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import { SelectEntity } from '../inputs'
type Props = Pick<ComponentProps<typeof SelectEntity>,
| 'id'
| 'fieldNames'
| 'classList'
| 'outerClassList'
| 'externalClassList'
| 'paginatedEntityList'
| 'onClickNext'
| 'onClickPrevious'
| 'defaultOptionName'
> & {
filterParamName: string
value?: string
}
const {
filterParamName,
value,
...props
}: Props = $props()
const filterUrl: URL = $state(page.url)
let filterValue = $state(value ?? props.paginatedEntityList.list?.[0]?.[
'value' as keyof typeof props.paginatedEntityList.list[0]
] ?? undefined)
const assignFilterValue = (): void => {
if (filterValue && filterValue.length) {
filterUrl.searchParams.set('page', '1')
filterUrl.searchParams.set(filterParamName, filterValue)
}
else filterUrl.searchParams.delete(filterParamName)
}
const doSearch = (): void => {
// eslint-disable-next-line svelte/no-goto-without-base
goto(filterUrl, {
invalidateAll: true,
})
}
// EVENTS
function onFilterChange(): void {
assignFilterValue()
doSearch()
}
</script>
<SelectEntity
autocomplete="off"
{...props}
onchange={onFilterChange}
bind:value={filterValue}
/>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import { InputCheckbox } from '../inputs'
type Props = Pick<ComponentProps<typeof InputCheckbox>,
| 'id'
| 'classList'
> & {
filterParamName: string
}
const {
filterParamName,
...props
}: Props = $props()
const filterUrl: URL = $state(page.url)
let filterChecked: boolean = $state(filterUrl.searchParams.get(filterParamName) ? true : false)
const assignFilterValue = (): void => {
if (filterChecked) {
filterUrl.searchParams.set(filterParamName, String(true))
}
else filterUrl.searchParams.delete(filterParamName)
}
const doSearch = (): void => {
// eslint-disable-next-line svelte/no-goto-without-base
goto(filterUrl, {
invalidateAll: true,
})
}
// EVENTS
function onFilterChange(): void {
filterChecked = !filterChecked
assignFilterValue()
doSearch()
}
</script>
<InputCheckbox
name="isExternal"
autocomplete="off"
classList={['toggle-accent']}
onchange={onFilterChange}
toggle={true}
value={true}
{...props}
/>

View file

@ -0,0 +1,17 @@
<script lang="ts">
interface Props {
fqdns: string[]
}
const {
fqdns = $bindable([]),
}: Props = $props()
</script>
<div class="inline-flex flex-wrap">
{#each fqdns as fqdn, index(index)}
<span class="px-2 py-1 m-1 bg-info-content text-info rounded-md">
{fqdn}
</span>
{/each}
</div>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import type { Brand } from '$lib/models'
import { Str, t } from '$lib/tools'
import HelpButton from './HelpButton.svelte'
interface Props {
forBrand?: Brand
globalStatistics: Record<string, number>
}
const {
forBrand,
globalStatistics,
}: Props = $props()
</script>
<div>
<div class="rounded text-lg my-6 join">
{#if forBrand}
<span class="bg-neutral inline-flex items-center text-neutral-content py-1 px-2 join-item">{Str.toTitleCase($t('entities.list.brand'))}</span>
<span class="bg-info inline-flex items-center text-info-content py-1 px-2 join-item">{forBrand.name}</span>
{:else}
<span class="bg-base-300 text-base-content dark:bg-neutral dark:text-neutral-content inline-flex items-center py-1 px-2 join-item">Toutes enseignes confondues</span>
{/if}
<HelpButton
classList="btn-xl"
helpPage="/help/visits"
joined={true}
size="26"
/>
</div>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4 lg:flex lg:flex-wrap">
{#each Object.entries(globalStatistics) as globalStatisticEntry, index(index)}
<div class="card bg-base-200/35 shadow-md w-full">
<div class="card-body">
<dt class="card-title">
<span class="text-base italic">{$t(`statistics.global.${globalStatisticEntry[0]}.title`)}</span>
</dt>
<dd class="text-center">
<span class="text-xl font-semibold">{globalStatisticEntry[1]}</span>
</dd>
</div>
</div>
{/each}
</dl>
</div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { LucideIcon } from '$lib/components'
import { Str, t } from '$lib/tools'
import type { ComponentProps } from 'svelte'
interface Props {
helpPage: string
joined?: boolean
classList?: string
size?: ComponentProps<typeof LucideIcon>['size']
}
const {
helpPage,
joined = true,
classList,
size,
}: Props = $props()
</script>
<a
class={'btn btn-sm btn-square btn-outline btn-info ' + classList}
class:join-item={joined}
aria-label={Str.ucFirst($t('common.action.gotoHelp'))}
href={helpPage}
rel="noopener noreferrer"
target="_blank"
title={Str.ucFirst($t('common.action.gotoHelp'))}
><LucideIcon
name="HelpCircleIcon"
{size}
/></a>

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import type { Modals } from 'svelte-modals/legacy'
import * as focusTrap from 'focus-trap'
import { LucideIcon } from '$lib/components'
type Props = {
title?: string
message?: string
confirm?: boolean
} & Pick<Modals,
| 'isOpen'
| 'close'
>
const {
isOpen,
close,
title,
message,
confirm,
}: Props = $props()
let modal: HTMLDialogElement | undefined = $state()
let modalFocusTrap: focusTrap.FocusTrap | null = $state(null)
onMount(() => {
if (modal) {
modalFocusTrap = focusTrap.createFocusTrap(modal)
modalFocusTrap.activate()
}
else {
modalFocusTrap = null
}
})
onDestroy(() => {
if (modalFocusTrap) {
modalFocusTrap.deactivate()
}
})
</script>
{#if isOpen}
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog
bind:this={modal}
class="modal"
onclick={(): void => close()}
open
>
<div class="modal-box">
<form method="dialog">
<button
class="btn btn-circle btn-ghost absolute right-2 top-2"
onclick={(): void => close()}
title="Fermer la fênetre modale"
type="button"
><LucideIcon title={"Une croix"} name="X" /></button>
</form>
{#if title}<h3 class="text-lg font-bold">{title}</h3>{/if}
<p class="py-4">{message}</p>
{#if confirm}
<div class="flex">
<button
class="btn btn-accent mx-4"
onclick={(): void => close(false)}
type="button"
>Annuler</button>
<button
class="btn btn-neutral mx-4"
onclick={(): void => close(true)}
type="button"
>Confirmer</button>
</div>
{/if}
</div>
</dialog>
{/if}
<style>
.modal {
position: fixed;
top: 0;
bottom: 0;
right: 0;
left: 0;
background: rgba(0,0,0,0.50)
}
</style>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { page } from '$app/state'
import { LucideIcon } from '$lib/components'
import type { Entity, ListOf } from '$lib/models'
import { Str, t } from '$lib/tools'
import { PerPage } from '.'
interface Props {
onEachSide?: number
paginatedData: ListOf<Entity>
dropDownDirection: 'Up' | 'Down'
}
const {
onEachSide = 2,
paginatedData,
dropDownDirection,
}: Props = $props()
const { url } = page
let perPage = $state(paginatedData.perPage)
let currentIndex = $derived(perPage * (paginatedData.page - 1))
let totalItemsCount = $derived(paginatedData.totalItems)
let itemsCount = $derived(paginatedData.list.length)
let totalPages = $derived(paginatedData.totalPages)
let currentPageNumber = $derived(paginatedData.page)
let previousPage: number | false = $derived((currentPageNumber - 1) >= 1 ? currentPageNumber - 1 : false)
let nextPage: number | false = $derived((currentPageNumber + 1) <= totalPages ? currentPageNumber + 1 : false)
let showDropdowns: boolean = $derived(paginatedData.totalPages >= 5)
if (onEachSide < 1) {
throw new Error('onEachSide cannot be less than 1')
}
const urlWithPage = (page: number): string => {
const outUrl = new URL(url)
if (page < 1) throw new Error('Page cannot be less than 1')
outUrl.searchParams.set('page', String(page))
outUrl.searchParams.set('per-page', String(perPage))
return outUrl.toString()
}
</script>
<!-- SNIPPET - Previous -->
{#snippet previous()}
{#if previousPage === false}
<span
class="join-item btn btn-sm tn-soft btn-secondary btn-disabled"
aria-disabled="true"
aria-label={$t('pagination.previous')}
role="button"
tabindex="-1"
><span aria-hidden="true">&lsaquo;</span></span>
{:else}
<a
class="join-item btn btn-sm btn-soft btn-secondary"
aria-label={$t('pagination.previous')}
href={urlWithPage(previousPage)}
rel="prev"
><span aria-hidden="true">&lsaquo;</span></a>
{/if}
{/snippet}
<!-- SNIPPET - Next -->
{#snippet next()}
{#if nextPage === false}
<span
class="join-item btn btn-sm btn-soft btn-secondary btn-disabled"
aria-disabled="true"
aria-label={$t('pagination.next')}
role="button"
><span aria-hidden="true">&rsaquo;</span></span>
{:else}
<a
class="join-item btn btn-sm btn-soft btn-secondary"
aria-label={$t('pagination.next')}
href={urlWithPage(nextPage)}
rel="next"
><span aria-hidden="true">&rsaquo;</span></a>
{/if}
{/snippet}
{#snippet currentPage(index: number, currentPageNumber: number)}
{#if paginatedData.totalPages < 5}
<!-- Simple Current -->
<a
class="join-item rounded-none btn btn-sm btn-soft btn-primary btn-active"
aria-current="page"
href={urlWithPage(index + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: index + 1 }))}
>{index + 1}</a>
{:else}
<!-- Dropdown Current goto -->
<div
class="rounded-none btn btn-sm btn-soft btn-active btn-primary flex w-16 px-1"
aria-current="page"
role="button"
tabindex="0"
><span>{index + 1}</span><LucideIcon
name={`Chevron${dropDownDirection}`}
title={"Une fleche directionnelle"}
classList="hidden sm:inline"
size="1rem"
/></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
class="dropdown-content menu bg-base-200 text-base-content dark:bg-neutral dark:text-neutral-content rounded-box p-0 z-1 my-2 flex-nowrap w-20 shadow-sm max-h-32 overflow-y-scroll"
tabindex="0"
>
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each Array(paginatedData.totalPages) as _, dropDownIndex (dropDownIndex)}
<li
class:bg-info={dropDownIndex + 1 === currentPageNumber}
class:text-info-content={dropDownIndex + 1 === currentPageNumber}
>
<a
class="px-2 py-1"
href={urlWithPage(dropDownIndex + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: dropDownIndex + 1 }))}
>{dropDownIndex + 1}</a>
</li>
{/each}
</ul>
{/if}
{/snippet}
<div class="flex flex-row justify-start items-end">
<!-- PAGINATION -->
<div>
<div
class="join sm:hidden"
class:dropdown={showDropdowns}
class:dropdown-down={dropDownDirection === 'Down' && showDropdowns}
class:dropdown-top={dropDownDirection === 'Up' && showDropdowns}
>
{@render previous()}
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each Array(paginatedData.totalPages) as _, index (index)}
{#if index + 1 === currentPageNumber}
{@render currentPage(index, currentPageNumber)}
{/if}
{/each}
{@render next()}
</div>
<div class="hidden sm:block my-3">
<small class="block text-muted-foreground">
{$t('pagination.showing')}
<span class="font-semibold">{currentIndex}</span>
{$t('pagination.to')}
<span class="font-semibold">{currentIndex + itemsCount}</span>
{$t('pagination.of')}
<span class="font-semibold">{totalItemsCount}</span>
{$t('pagination.results')}
</small>
</div>
<div class="join hidden sm:inline-flex">
{@render previous()}
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each Array(paginatedData.totalPages) as _, index (index)}
<!-- CURRENT -->
{#if index + 1 === currentPageNumber}
<div
class="join-item"
class:dropdown={showDropdowns}
class:dropdown-down={dropDownDirection === 'Down' && showDropdowns}
class:dropdown-top={dropDownDirection === 'Up' && showDropdowns}
>
{@render currentPage(index, currentPageNumber)}
</div>
<!-- FIRST -->
{:else if index === 0}
<a
class="join-item btn btn-sm btn-soft btn-primary"
href={urlWithPage(index + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: index + 1 }))}
>{index + 1}</a>
{#if paginatedData.totalPages > onEachSide && currentPageNumber > onEachSide + 2}
<span
class="join-item btn btn-sm btn-soft btn-secondary btn-disabled"
aria-disabled="true"
role="button"
tabindex="-1"
>...</span>
{/if}
<!-- LAST -->
{:else if index === paginatedData.totalPages - 1}
{#if paginatedData.totalPages > onEachSide && currentPageNumber < paginatedData.totalPages - onEachSide - 1}
<span
class="join-item btn btn-sm btn-soft btn-primary btn-disabled"
aria-disabled="true"
role="button"
tabindex="-1"
>...</span>
{/if}
<a
class="join-item btn btn-sm btn-soft btn-primary"
href={urlWithPage(index + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: index + 1 }))}
>{index + 1}</a>
<!-- PREVIOUS -->
{:else if index < currentPageNumber && index >= currentPageNumber - onEachSide - 1}
<a
class="join-item btn btn-sm btn-soft btn-primary"
href={urlWithPage(index + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: index + 1 }))}
>{index + 1}</a>
<!-- NEXT -->
{:else if index >= currentPageNumber && index < currentPageNumber + onEachSide}
<a
class="join-item btn btn-sm btn-soft btn-primary"
href={urlWithPage(index + 1)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: index + 1 }))}
>{index + 1}</a>
{/if}
{/each}
{@render next()}
</div>
</div>
<!-- PER-PAGE -->
<div class="ms-16">
<PerPage
{dropDownDirection}
bind:perPage
/>
</div>
</div>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { page } from '$app/state'
import { LucideIcon } from '$lib/components'
import { Str, t } from '$lib/tools'
const perPageList = [
1, 2, 3, 5, 10, 15, 25, 50,
]
interface Props {
perPage?: number
defautPerPage?: number
dropDownDirection: 'Up' | 'Down'
}
let {
perPage = $bindable(perPageList[0]),
defautPerPage = 15,
dropDownDirection,
}: Props = $props()
const { url } = $derived(page)
if (perPageList.findIndex(perPage => perPage === defautPerPage) === undefined) {
throw new Error('DefautPerPage is not in accepted list ' + perPageList.join(', '))
}
$effect(() => {
perPage = Math.min(
perPageList[perPageList.length - 1],
Math.max(
perPageList[0],
Number.parseInt(String(url.searchParams.get('per-page') ?? defautPerPage)),
),
)
})
const urlWithPerPage = (perPage: number): string => {
const outUrl = new URL(url)
if (perPage < 1) throw new Error('Page cannot be less than 1')
if (perPage > perPageList[perPageList.length - 1]) throw new Error('Page cannot be more than ' + perPageList[perPageList.length - 1])
outUrl.searchParams.set('per-page', String(perPage))
return outUrl.toString()
}
</script>
<div
class="dropdown"
class:dropdown-down={dropDownDirection === 'Down'}
class:dropdown-top={dropDownDirection === 'Up'}
>
<div
class="btn btn-sm btn-primary btn-soft border border-primary/25 w-24 px-2"
role="button"
tabindex="0"
><LucideIcon
name="List"
title={"Une liste déroulante pour choisir le nombre d'éléments par page"}
size="1rem"
/>&nbsp;<span class="font-extrabold">{perPage}</span>&nbsp;<LucideIcon
name={`Chevron${dropDownDirection}`}
title={"Une chevron indiquant la direction de la liste déroulante"}
classList="hidden sm:inline"
size="1rem"
/></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
class="dropdown-content menu bg-base-200 text-base-content dark:bg-neutral dark:text-neutral-content rounded-box p-0 z-1 my-2 flex-nowrap w-20 shadow-sm max-h-32 overflow-y-scroll"
tabindex="0"
>
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#each perPageList as perPageCount, dropDownIndex (dropDownIndex)}
<li
class:bg-info={perPageCount === perPage}
class:text-info-content={perPageCount === perPage}
>
<a
class="no-underline"
href={urlWithPerPage(perPageCount)}
title={Str.ucFirst($t('pagination.gotoPage', { pageNumber: perPageCount }))}
>{perPageCount}</a>
</li>
{/each}
</ul>
</div>

View file

@ -0,0 +1,142 @@
<script lang="ts">
import { onMount, tick, type ComponentProps } from 'svelte'
import { Debounce } from '$lib/tools'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import { Input, SelectInput } from '../inputs'
import { LucideIcon } from '$lib/components'
interface Props {
debounceTimout?: number
searchParamName: string
searchOnParamName: string
searchCriterias: ComponentProps<typeof SelectInput>['options']
}
const {
debounceTimout,
searchParamName,
searchOnParamName,
searchCriterias,
}: Props = $props()
const debounceObject = new Debounce()
let loading: boolean = $state(false)
let searchOnValue: string = $state(searchCriterias?.[0]?.value ?? '')
let searchValue: string = $state('')
let searchUrl: URL = $state(page.url)
let searchInput: HTMLInputElement = $state({} as HTMLInputElement)
const assignSearchValues = (): void => {
if (searchOnValue.length) searchUrl.searchParams.set(searchOnParamName, searchOnValue)
else searchUrl.searchParams.delete(searchOnParamName)
if (searchValue.length) searchUrl.searchParams.set(searchParamName, searchValue)
else searchUrl.searchParams.delete(searchParamName)
}
const doSearch = (): void => {
loading = true
// eslint-disable-next-line svelte/no-goto-without-base
goto(searchUrl, {
invalidateAll: true,
}).then(() => {
loading = false
tick().then(() => {
searchInput.focus()
})
})
}
onMount(() => {
assignSearchValues()
})
// EVENTS
function onSubmit(): void {
assignSearchValues()
doSearch()
}
function onSearchKeyUp(e: KeyboardEvent): void {
const handleKeyUp = (): void => {
assignSearchValues()
doSearch()
}
const input = e.target as HTMLInputElement
searchValue = input.value && input.value.length ? input.value : ''
debounceObject.debounce(handleKeyUp, debounceTimout ?? 600)()
}
function onSearchOnChange(e: Event): void {
const input = e.target as HTMLSelectElement
searchOnValue = input.value && input.value.length ? input.value : ''
assignSearchValues()
doSearch()
}
function onSearchClear(): void {
searchOnValue = ''
searchValue = ''
assignSearchValues()
doSearch()
searchOnValue = searchCriterias?.[0]?.value ?? ''
}
</script>
<div
class="join md:w-8/12 lg:w-6/12"
inert={loading}
>
<button
class="join-item btn btn-outline btn-accent hidden sm:inline-flex"
disabled={loading}
onclick={onSubmit}
title={"Lancer la recherche"}
type="button"
>{#if !loading}<LucideIcon name="Search" title="Une loupe pour rechercher" />{:else}<LucideIcon
name="LoaderCircle"
title={"Une animation de chargement"}
classList={['animate-spin']}
hidden={!loading}
/>{/if}
</button>
<label
class="join-item input rounded-s-lg sm:rounded-s-none flex items-center grow gap-2 bg-base-200"
for="link-search"
aria-label="Recherche de liens"
>
<span aria-hidden="false" class="hidden">Recherche de liens</span>
<Input
id="link-search"
name="search"
classList="border-0 focus:border-0 !bg-transparent focus:outline-0 rounded-none px-2 w-full"
color="ghost"
onkeyup={onSearchKeyUp}
outerClassList="grow"
type="text"
placeholder="Rechercher un lien par"
bind:input={searchInput}
bind:value={searchValue}
/>
</label>
<SelectInput
name={searchOnParamName}
classList={'border-s-none min-w-18 sm:min-w-32 ' + (searchValue ? 'rounded-none' : 'rounded-s-none')}
disabled={loading}
onchange={onSearchOnChange}
options={searchCriterias}
outerClassList="join-item"
ariaLabel="Critère de recherche"
required
bind:value={searchOnValue}
/>
<button
class="join-item btn btn-outline btn-error"
class:hidden={!searchValue}
disabled={loading}
onclick={onSearchClear}
type="button"
><LucideIcon name="Eraser" title={"Une gomme pour effacer"} />
</button>
</div>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { ComponentProps } from 'svelte'
import { LucideIcon } from '$lib/components'
import { Direction } from '$lib/enums'
import { t } from '$lib/tools'
type LucideIconName = ComponentProps<typeof LucideIcon>['name']
const directionTitle: Record<Direction, string> = {
asc: t.get('common.sort.sortBy', { sort: t.get('common.sort.asc') }),
desc: t.get('common.sort.sortBy', { sort: t.get('common.sort.desc') }),
auto: t.get('common.sort.sortBy', { sort: t.get('common.sort.auto') }),
}
const directionIcons: Record<Direction, LucideIconName> = {
asc: 'ChevronUp',
desc: 'ChevronDown',
auto: 'ChevronsUpDown',
}
const nextDirectionList: Record<Direction, Direction> = {
asc: Direction.desc,
desc: Direction.auto,
auto: Direction.asc,
}
interface Props {
url: URL
fieldName: string
}
let {
url = $bindable(),
fieldName,
}: Props = $props()
const querySortByParameterName = 'sortBy'
const queryDirectionParameterName = 'direction'
let icon: LucideIconName = $state(directionIcons['auto'])
let title: string = $state(directionTitle['auto'])
const getNextDirection = (currentDirection: string): Direction => {
return (Object.keys(nextDirectionList).find(nextDir => nextDir === currentDirection) ?? 'auto') as Direction
}
let queryFieldName = $derived(url.searchParams.get(querySortByParameterName))
let queryDirection = $derived(url.searchParams.get(queryDirectionParameterName) ?? 'auto')
let direction: Direction = $derived(queryFieldName === fieldName ? getNextDirection(queryDirection) : Direction.auto)
let nextDirection: Direction = $derived(nextDirectionList[direction])
$effect(() => {
icon = directionIcons[direction]
title = directionTitle[nextDirection]
})
</script>
<form
class="inline-flex"
action={url.toString()}
method="GET"
>
<input
name={querySortByParameterName}
type="hidden"
value={fieldName}
/>
<input
name={queryDirectionParameterName}
type="hidden"
value={nextDirection}
/>
<button
class="ms-2 btn btn-sm btn-square btn-ghost"
{title}
type="submit"
>
<LucideIcon bind:name={icon} title="Une flèche pour indiquer le sens du tri" />
</button>
</form>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Role } from '$lib/enums'
interface Props {
role: Role
}
const {
role,
}: Props = $props()
const toDisplayable = (role: Role): string => {
switch (role) {
case Role.ADMINISTRATOR: return 'Administrateur'
case Role.PUBLISHER: return 'Publicateur'
default: throw new Error(`Unhandled role ${role}`)
}
}
const displayableRole = $derived(toDisplayable(role))
</script>
{displayableRole}

View file

@ -0,0 +1,18 @@
import BadgeColor from './BadgeColor.svelte'
import FilterEntity from './FilterEntity.svelte'
import FilterNoBrand from './FilterNoBrand.svelte'
import Fqdns from './Fqdns.svelte'
import GlobalStatistics from './GlobalStatistics.svelte'
import HelpButton from './HelpButton.svelte'
import Modal from './Modal.svelte'
import Pagination from './Pagination.svelte'
import PerPage from './PerPage.svelte'
import Search from './Search.svelte'
import SortBy from './SortBy.svelte'
import UserRole from './UserRole.svelte'
export {
BadgeColor, FilterEntity, FilterNoBrand, Fqdns,
GlobalStatistics, HelpButton, Modal,
Pagination, PerPage, Search, SortBy, UserRole,
}

View file

@ -0,0 +1,75 @@
<script lang="ts">
interface Props {
failedImports: string[]
}
const {
failedImports,
}: Props = $props()
// FUNCTION
function failedImportToCol(failedImport: string): string[] {
const output: string[] = []
const line = failedImport.replaceAll('\n', '')
.match(/^line\s+([0-9]+)\s+->\s+"(.*)"\s+source:(.*),\s+target:(.*),\s+ext:(.*)$/)
if (line === null) {
output.push(failedImport)
}
else {
line.forEach((column, idx) => idx > 0 ? output.push(column) : null)
}
return output
}
</script>
<details class="collapse bg-warning text-warning-content">
<summary class="collapse-title text-xl font-medium">Cliquez ici pour voir le détails des erreurs d'importation</summary>
<div class="collapse-content">
<div class="overflow-x-auto">
<table>
<thead class="hidden lg:table-header-group">
<tr>
<th><b>ligne</b></th>
<th><b>message</b></th>
<th><b>source</b></th>
<th><b>url</b></th>
<th><b>externe</b></th>
</tr>
</thead>
<tbody>
{#each failedImports as failedImport, index(index)}
{@const importedLine = failedImportToCol(failedImport)}
<tr class="hidden lg:table-row">
{#if importedLine.length === 1}
<td colspan="5">{importedLine[0]}</td>
{:else}
<td><i>{importedLine[0]}</i></td>
<td>{importedLine[1]}</td>
<td>{importedLine[2]}</td>
<td>{importedLine[3]}</td>
<td>{importedLine[4]}</td>
{/if}
</tr>
<tr class="lg:hidden">
{#if importedLine.length === 1}
<td colspan="5">{importedLine[0]}</td>
{:else}
<td colspan="5">
<p><b>ligne&nbsp;:&nbsp;</b><i>{importedLine[0]}</i></p>
<p class="flex flex-row"><b class="ms-3">message&nbsp;:&nbsp;</b><span>{importedLine[1]}</span></p>
<p class="flex flex-row"><b class="ms-3">source&nbsp;:&nbsp;</b><span>{importedLine[2]}</span></p>
<p class="flex flex-row"><b class="ms-3">url&nbsp;:&nbsp;</b><span>{importedLine[3]}</span></p>
<p class="flex flex-row"><b class="ms-3">externe&nbsp;:&nbsp;</b><span>{importedLine[4]}</span></p>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</details>

View file

@ -0,0 +1,3 @@
import ImportErrors from './ImportErrors.svelte'
export { ImportErrors }

View file

@ -0,0 +1,15 @@
import InputCheckbox from './input-checkbox.svelte'
import InputCollectionTable from './input-collection-table.svelte'
import InputColor from './input-color.svelte'
import InputFile from './input-file.svelte'
import InputPassword from './input-password.svelte'
import Input from './input.svelte'
import SelectEntity from './select-entity.svelte'
import SelectInput from './select-input.svelte'
import SelectMultipleEntities from './select-multiple-entities.svelte'
export {
Input, InputCheckbox, InputCollectionTable,
InputColor, InputFile, InputPassword,
SelectEntity, SelectInput, SelectMultipleEntities,
}

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { uniquId } from '$lib/tools'
type Props = Partial<Pick<HTMLInputElement,
| 'autocomplete'
| 'checked'
| 'required'
| 'onchange'
>> & {
id?: string
label?: string
helper?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string | number | boolean
disabled?: boolean
errors?: string[]
validity?: string
toggle?: boolean
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
helper,
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
value,
toggle = false,
checked = $bindable(false),
...props
}: Props = $props()
let input: HTMLInputElement
$effect(() => {
input.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<input
bind:this={input}
{id}
class:checkbox={!toggle}
class:toggle
autocomplete={props.autocomplete}
autocorrect={props.autocomplete === 'off' ? 'off' : null}
data-form-type={props.autocomplete === 'off' ? 'other' : null}
data-lpignore={props.autocomplete === 'off' ? 'true' : null}
{disabled}
spellcheck={props.autocomplete === 'off' ? 'false' : null}
{value}
bind:checked
{...props}
class={[
...(classList instanceof Array ? classList : [classList]),
size ? `input-${size}` : '',
color ? `input-${color}` : '',
].join(' ')}
type="checkbox"
/>
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { LucideIcon } from '$lib/components'
import { t } from '$lib/tools'
interface Props {
name: string
label: string
required?: boolean
value?: string[]
errors?: string[]
}
let {
name,
label,
required = false,
value = $bindable([]),
errors,
}: Props = $props()
const concatenatedValues = $derived(value.join(', '))
let inputValue = $state('')
// EVENTS
function onAddElement(): void {
if (!inputValue.length) {
return
}
value = [inputValue, ...value]
inputValue = ''
}
function onRemoveElement(index: number): void {
value.splice(index, 1)
value = value
}
</script>
<div class="">
<span class="label justify-start">
<span class="me-1">{label}</span>
{#if required}
<span class="text-error">*</span>
{/if}
</span>
<div class="join flex mb-2 relative">
<input
name=""
class="input join-item grow px-1"
type="text"
bind:value={inputValue}
/>
{#if required}
<input
name=""
class="opacity-0 w-0 absolute left-0 bottom-0"
aria-hidden="true"
{required}
tabindex="-1"
type="text"
value={concatenatedValues}
/>
{/if}
<button
class="btn btn-accent join-item"
onclick={onAddElement}
title={$t('common.inputs.input-collection.add')}
type="button"
><LucideIcon name="Plus" title={"Un signe plus"} /></button>
</div>
<div class="max-h-44 overflow-y-auto border border-base-content/25 bg-base-300 rounded-lg p-2">
{#if value.length === 0}
<span class="text-base-content">{$t('common.inputs.input-collection.empty')}</span>
{/if}
{#each value as element, index(index)}
<div class="flex items-center">
<span class="grow">
<span>{element}</span>
<input
name={`${name}[]`}
type="hidden"
value={element}
/>
</span>
<button
class="btn btn-sm btn-neutral"
onclick={(): void => onRemoveElement(index)}
title={$t('common.inputs.input-collection.remove')}
type="button"
><LucideIcon name="X" title={"Une croix"} /></button>
</div>
{#if index < value.length - 1}
<div class="divider my-0"></div>
{/if}
{/each}
</div>
{#if errors?.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { uniquId } from '$lib/tools'
import { BadgeColor } from '../common'
type Props = Partial<Pick<HTMLInputElement,
| 'required'
>> & {
id?: string
label?: string
helper?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string
disabled?: boolean
errors?: string[]
validity?: string
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
helper,
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
value = $bindable(),
...props
}: Props = $props()
let input: HTMLInputElement
$effect(() => {
input.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<div class="inline-flex items-center">
<input
bind:this={input}
{id}
autocomplete="off"
autocorrect="off"
data-form-type={null}
data-lpignore={null}
{disabled}
spellcheck="false"
type="color"
bind:value
{...props}
class={[
...['input'],
...(classList instanceof Array ? classList : [classList]),
size ? `input-${size}` : '',
color ? `input-${color}` : '',
].join(' ')}
/>
<BadgeColor
classList={['ms-2']}
color={value ?? ''}
/>
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
</div>
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { Str, uniquId } from '$lib/tools'
type Props = Partial<Pick<HTMLInputElement,
| 'autocomplete'
| 'required'
| 'accept'
| 'multiple'
| 'title'
>> & {
id?: string
label?: string
helper?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string
disabled?: boolean
errors?: string[]
validity?: string
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
helper,
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
...props
}: Props = $props()
let input: HTMLInputElement
let files: HTMLInputElement['files'] = $state(null)
const limitFileName = (fileName: string): string => {
const fileNameParts = fileName.split('.')
const ext = fileNameParts.indexOf('.') !== -1 ? fileNameParts.pop() : undefined
return Str.limit(fileNameParts.join('.')) + (ext ? `.${ext}` : '')
}
$effect(() => {
input.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<button
class={[
...['btn btn-bordered md:hidden uppercase font-bold'],
...(classList instanceof Array ? classList : [classList]),
size ? `btn-${size}` : '',
color ? `btn-${color}` : 'btn-neutral',
].join(' ')}
onclick={(): void => { input.click() }}
type="button"
>Parcourir...</button>
<input
bind:this={input}
{id}
autocomplete={props.autocomplete}
autocorrect={props.autocomplete === 'off' ? 'off' : null}
data-form-type={props.autocomplete === 'off' ? 'other' : null}
data-lpignore={props.autocomplete === 'off' ? 'true' : null}
{disabled}
spellcheck={props.autocomplete === 'off' ? 'false' : null}
{...props}
class={[
...['file-input hidden md:inline'],
...(classList instanceof Array ? classList : [classList]),
size ? `input-${size}` : '',
color ? `input-${color}` : '',
].join(' ')}
type="file"
bind:files
/>
{#if files}
<div
class="label flex-col items-end"
class:hidden={!files.length}
>
{#each files as file, index(index)}
<span class="text-info mx-2">{limitFileName(file.name)} - <span class="font-extrabold">{Str.humanFileSize(file.size)}</span></span>
{/each}
</div>
{/if}
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { LucideIcon } from '$lib/components'
import { uniquId } from '$lib/tools'
type Props = Partial<Pick<HTMLInputElement,
| 'autocomplete'
| 'required'
| 'onkeyup'
>> & {
id?: string
label?: string
helper?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string
disabled?: boolean
errors?: string[]
validity?: string
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
helper,
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
value = $bindable(),
...props
}: Props = $props()
let passwordVisibilty: boolean = $state(false)
let input: HTMLInputElement
$effect(() => {
input.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<div class="join">
<input
bind:this={input}
{id}
autocomplete={props.autocomplete}
autocorrect={props.autocomplete === 'off' ? 'off' : null}
data-form-type={props.autocomplete === 'off' ? 'other' : null}
data-lpignore={props.autocomplete === 'off' ? 'true' : null}
{disabled}
spellcheck={props.autocomplete === 'off' ? 'false' : null}
{...props}
class={[
...['join-item w-full input'],
...(classList instanceof Array ? classList : [classList]),
size ? `input-${size}` : '',
color ? `input-${color}` : '',
].join(' ')}
type={!passwordVisibilty ? 'password' : 'text'}
bind:value
/>
<button
class="join-item btn btn-primary rounded-s-none"
onclick={(): void => { passwordVisibilty = !passwordVisibilty }}
type="button"
>{#if passwordVisibilty}<LucideIcon name="Eye" title={"Un oeil ouvert pour voir quelque chose"} />{:else}<LucideIcon name="EyeClosed" title={"Un oeil fermé"} />{/if}</button>
</div>
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { uniquId } from '$lib/tools'
type Props = Partial<Pick<HTMLInputElement,
| 'autocomplete'
| 'required'
| 'onkeyup'
>> & {
input?: HTMLInputElement
id?: string
label?: string
helper?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string
disabled?: boolean
errors?: string[]
placeholder?: string
validity?: string
type:
| 'email'
| 'url'
| 'text'
| 'tel'
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
input = $bindable(),
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
helper,
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
value = $bindable(),
...props
}: Props = $props()
$effect(() => {
input?.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<input
bind:this={input}
{id}
autocomplete={props.autocomplete}
autocorrect={props.autocomplete === 'off' ? 'off' : null}
data-form-type={props.autocomplete === 'off' ? 'other' : null}
data-lpignore={props.autocomplete === 'off' ? 'true' : null}
{disabled}
spellcheck={props.autocomplete === 'off' ? 'false' : null}
{...props}
class={[
...['input'],
...(classList instanceof Array ? classList : [classList]),
size ? `input-${size}` : '',
color ? `input-${color}` : '',
].join(' ')}
bind:value
/>
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,107 @@
<script lang="ts">
import { onMount, type ComponentProps } from 'svelte'
import { SelectInput } from '.'
import type { Entity, ListOf } from '$lib/models'
import { LucideIcon } from '$lib/components'
type EntityList = Record<string, string | boolean | number>[]
type Props = Pick<ComponentProps<typeof SelectInput>,
| 'id'
| 'name'
| 'value'
| 'label'
| 'autocomplete'
| 'required'
| 'outerClassList'
| 'classList'
| 'onchange'
| 'defaultOptionName'
> & {
fieldNames: {
label: string
value: string
}
externalClassList?: string | string[]
errors?: string[]
helper?: string
paginatedEntityList: ListOf<Entity>
onClickNext?: (nextPage: number) => void
onClickPrevious?: (previousPage: number) => void
}
let {
fieldNames,
paginatedEntityList = $bindable(),
errors,
externalClassList,
outerClassList,
classList,
value = $bindable(undefined),
onClickNext,
onClickPrevious,
...props
}: Props = $props()
let entityList: EntityList = $derived(paginatedEntityList.list as EntityList)
let options: ComponentProps<typeof SelectInput>['options'] = $derived(
entityList.map((value) => {
return {
value: String(value[fieldNames.value]),
label: String(value[fieldNames.label]),
}
}),
)
const defaultValue = $derived(props.required ? (value ?? options?.[0]?.value ?? undefined) : undefined)
let totalPages = $derived(paginatedEntityList.totalPages)
let currentPageNumber = $derived(paginatedEntityList.page)
let previousPage = $derived((currentPageNumber - 1) >= 1 ? currentPageNumber - 1 : false)
let nextPage = $derived((currentPageNumber + 1) <= totalPages ? currentPageNumber + 1 : false)
let hasOnlyOnePage = $derived(previousPage === false && nextPage === false)
onMount(() => {
value = value ? value : (props.required ? (options?.[0]?.value ?? defaultValue) : undefined)
})
</script>
<div
class={[
...(externalClassList ? (externalClassList instanceof Array ? externalClassList : [externalClassList]) : []),
].join(' ')}
>
<SelectInput
{...props}
classList={[
...(classList ? (classList instanceof Array ? classList : [classList]) : []),
...[!hasOnlyOnePage ? 'rounded-e-none' : ''],
]}
{errors}
{options}
outerClassList={[
...(outerClassList ? (outerClassList instanceof Array ? outerClassList : [outerClassList]) : []),
...['join-item rounded-s-md'],
]}
bind:value
/>
{#if previousPage !== false}
<button
class="btn join-item btn-info self-end"
onclick={(): void => onClickPrevious ? onClickPrevious(previousPage) : undefined}
title="Page précédente"
type="button"
><LucideIcon name="ChevronLeft" title={"Un chevron pointant vers la gauche"} /></button>
{/if}
{#if nextPage !== false}
<button
class="btn join-item btn-info self-end"
onclick={(): void => onClickNext ? onClickNext(nextPage) : undefined}
title="Page suivante"
type="button"
><LucideIcon name="ChevronRight" title={"Un chevron pointant vers la droite"} /></button>
{/if}
</div>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { uniquId } from '$lib/tools'
type Props = Partial<Pick<HTMLSelectElement,
| 'autocomplete'
| 'required'
| 'onchange'
| 'multiple'
>> & {
id?: string
label?: string
ariaLabel?: string
helper?: string
defaultOptionName?: string
outerClassList?: string | string[]
classList?: string | string[]
name?: string
value?: string
disabled?: boolean
errors?: string[]
validity?: string
options: {
value: string
label: string
}[]
size?:
| 'xs'
| 'sm'
| 'md'
| 'lg'
color?:
| 'ghost'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'
}
let {
id = String(uniquId()),
outerClassList = [],
classList = [],
label,
ariaLabel,
helper,
defaultOptionName = 'Aucun',
disabled = $bindable(false),
size,
color,
errors = $bindable([]),
validity = $bindable(undefined),
value = $bindable(undefined),
options = $bindable([]),
...props
}: Props = $props()
let input: HTMLSelectElement
$effect(() => {
input.setCustomValidity(validity ?? '')
})
</script>
<div
class={[
...['flex', 'flex-col'],
...(outerClassList instanceof Array ? outerClassList : [outerClassList]),
].join(' ')}
>
{#if label}
<label
class="label"
for={id}
>
<span>{label}{#if props.required}&nbsp;<span class="text-error">*</span>{/if}</span>
</label>
{/if}
<select
bind:this={input}
{id}
aria-label="{ariaLabel}"
title="{ariaLabel}"
autocomplete={props.autocomplete}
data-form-type={props.autocomplete === 'off' ? 'other' : null}
data-lpignore={props.autocomplete === 'off' ? 'true' : null}
{disabled}
spellcheck={props.autocomplete === 'off' ? 'false' : null}
{...props}
class={[
...['select', 'cursor-pointer'],
...(classList instanceof Array ? classList : [classList]),
size ? `select-${size}` : '',
color ? `select-${color}` : '',
].join(' ')}
bind:value
>
{#if !props.required}
<option
selected={value === undefined}
value={undefined}
>{defaultOptionName}</option>
{/if}
{#each options as option, index(index)}
<option
selected={option.value === value}
value={option.value}
>{option.label}</option>
{/each}
</select>
{#if helper}
<div class="label">
<span class="label-text-alt font-bold">{helper}</span>
</div>
{/if}
{#if errors.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import { onMount, type ComponentProps } from 'svelte'
import type { Entity, ListOf } from '$lib/models'
import { LucideIcon } from '$lib/components'
import type { SelectInput } from '.'
type EntityList = Record<string, string | boolean | number>[]
interface Props {
name: string
label: string
fieldNames: {
label: string
value: string
}
required?: boolean
value?: string[]
paginatedEntityList: ListOf<Entity>
errors?: string[]
onClickNext?: (nextPage: number) => void
onClickPrevious?: (previousPage: number) => void
}
let {
name,
label,
fieldNames,
required = $bindable(false),
value = $bindable([]),
paginatedEntityList = $bindable(),
errors,
onClickNext,
onClickPrevious,
}: Props = $props()
let entityList: EntityList = $derived(paginatedEntityList.list as EntityList)
let options: ComponentProps<typeof SelectInput>['options'] = $derived(
entityList.map((value) => {
return {
value: String(value[fieldNames.value]),
label: String(value[fieldNames.label]),
}
}),
)
const defaultValue = $derived(required ? (value.length ? [options?.[0]?.value] : []) : [])
let totalPages = $derived(paginatedEntityList.totalPages)
let currentPageNumber = $derived(paginatedEntityList.page)
let previousPage = $derived((currentPageNumber - 1) >= 1 ? currentPageNumber - 1 : false)
let nextPage = $derived((currentPageNumber + 1) <= totalPages ? currentPageNumber + 1 : false)
onMount(() => {
value = value ? value : (required ? (options?.[0]?.value.length > 0 ? [options?.[0]?.value] : defaultValue) : [])
})
function hasValue(checkValue: string): boolean {
return value.indexOf(checkValue) !== -1
}
</script>
<div class="border border-base-content/25 bg-base-300 p-2 rounded-lg join-vertical justify-items-end">
<span class="join-item">
<span>{label}</span>
{#if required}<span class="text-error">*</span>{/if}
</span>
{#each options as option, index(index)}
<div class="join-item rounded-s-md">
<fieldset class="fieldset">
<label class="label cursor-pointer">
<span class="label-text">{option.label}</span>
<input
name={`${name}[]`}
class="checkbox"
checked={hasValue(option.value)}
type="checkbox"
value={option.value}
/>
</label>
</fieldset>
</div>
{/each}
{#if errors?.length}
<div class="label flex-wrap">
{#each errors as error, index(index)}
<span class="text-error">{error}</span>
{/each}
</div>
{/if}
{#if previousPage !== false}
<button
class="btn join-item btn-info self-end"
onclick={(): void => onClickPrevious ? onClickPrevious(previousPage) : undefined}
title="Page précédente"
type="button"
><LucideIcon name="ChevronLeft" title={"Un chevron pointant vers la gauche"} /></button>
{/if}
{#if nextPage !== false}
<button
class="btn join-item btn-info self-end"
onclick={(): void => onClickNext ? onClickNext(nextPage) : undefined}
title="Page suivante"
type="button"
><LucideIcon name="ChevronRight" title={"Un chevron pointant vers la droite"} /></button>
{/if}
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
appName: string
appVersion: string
}
const {
appName,
appVersion,
}: Props = $props()
</script>
<footer class="container-fluid">
<div class="row bg-base-200/65 border-t-2 border-t-base-300/65 text-base-content/70 py-2 shadow-inner">
<div class="col">
<ul class="flex flex-wrap justify-center text-sm">
<li class="mx-2 font-bold">{appName}</li>
<li class="mx-2">-</li>
<li class="mx-2 font-bold">version : {appVersion}</li>
</ul>
</div>
</div>
</footer>

View file

@ -0,0 +1,187 @@
<script lang="ts">
import { onMount, tick } from 'svelte'
import * as focusTrap from 'focus-trap'
import { PUBLIC_APP_NAME } from '$env/static/public'
import { LucideIcon } from '$lib/components'
import type { Brand } from '$lib/models'
import { onNavigate } from '$app/navigation'
import { BrandSelector, MainMenu, ThemeChanger, UserMenu } from './menu'
import { Str, t } from '$lib/tools'
let drawerSide: HTMLDivElement | undefined = $state()
let modalFocusTrap: focusTrap.FocusTrap| undefined = $state()
let mobileMenuDisplayed = $state(false)
interface Props {
classList?: string | string[]
activeBrand: Brand | undefined
brands: Brand[]
}
const {
classList,
activeBrand,
brands,
}: Props = $props()
onMount(() => {
// @ts-ignore
window.focusTrap = focusTrap
// @ts-ignore
window.drawerSide = drawerSide
if (drawerSide) {
modalFocusTrap = focusTrap.createFocusTrap(drawerSide)
} else {
modalFocusTrap = undefined
}
})
$effect(() => {
return () => {
// on delete and before rerun effect
modalFocusTrap?.deactivate()
}
})
$effect.pre(() => {
if (mobileMenuDisplayed) {
setTimeout(() => {
if (!drawerSide) {
return
}
modalFocusTrap = focusTrap.createFocusTrap(drawerSide).activate()
}, 2000)
}
else {
modalFocusTrap?.deactivate()
}
})
// Events
const onKeyDownMainMenuDrawerInput = (e: KeyboardEvent): void => {
if (e.key === 'Enter') {
mobileMenuDisplayed = true
}
}
const onClickMainMenuDrawerShowButton = (): void => {
mobileMenuDisplayed = true
}
const onClickMainMenuDrawerExitButton = (): void => {
mobileMenuDisplayed = false
}
onNavigate(() => {
mobileMenuDisplayed = false
modalFocusTrap?.deactivate()
})
</script>
<header
class={[
'border-b-2 border-secondary shadow-xs shadow-slate-500',
...typeof classList === 'string' ? [classList] : classList ?? []].join(' ')}
>
<!-- DRAWER INPUT -->
<input
id="main-menu-drawer"
class="drawer-toggle btn btn-ghost w-12 h-8 opacity-100 focus-visible:ring-3 top-2 left-2 xl:hidden"
onkeydown={onKeyDownMainMenuDrawerInput}
role="button"
tabindex="0"
type="checkbox"
bind:checked={mobileMenuDisplayed}
/>
<!-- DRAWER CONTENT -->
<div class="drawer-content flex flex-col">
<!-- NAVBAR -->
<div class="navbar backdrop-blur-xl backdrop-grayscale">
<button
class="btn btn-ghost btn-square hover:bg-transparent xl:hidden"
aria-label="Accueil"
onclick={onClickMainMenuDrawerShowButton}
type="button"
>
<LucideIcon
name="Menu"
title={"Un menu"}
size="1.6rem"
/>
</button>
<!-- Brand Button -->
<div class="grow flex items-center">
<a
class="hidden sm:inline-flex btn btn-ghost text-xl"
href="/"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.dashboard') }))}
>{PUBLIC_APP_NAME}</a>
<BrandSelector
{activeBrand}
{brands}
/>
</div>
<!-- MAIN MENU -->
<div class="xl:inline-flex flex-none pe-6">
<div class="hidden xl:block">
<MainMenu />
</div>
<div class="my-auto">
<UserMenu />
</div>
</div>
<div class="me-2">
<ThemeChanger />
</div>
</div>
</div>
<!-- DRAWER SIDE -->
<div
bind:this={drawerSide}
class="drawer-side"
>
<!-- CLOSABLE OVERLAY -->
<button
class="drawer-overlay"
class:hidden={!mobileMenuDisplayed}
aria-label="close sidebar"
onclick={onClickMainMenuDrawerExitButton}
tabindex="-1"
type="button"
></button>
<div
class="menu bg-base-100 text-base-content min-h-full w-80 p-4 shadow-sm"
aria-label="Menu mobile"
aria-modal="true"
inert={!mobileMenuDisplayed}
role="dialog"
tabindex="-1"
>
<div class="flex justify-end">
<button
class="btn btn-sm btn-square btn-ghost"
onclick={onClickMainMenuDrawerExitButton}
title="Fermer le menu"
type="button"
>
<LucideIcon name="X" title={"Une croix"} />
</button>
</div>
<MainMenu direction="vertical" />
</div>
</div>
</header>

View file

@ -0,0 +1,78 @@
<script lang="ts">
import type { FlashMessage } from '$customType'
import { LucideIcon } from '$lib/components'
interface Props {
message: FlashMessage
onclose?: () => void
}
const {
message,
onclose,
}: Props = $props()
let nodeRef: HTMLDivElement
let opened: boolean = $state(true)
$effect(() => {
if (message.message.length) {
opened = true
}
})
</script>
<div
bind:this={nodeRef}
class="alert mb-3"
class:alert-error={message.type === 'error'}
class:alert-info={message.type === 'info'}
class:alert-success={message.type === 'success'}
class:alert-warning={message.type === 'warning'}
class:hidden={!opened}
role="alert"
>
{#if message.type === 'error'}
<LucideIcon name="CircleX" title={"Une croix"} />
{/if}
{#if message.type === 'warning'}
<LucideIcon name="TriangleAlert" title={"Un point d'exclamation"} />
{/if}
{#if message.type === 'success'}
<LucideIcon name="CircleCheck" title={"Un signe de validation"} />
{/if}
{#if message.type === 'info'}
<LucideIcon name="Info" title={"Un signe representant un fait notable"} />
{/if}
{#if message.type === 'default'}
<LucideIcon
name="Info"
title={"Un signe representant un fait notable"}
classList="text-primary "
/>
{/if}
<pre
class="font-serif"
class:text-error-content={message.type === 'error'}
class:text-info-content={message.type === 'info'}
class:text-success-content={message.type === 'success'}
class:text-warning-content={message.type === 'warning'}
>{message.message}</pre>
{#if message.at}
<a
class="btn btn-sm no-underline"
href={message.at}
>Voir</a>
{/if}
<button
class="btn btn-square btn-sm btn-ghost"
onclick={(): void => {
if (onclose) onclose()
opened = false
}}
title="Fermer l´alerte"
type="button"
><LucideIcon name="X" title={"Une croix"}/>
</button>
</div>

View file

@ -0,0 +1,7 @@
import Footer from './Footer.svelte'
import Header from './Header.svelte'
import Message from './Message.svelte'
export {
Footer, Header, Message,
}

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from '$app/forms'
import { goto } from '$app/navigation'
import { page } from '$app/state'
import type { Uuid } from '$customType/value-objects'
import type { Brand } from '$lib/models'
import { Str } from '$lib/tools'
import { SelectInput } from '../../inputs'
interface Props {
activeBrand: Brand | undefined
brands: Brand[]
}
const {
activeBrand,
brands,
}: Props = $props()
const { url } = $derived(page)
let changingActiveBrand: boolean = $state(false)
let form: HTMLFormElement
let selectedBrand: Uuid | undefined = $state(activeBrand?.slug)
$effect(() => {
selectedBrand = activeBrand?.slug
})
// EVENTS
function onSearchOnChange(): void {
form.requestSubmit()
}
</script>
<form
bind:this={form}
action="/api/active-brand"
method="POST"
use:enhance={() => {
changingActiveBrand = true
return async ({ update }): Promise<void> => {
await update()
changingActiveBrand = false
// eslint-disable-next-line svelte/no-goto-without-base
return goto(url)
}
}}
>
{#if brands.length}
<SelectInput
name="active-brand"
ariaLabel="Enseigne active du site"
classList="border-0"
disabled={changingActiveBrand}
onchange={onSearchOnChange}
options={brands.map((brand) => {
return {
label: Str.toTitleCase(brand.name),
value: brand.slug,
}
})}
outerClassList="join-item w-40 md:w-52"
required
bind:value={selectedBrand}
/>
{/if}
</form>

View file

@ -0,0 +1,125 @@
<script lang="ts">
import { page } from '$app/state'
import { can } from '$lib/client/tools'
import { LucideIcon } from '$lib/components'
import { Str, t } from '$lib/tools';
interface Props {
direction?: 'horizontal' | 'vertical'
}
let {
direction = $bindable('horizontal'),
}: Props = $props()
const { url, data } = $derived(page)
const firstLayerPage = $derived(url.pathname.split('/').at(1))
const secondLayerPage = $derived(url.pathname.split('/').at(2))
</script>
<ul
class="menu menu-horizontal px-1 w-full"
class:menu-horizontal={direction === 'horizontal'}
class:menu-vertical={direction === 'vertical'}
>
<li>
<a
class="lg:rounded-e-none"
class:bg-info={firstLayerPage === 'links'}
class:text-info-content={firstLayerPage === 'links'}
href="/links"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.links') }))}
>{Str.toTitleCase($t('common.pages.links'))}</a>
</li>
<li class="lg:dropdown lg:dropdown-end">
<summary
class="rounded-s-none cursor-pointer ps-2 pe-0 hidden lg:inline-flex"
tabindex="0"
title="Afficher le menu des liens"
><LucideIcon name="ChevronDown" title={"Un chevron bas"}/>&nbsp;</summary>
<ul class="ms-0 rounded-sm dropdown-content bg-base-200 text-base-content dark:bg-neutral dark:text-neutral-content p-2">
<li
class:bg-info={secondLayerPage === 'link-statistics'}
class:text-info-content={secondLayerPage === 'link-statistics'}
>
<a
class="justify-between btn rounded-none btn-ghost px-2 w-full"
href="/links/link-statistics"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.link-statistics') }))}
>{Str.toTitleCase($t('common.pages.link-statistics'))}</a>
</li>
<li
class:bg-info={secondLayerPage === 'import'}
class:text-info-content={secondLayerPage === 'import'}
>
<a
class="justify-between btn rounded-none btn-ghost px-2 w-full"
href="/links/import"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.import') }))}
>{Str.toTitleCase($t('common.pages.import'))}</a>
</li>
<li
class:bg-info={secondLayerPage === 'export'}
class:text-info-content={secondLayerPage === 'export'}
>
<a
class="justify-between btn rounded-none btn-ghost px-2 w-full"
href="/links/export"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.export') }))}
>{Str.toTitleCase($t('common.pages.export'))}</a>
</li>
</ul>
</li>
<li>
<a
class:bg-info={firstLayerPage === 'catalogs'}
class:text-info-content={firstLayerPage === 'catalogs'}
href="/catalogs"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.catalogs') }))}
>{Str.ucFirst($t('common.pages.catalogs'))}</a>
</li>
<li>
<a
class:bg-info={firstLayerPage === 'tags'}
class:text-info-content={firstLayerPage === 'tags'}
href="/tags"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.tags') }))}
>{Str.ucFirst($t('common.pages.tags'))}</a>
</li>
{#if can('brand:viewAny', data['auth'])}
<li>
<a
class:bg-info={firstLayerPage === 'brands'}
class:text-info-content={firstLayerPage === 'brands'}
href="/brands"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.brands') }))}
>{Str.ucFirst($t('common.pages.brands'))}</a>
</li>
{/if}
{#if can('user:viewAny', data['auth'])}
<li>
<a
class:bg-info={firstLayerPage === 'users'}
class:text-info-content={firstLayerPage === 'users'}
href="/users"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.users') }))}
>{Str.ucFirst($t('common.pages.users'))}</a>
</li>
{/if}
<li>
<a
class:bg-info={firstLayerPage === 'statistics'}
class:text-info-content={firstLayerPage === 'statistics'}
href="/statistics"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.statistics') }))}
>{Str.ucFirst($t('common.pages.statistics'))}</a>
</li>
<li>
<a
class:bg-info={firstLayerPage === 'help'}
class:text-info-content={firstLayerPage === 'help'}
href="/help"
title={Str.ucFirst($t('common.action.goto', { pageName: $t('common.pages.help') }))}
>{Str.ucFirst($t('common.pages.help'))}</a>
</li>
</ul>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { wantedTheme, setTheme, type Theme } from '$lib/client'
import { LucideIcon } from '$lib/components'
let manuallyChangedTheme = $state<boolean>(wantedTheme() === 'dark')
const userDarkTheme = $derived<Theme>(manuallyChangedTheme ? 'dark' : 'light')
$effect(() => {
manuallyChangedTheme = wantedTheme() === 'dark'
})
// Events
const onKeyDownThemeSwapInput = (e: KeyboardEvent): void => {
if (e.key === 'Enter') {
manuallyChangedTheme = (e.target as HTMLInputElement).checked = !(e.target as HTMLInputElement).checked
}
}
</script>
<label class="swap swap-rotate">
<input
class="theme-button btn btn-ghost"
onkeydown={onKeyDownThemeSwapInput}
role="button"
tabindex="0"
type="checkbox"
value="dark"
onchange="{() => setTheme(userDarkTheme)}"
bind:checked={manuallyChangedTheme}
/>
<span class="flex justify-center align-middle items-center swap-off fill-current">
<LucideIcon
name="Sun"
title={"Un soleil"}
size="1.8rem"
/>
</span>
<span class="flex justify-center align-middle items-center swap-on fill-current">
<LucideIcon
name="Moon"
title={"Une lune"}
size="1.8rem"
/>
</span>
</label>
<style lang="postcss">
@reference "$src/css/app.css";
.theme-button {
@apply w-10 min-h-10 h-10 p-0;
}
</style>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { modals } from 'svelte-modals'
import { applyAction, enhance } from '$app/forms'
import { page } from '$app/state'
import { LucideIcon } from '$lib/components'
import { Modal } from '../../common'
import { Str, t } from '$lib/tools'
const { url } = $derived(page)
const firstLayerPage = $derived(url.pathname.split('/').at(1))
</script>
<div class="dropdown dropdown-end">
<button
class="profile-button btn btn-ghost btn-circle hover:ring-3 focus:ring-3 border-slate-700 from-slate-800 to-slate-200 shadow-sm"
class:bg-info={firstLayerPage === 'account'}
class:text-info-content={firstLayerPage === 'account'}
tabindex="0"
title="Afficher le menu utilisateur"
type="button"
>
<span class="rounded-full flex justify-center align-middle items-center">
<LucideIcon
name="User"
title={"Une personne physique"}
size="2rem"
/>
</span>
</button>
<ul class="menu dropdown-content bg-base-200 text-base-content dark:bg-neutral dark:text-neutral-content rounded-box z-1 mt-3 w-32 p-2 shadow-sm">
<li
class="mb-2"
class:bg-info={firstLayerPage === 'account'}
class:text-info-content={firstLayerPage === 'account'}
>
<a
class="justify-between btn rounded-none btn-ghost px-2 w-full"
href="/account"
title="Gérer mon compte"
>Mon compte</a>
</li>
<li class="mb-2">
<form
class="flex p-0"
action="/logout"
method="POST"
use:enhance={async ({ cancel }) => {
if (!await modals.open(Modal, {
title: Str.toTitleCase($t('common.action.confirm')),
message: 'Voulez-vous vous déconnecter ?',
confirm: true,
})) {
cancel()
}
return async ({ result }): Promise<void> => {
applyAction(result)
}
}}
>
<button
class="justify-between btn rounded-none btn-ghost px-2 w-full"
title="Se deconnecter"
type="submit"
>Deconnexion</button>
</form>
</li>
</ul>
</div>
<style lang="postcss">
@reference "$src/css/app.css";
.profile-button {
@apply w-10 min-h-10 h-10 p-0;
> span {
@apply w-5;
}
@max-sm {
> span {
@apply w-6;
}
}
}
</style>

View file

@ -0,0 +1,8 @@
import BrandSelector from './BrandSelector.svelte'
import MainMenu from './MainMenu.svelte'
import ThemeChanger from './ThemeChanger.svelte'
import UserMenu from './UserMenu.svelte'
export {
BrandSelector, MainMenu, ThemeChanger, UserMenu,
}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { LucideIcon } from '$lib/components'
import { t } from '$lib/tools'
interface Props {
pageTitle: string
subTitle?: string
previousUrl?: string
children?: Snippet
}
const {
pageTitle,
subTitle,
previousUrl,
children,
}: Props = $props()
</script>
<div class="row mb-4">
<div class="col sm:text-center relative flex flex-wrap justify-between">
{#if previousUrl}
<a
class="btn btn-sm btn-square btn-primary mb-2 sm:absolute sm:left-0 sm:mb-0"
href={previousUrl}
title={$t('common.action.previous')}
><LucideIcon name="ChevronLeft" title={"Un chevron pointant vers la gauche"} />
</a>
{/if}
{#if children}
<span class="mx-2 mb-2 sm:absolute sm:right-0 sm:top-0 sm:mb-0 sm:mx-0">
{@render children()}
</span>
{/if}
<h1 class="mx-auto">
{pageTitle}
{#if subTitle !== undefined}
<small class="block text-sm text-neutral dark:text-neutral-400">{subTitle}</small>
{/if}
</h1>
</div>
</div>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation'
import { breadcrumb } from '$lib/client/stores'
import { LucideIcon } from '$lib/components'
import { Str, t } from '$lib/tools'
interface BreadcrumdData { name: string, url?: string }
let breadcrumbParts: BreadcrumdData[] = $state([])
beforeNavigate(() => {
breadcrumb.set({})
})
afterNavigate((navigation) => {
breadcrumbParts = []
const currentUrl = navigation.to?.url
if (!currentUrl) return
const segments = currentUrl.pathname.split('/').filter(segment => segment)
const breadcrumbData = $breadcrumb
if (breadcrumbData.previous === false) {
segments.splice(segments.length - 2, 1)
}
if (typeof breadcrumbData.previous === 'object') {
segments.splice(segments.length - 2, 1, breadcrumbData.previous.text)
}
if (breadcrumbData.current?.length) {
segments[segments.length - 1] = breadcrumbData.current
}
segments.forEach((segment, index) => {
breadcrumbParts.push({
url: '/' + segments.slice(0, index + 1).join('/'),
name: segment,
})
})
if (typeof breadcrumbData.previous === 'object') {
breadcrumbParts.splice(segments.length - 2, 1, {
url: breadcrumbData.previous.url,
name: breadcrumbData.previous.text,
})
}
if (typeof breadcrumbData.previous === 'string') {
breadcrumbParts.splice(segments.length - 2, 1, {
name: breadcrumbData.previous,
})
}
})
const getPageTitle = (i18nPrefix: string, value: string): string => {
const translated = $t(`${i18nPrefix}.${value}`)
if (translated.indexOf(i18nPrefix) === 0) {
return value
}
return translated
}
</script>
{#key breadcrumbParts.length}
<div class="breadcrumbs text-sm">
<ul>
<li>
<a
href="/"
title={Str.ucFirst($t('common.action.goto', { pageName: Str.ucFirst($t('common.pages.dashboard')) }))}
>
<span class="ps-1">{Str.ucFirst($t('common.pages.dashboard'))}</span>
</a>
</li>
{#each breadcrumbParts as segment, index (index)}
<li>
{#if index === breadcrumbParts.length - 1}
<span class="hover:!no-underline hover:cursor-auto">{Str.ucFirst(getPageTitle('common.pages', segment.name))}</span>
{:else if segment.url !== undefined}
<a
href={segment.url}
title={Str.ucFirst($t('common.action.goto', { pageName: Str.ucFirst(segment.name) }))}
>{Str.ucFirst(getPageTitle('common.pages', segment.name))}</a>
{:else}
<span class="hover:!no-underline hover:cursor-auto">{Str.ucFirst(getPageTitle('common.pages', segment.name))}</span>
{/if}
</li>
{/each}
</ul>
</div>
{/key}

View file

@ -0,0 +1,4 @@
import ActionBar from './ActionBar.svelte'
import Breadcrumb from './Breadcrumb.svelte'
export { ActionBar, Breadcrumb }

View file

@ -0,0 +1,48 @@
<script lang="ts">
import LucideIcon from "$lib/components/LucideIcon.svelte";
import { Str, t } from "$lib/tools";
interface Props {
title: string
description: string
class?: HTMLDivElement['className']
color?:'primary'|'secondary'|'success'|'warning'|'error'|'neutral'|'accent'|'info'
url: string
}
let {
title,
description,
color,
url,
...props
}: Props = $props()
</script>
<div class={'card w-96 bg-base-200 border border-slate-200 dark:border-slate-800 shadow-sm shadow-slate-200 dark:shadow-slate-800 card-md ' + props?.class }>
<div class="card-body relative">
<h2 class="card-title">{title}</h2>
<span
class="status absolute top-3 right-3"
class:status-info={color === 'info'}
class:status-primary={color === 'primary'}
class:status-secondary={color === 'secondary'}
class:status-success={color === 'success'}
class:status-warning={color === 'warning'}
class:status-error={color === 'error'}
class:status-neutral={color === 'neutral'}
class:status-accent={color === 'accent'}
>
</span>
<p>{description}</p>
<div class="justify-end card-actions">
<a
class="btn btn-primary"
title={Str.ucFirst($t('common.action.view_statistics'))}
href={url}
><LucideIcon name="Eye" /></a>
</div>
</div>
</div>

View file

@ -0,0 +1,3 @@
import StatisticsShortCutCard from "./StatisticsShortCutCard.svelte";
export { StatisticsShortCutCard };

View file

@ -0,0 +1,7 @@
import HelpComponent from './HelpComponent.svelte'
import LucideIcon from './LucideIcon.svelte'
export {
HelpComponent,
LucideIcon,
}

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { SvelteToast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
import { title } from '$lib/client/stores'
interface Props {
errorCode: number
message: string
}
const {
errorCode,
message,
}: Props = $props()
const messageText = errorCode === 429 ? 'Trop de requêtes' : message
const pageTitle = `${errorCode} | ${messageText}`
// Set Page Title
title.set(pageTitle)
const options: SvelteToastOptions = {
classes: ['toast', 'toast-top', 'toast-end'],
duration: 10000,
}
</script>
<h1>{pageTitle}</h1>
<div class="wrap">
<SvelteToast {options} />
</div>
<style lang="postcss">
.wrap {
--toastBackground: oklch(var(--b3));
--toastColor: oklch(var(--bc));
}
</style>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { base } from '$app/paths'
import { title } from '$lib/client/stores'
const pageTitle = 'Bad Link!'
// Set Page Title
title.set(pageTitle)
</script>
<h1>Bad Link!</h1>
<p class="my-2">La page demandée n'a pas pu être trouvée</p>
<button
class="btn btn-primary mt-8"
onclick={(): Promise<void> => goto(`${base}/`)}
type="button"
>Retour à l'accueil</button>

View file

@ -0,0 +1,6 @@
import ErrorScreen from './ErrorScreen.svelte'
import NotFoundScreen from './NotFoundScreen.svelte'
export {
ErrorScreen, NotFoundScreen,
}

View file

@ -0,0 +1,5 @@
export enum Direction {
auto = 'auto',
asc = 'asc',
desc = 'desc',
}

View file

@ -0,0 +1,9 @@
export enum GlobalStatistic {
total_invalid_links_all = 'total_invalid_links_for_all',
total_invalid_links_brand = 'total_invalid_links_for_brand',
total_visits_all = 'total_visits_for_all',
total_visits_brand = 'total_visits_for_brand',
total_external_visits_all = 'total_external_visits_for_all',
total_external_visits_brand = 'total_external_visits_for_brand',
total_brand_less_visits = 'total_brand_less_visits',
}

13
src/lib/enums/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { Direction } from './direction.enum'
import { GlobalStatistic } from './globalStatistic.enum'
import { Level } from './level.enum'
import { Role } from './roles.enum'
import { Validity } from './validity.enum'
export {
Direction,
GlobalStatistic,
Level,
Role,
Validity,
}

View file

@ -0,0 +1,5 @@
export enum Level {
info = 'information',
warn = 'avertissement',
critical = 'critique',
}

View file

@ -0,0 +1,4 @@
export enum Role {
ADMINISTRATOR = 'ADMINISTRATOR',
PUBLISHER = 'PUBLISHER',
}

View file

@ -0,0 +1,5 @@
export enum Validity {
unknown,
valid,
invalid,
}

View file

@ -0,0 +1,9 @@
import type { Uuid } from '$customType/value-objects'
import type { Entity } from '.'
export default interface Brand extends Entity {
uuid: Uuid
slug: string
name: string
fqdns: string[]
}

View file

@ -0,0 +1,9 @@
import type { Uuid } from '$customType/value-objects'
import type { Entity } from '.'
export default interface Catalog extends Entity {
uuid: Uuid
slug: string
name: string
brand: Uuid
}

Some files were not shown because too many files have changed in this diff Show more