init
This commit is contained in:
commit
0526ecc057
392 changed files with 24372 additions and 0 deletions
20
.editorconfig
Normal file
20
.editorconfig
Normal 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
8
.env.example
Normal 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
287
.gitignore
vendored
Normal 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
42
.gitlab-ci.yml
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
6
.vscode/extensions.json
vendored
Normal file
6
.vscode/extensions.json
vendored
Normal 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
66
.vscode/settings.json
vendored
Normal 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
188
CHANGELOG.md
Normal 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
21
Dockerfile
Normal 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
47
README.md
Normal 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
81
eslint.config.js
Normal 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
7740
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
81
package.json
Normal file
81
package.json
Normal 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
12
playwright.config.ts
Normal 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
32
src/app.d.ts
vendored
Normal 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
47
src/app.html
Normal 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
7
src/css/app.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@import './tailwind.css';
|
||||
@import './fonts.css';
|
||||
|
||||
@import './tailwind-custom.css';
|
||||
@import './daisyui-custom.css';
|
||||
|
||||
@import './grid.css';
|
||||
51
src/css/daisyui-custom.css
Normal file
51
src/css/daisyui-custom.css
Normal 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
125
src/css/fonts.css
Normal 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
96
src/css/grid.css
Normal 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;
|
||||
}
|
||||
34
src/css/tailwind-custom.css
Normal file
34
src/css/tailwind-custom.css
Normal 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
107
src/css/tailwind.css
Normal 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
107
src/error.html
Normal 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
22
src/hooks.client.ts
Normal 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
180
src/hooks.server.ts
Normal 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
9
src/lib/client/index.ts
Normal 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
|
||||
}
|
||||
|
||||
49
src/lib/client/preferedTheme.svelte.ts
Normal file
49
src/lib/client/preferedTheme.svelte.ts
Normal 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
|
||||
}
|
||||
5
src/lib/client/services/index.ts
Normal file
5
src/lib/client/services/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { validateLink } from './validateLink.svelte'
|
||||
|
||||
export {
|
||||
validateLink,
|
||||
}
|
||||
24
src/lib/client/services/validateLink.svelte.ts
Normal file
24
src/lib/client/services/validateLink.svelte.ts
Normal 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
|
||||
}
|
||||
30
src/lib/client/stores/brandStore.svelte.ts
Normal file
30
src/lib/client/stores/brandStore.svelte.ts
Normal 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)
|
||||
30
src/lib/client/stores/catalogStore.svelte.ts
Normal file
30
src/lib/client/stores/catalogStore.svelte.ts
Normal 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)
|
||||
16
src/lib/client/stores/errorsStore.svelte.ts
Normal file
16
src/lib/client/stores/errorsStore.svelte.ts
Normal 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()
|
||||
26
src/lib/client/stores/index.ts
Normal file
26
src/lib/client/stores/index.ts
Normal 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 }
|
||||
21
src/lib/client/stores/messagesStore.svelte.ts
Normal file
21
src/lib/client/stores/messagesStore.svelte.ts
Normal 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
|
||||
19
src/lib/client/stores/pageBreadcrumbStore.svelte.ts
Normal file
19
src/lib/client/stores/pageBreadcrumbStore.svelte.ts
Normal 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()
|
||||
16
src/lib/client/stores/pageTitleStore.svelte.ts
Normal file
16
src/lib/client/stores/pageTitleStore.svelte.ts
Normal 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()
|
||||
30
src/lib/client/stores/tagStore.svelte.ts
Normal file
30
src/lib/client/stores/tagStore.svelte.ts
Normal 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
35
src/lib/client/toaster.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
28
src/lib/client/tools/applyZ3Action.tools.ts
Normal file
28
src/lib/client/tools/applyZ3Action.tools.ts
Normal 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({})
|
||||
}
|
||||
}
|
||||
45
src/lib/client/tools/authorization/can.tools.ts
Normal file
45
src/lib/client/tools/authorization/can.tools.ts
Normal 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)
|
||||
}
|
||||
30
src/lib/client/tools/authorization/policies/BrandPolicy.ts
Normal file
30
src/lib/client/tools/authorization/policies/BrandPolicy.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/lib/client/tools/authorization/policies/UserPolicy.ts
Normal file
45
src/lib/client/tools/authorization/policies/UserPolicy.ts
Normal 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
|
||||
}
|
||||
}
|
||||
7
src/lib/client/tools/authorization/policies/index.ts
Normal file
7
src/lib/client/tools/authorization/policies/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import BrandPolicy from './BrandPolicy'
|
||||
import UserPolicy from './UserPolicy'
|
||||
|
||||
export {
|
||||
BrandPolicy,
|
||||
UserPolicy,
|
||||
}
|
||||
4
src/lib/client/tools/index.ts
Normal file
4
src/lib/client/tools/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import applyZ3Action from './applyZ3Action.tools'
|
||||
import can from './authorization/can.tools'
|
||||
|
||||
export { applyZ3Action, can }
|
||||
37
src/lib/components/HelpComponent.svelte
Normal file
37
src/lib/components/HelpComponent.svelte
Normal 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>
|
||||
94
src/lib/components/LucideIcon.svelte
Normal file
94
src/lib/components/LucideIcon.svelte
Normal 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"
|
||||
/>
|
||||
264
src/lib/components/app/charts/BarChart.svelte
Normal file
264
src/lib/components/app/charts/BarChart.svelte
Normal 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>
|
||||
267
src/lib/components/app/charts/LineChart.svelte
Normal file
267
src/lib/components/app/charts/LineChart.svelte
Normal 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>
|
||||
4
src/lib/components/app/charts/index.ts
Normal file
4
src/lib/components/app/charts/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import BarChart from './BarChart.svelte'
|
||||
import LineChart from './LineChart.svelte'
|
||||
|
||||
export { BarChart, LineChart }
|
||||
40
src/lib/components/app/common/BadgeColor.svelte
Normal file
40
src/lib/components/app/common/BadgeColor.svelte
Normal 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>
|
||||
61
src/lib/components/app/common/FilterEntity.svelte
Normal file
61
src/lib/components/app/common/FilterEntity.svelte
Normal 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}
|
||||
/>
|
||||
53
src/lib/components/app/common/FilterNoBrand.svelte
Normal file
53
src/lib/components/app/common/FilterNoBrand.svelte
Normal 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}
|
||||
/>
|
||||
17
src/lib/components/app/common/Fqdns.svelte
Normal file
17
src/lib/components/app/common/Fqdns.svelte
Normal 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>
|
||||
49
src/lib/components/app/common/GlobalStatistics.svelte
Normal file
49
src/lib/components/app/common/GlobalStatistics.svelte
Normal 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>
|
||||
33
src/lib/components/app/common/HelpButton.svelte
Normal file
33
src/lib/components/app/common/HelpButton.svelte
Normal 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>
|
||||
95
src/lib/components/app/common/Modal.svelte
Normal file
95
src/lib/components/app/common/Modal.svelte
Normal 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>
|
||||
243
src/lib/components/app/common/Pagination.svelte
Normal file
243
src/lib/components/app/common/Pagination.svelte
Normal 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">‹</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">‹</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">›</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">›</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>
|
||||
86
src/lib/components/app/common/PerPage.svelte
Normal file
86
src/lib/components/app/common/PerPage.svelte
Normal 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"
|
||||
/> <span class="font-extrabold">{perPage}</span> <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>
|
||||
142
src/lib/components/app/common/Search.svelte
Normal file
142
src/lib/components/app/common/Search.svelte
Normal 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>
|
||||
81
src/lib/components/app/common/SortBy.svelte
Normal file
81
src/lib/components/app/common/SortBy.svelte
Normal 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>
|
||||
22
src/lib/components/app/common/UserRole.svelte
Normal file
22
src/lib/components/app/common/UserRole.svelte
Normal 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}
|
||||
18
src/lib/components/app/common/index.ts
Normal file
18
src/lib/components/app/common/index.ts
Normal 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,
|
||||
}
|
||||
75
src/lib/components/app/import/ImportErrors.svelte
Normal file
75
src/lib/components/app/import/ImportErrors.svelte
Normal 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 : </b><i>{importedLine[0]}</i></p>
|
||||
<p class="flex flex-row"><b class="ms-3">message : </b><span>{importedLine[1]}</span></p>
|
||||
<p class="flex flex-row"><b class="ms-3">source : </b><span>{importedLine[2]}</span></p>
|
||||
<p class="flex flex-row"><b class="ms-3">url : </b><span>{importedLine[3]}</span></p>
|
||||
<p class="flex flex-row"><b class="ms-3">externe : </b><span>{importedLine[4]}</span></p>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
3
src/lib/components/app/import/index.ts
Normal file
3
src/lib/components/app/import/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import ImportErrors from './ImportErrors.svelte'
|
||||
|
||||
export { ImportErrors }
|
||||
15
src/lib/components/app/inputs/index.ts
Normal file
15
src/lib/components/app/inputs/index.ts
Normal 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,
|
||||
}
|
||||
108
src/lib/components/app/inputs/input-checkbox.svelte
Normal file
108
src/lib/components/app/inputs/input-checkbox.svelte
Normal 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} <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>
|
||||
105
src/lib/components/app/inputs/input-collection-table.svelte
Normal file
105
src/lib/components/app/inputs/input-collection-table.svelte
Normal 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>
|
||||
107
src/lib/components/app/inputs/input-color.svelte
Normal file
107
src/lib/components/app/inputs/input-color.svelte
Normal 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} <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>
|
||||
131
src/lib/components/app/inputs/input-file.svelte
Normal file
131
src/lib/components/app/inputs/input-file.svelte
Normal 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} <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>
|
||||
112
src/lib/components/app/inputs/input-password.svelte
Normal file
112
src/lib/components/app/inputs/input-password.svelte
Normal 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} <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>
|
||||
107
src/lib/components/app/inputs/input.svelte
Normal file
107
src/lib/components/app/inputs/input.svelte
Normal 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} <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>
|
||||
107
src/lib/components/app/inputs/select-entity.svelte
Normal file
107
src/lib/components/app/inputs/select-entity.svelte
Normal 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>
|
||||
125
src/lib/components/app/inputs/select-input.svelte
Normal file
125
src/lib/components/app/inputs/select-input.svelte
Normal 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} <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>
|
||||
109
src/lib/components/app/inputs/select-multiple-entities.svelte
Normal file
109
src/lib/components/app/inputs/select-multiple-entities.svelte
Normal 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>
|
||||
25
src/lib/components/app/layout/Footer.svelte
Normal file
25
src/lib/components/app/layout/Footer.svelte
Normal 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>
|
||||
187
src/lib/components/app/layout/Header.svelte
Normal file
187
src/lib/components/app/layout/Header.svelte
Normal 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>
|
||||
78
src/lib/components/app/layout/Message.svelte
Normal file
78
src/lib/components/app/layout/Message.svelte
Normal 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>
|
||||
7
src/lib/components/app/layout/index.ts
Normal file
7
src/lib/components/app/layout/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Footer from './Footer.svelte'
|
||||
import Header from './Header.svelte'
|
||||
import Message from './Message.svelte'
|
||||
|
||||
export {
|
||||
Footer, Header, Message,
|
||||
}
|
||||
70
src/lib/components/app/layout/menu/BrandSelector.svelte
Normal file
70
src/lib/components/app/layout/menu/BrandSelector.svelte
Normal 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>
|
||||
125
src/lib/components/app/layout/menu/MainMenu.svelte
Normal file
125
src/lib/components/app/layout/menu/MainMenu.svelte
Normal 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"}/> </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>
|
||||
55
src/lib/components/app/layout/menu/ThemeChanger.svelte
Normal file
55
src/lib/components/app/layout/menu/ThemeChanger.svelte
Normal 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>
|
||||
88
src/lib/components/app/layout/menu/UserMenu.svelte
Normal file
88
src/lib/components/app/layout/menu/UserMenu.svelte
Normal 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>
|
||||
8
src/lib/components/app/layout/menu/index.ts
Normal file
8
src/lib/components/app/layout/menu/index.ts
Normal 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,
|
||||
}
|
||||
46
src/lib/components/app/layout/navigation/ActionBar.svelte
Normal file
46
src/lib/components/app/layout/navigation/ActionBar.svelte
Normal 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>
|
||||
93
src/lib/components/app/layout/navigation/Breadcrumb.svelte
Normal file
93
src/lib/components/app/layout/navigation/Breadcrumb.svelte
Normal 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}
|
||||
4
src/lib/components/app/layout/navigation/index.ts
Normal file
4
src/lib/components/app/layout/navigation/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import ActionBar from './ActionBar.svelte'
|
||||
import Breadcrumb from './Breadcrumb.svelte'
|
||||
|
||||
export { ActionBar, Breadcrumb }
|
||||
|
|
@ -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>
|
||||
3
src/lib/components/app/statistics/index.ts
Normal file
3
src/lib/components/app/statistics/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import StatisticsShortCutCard from "./StatisticsShortCutCard.svelte";
|
||||
|
||||
export { StatisticsShortCutCard };
|
||||
7
src/lib/components/index.ts
Normal file
7
src/lib/components/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import HelpComponent from './HelpComponent.svelte'
|
||||
import LucideIcon from './LucideIcon.svelte'
|
||||
|
||||
export {
|
||||
HelpComponent,
|
||||
LucideIcon,
|
||||
}
|
||||
40
src/lib/components/screens/ErrorScreen.svelte
Normal file
40
src/lib/components/screens/ErrorScreen.svelte
Normal 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>
|
||||
20
src/lib/components/screens/NotFoundScreen.svelte
Normal file
20
src/lib/components/screens/NotFoundScreen.svelte
Normal 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>
|
||||
6
src/lib/components/screens/index.ts
Normal file
6
src/lib/components/screens/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import ErrorScreen from './ErrorScreen.svelte'
|
||||
import NotFoundScreen from './NotFoundScreen.svelte'
|
||||
|
||||
export {
|
||||
ErrorScreen, NotFoundScreen,
|
||||
}
|
||||
5
src/lib/enums/direction.enum.ts
Normal file
5
src/lib/enums/direction.enum.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum Direction {
|
||||
auto = 'auto',
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
9
src/lib/enums/globalStatistic.enum.ts
Normal file
9
src/lib/enums/globalStatistic.enum.ts
Normal 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
13
src/lib/enums/index.ts
Normal 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,
|
||||
}
|
||||
5
src/lib/enums/level.enum.ts
Normal file
5
src/lib/enums/level.enum.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum Level {
|
||||
info = 'information',
|
||||
warn = 'avertissement',
|
||||
critical = 'critique',
|
||||
}
|
||||
4
src/lib/enums/roles.enum.ts
Normal file
4
src/lib/enums/roles.enum.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum Role {
|
||||
ADMINISTRATOR = 'ADMINISTRATOR',
|
||||
PUBLISHER = 'PUBLISHER',
|
||||
}
|
||||
5
src/lib/enums/validity.enum.ts
Normal file
5
src/lib/enums/validity.enum.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export enum Validity {
|
||||
unknown,
|
||||
valid,
|
||||
invalid,
|
||||
}
|
||||
9
src/lib/models/Brand.type.ts
Normal file
9
src/lib/models/Brand.type.ts
Normal 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[]
|
||||
}
|
||||
9
src/lib/models/Catalog.type.ts
Normal file
9
src/lib/models/Catalog.type.ts
Normal 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
Loading…
Add table
Reference in a new issue