├── screenshot ├── mobil-show-modal.png ├── desktop-show-modal.png ├── mobil-show-btn-modal.png ├── desktop-show-btn-modal.png ├── mobil-show-modal-open-closed.png ├── desktop-show-modal-open-closed.png ├── desktop-show-modal-open-seleted.png └── mobil-show-modal-open-seleted.png ├── src ├── lang │ ├── en.json │ ├── it.json │ ├── pt.json │ ├── tr.json │ ├── de.json │ ├── es.json │ └── fr.json └── js │ ├── index.js │ ├── events.js │ ├── style.js │ ├── preload.js │ ├── props.js │ ├── ui-elements.js │ └── functions.js ├── webpack.config.js ├── jsdoc.json ├── package.json ├── LICENSE ├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md └── main.js /screenshot/mobil-show-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/mobil-show-modal.png -------------------------------------------------------------------------------- /screenshot/desktop-show-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/desktop-show-modal.png -------------------------------------------------------------------------------- /screenshot/mobil-show-btn-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/mobil-show-btn-modal.png -------------------------------------------------------------------------------- /screenshot/desktop-show-btn-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/desktop-show-btn-modal.png -------------------------------------------------------------------------------- /screenshot/mobil-show-modal-open-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/mobil-show-modal-open-closed.png -------------------------------------------------------------------------------- /screenshot/desktop-show-modal-open-closed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/desktop-show-modal-open-closed.png -------------------------------------------------------------------------------- /screenshot/desktop-show-modal-open-seleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/desktop-show-modal-open-seleted.png -------------------------------------------------------------------------------- /screenshot/mobil-show-modal-open-seleted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kalibrado/jf-avatars/HEAD/screenshot/mobil-show-modal-open-seleted.png -------------------------------------------------------------------------------- /src/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Select your avatar", 3 | "search-label": "Search for an avatar...", 4 | "btn-cancel": "Cancel", 5 | "btn-validate": "Validate", 6 | "btn-show": "More Avatars", 7 | "filter-label": "Filter by category", 8 | "default-option": "All" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Seleziona il tuo avatar", 3 | "search-label": "Cerca un avatar...", 4 | "btn-cancel": "Annulla", 5 | "btn-validate": "Convalida", 6 | "btn-show": "Altri avatar", 7 | "filter-label": "Filtra per categoria", 8 | "default-option": "Tutti" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Selecione seu avatar", 3 | "btn-cancel": "Cancelar", 4 | "btn-validate": "Validar", 5 | "btn-show": "Mais avatares", 6 | "search-label": "Pesquise um avatar...", 7 | "filter-label": "Filtrar por categoria", 8 | "default-option": "Todos" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Avatarınızı seçin", 3 | "search-label": "Bir avatar arayın...", 4 | "btn-cancel": "İptal", 5 | "btn-validate": "Onayla", 6 | "btn-show": "Daha fazla avatar", 7 | "filter-label": "Kategorilere göre filtrele", 8 | "default-option": "Hepsi" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Wähle deinen Avatar aus", 3 | "search-label": "Suche nach einem Avatar...", 4 | "btn-cancel": "Abbrechen", 5 | "btn-validate": "Bestätigen", 6 | "btn-show": "Mehr Avatare", 7 | "filter-label": "Nach Kategorie filtern", 8 | "default-option": "Alle" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Selecciona tu avatar", 3 | "search-label": "Busca un avatar...", 4 | "btn-cancel": "Cancelar", 5 | "btn-validate": "Validar", 6 | "btn-show": "Más avatares", 7 | "filter-label": "Filtrar por categoría", 8 | "default-option": "Todos" 9 | } 10 | -------------------------------------------------------------------------------- /src/lang/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Sélectionnez votre avatar", 3 | "search-label": "Recherchez un avatar...", 4 | "btn-cancel": "Annuler", 5 | "btn-validate": "Valider", 6 | "btn-show": "Plus d'avatars", 7 | "filter-label": "Filtrer par catégorie", 8 | "default-option": "Tous" 9 | 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | entry: "./src/js/index.js", 5 | output: { 6 | filename: "main.js", 7 | path: path.resolve(__dirname, "dist"), 8 | clean: true, 9 | }, 10 | mode: "production", 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: "babel-loader", 18 | options: { 19 | presets: ["@babel/preset-env"], 20 | }, 21 | }, 22 | }, 23 | ], 24 | }, 25 | resolve: { 26 | extensions: [".js"], 27 | }, 28 | performance: { 29 | hints: false, 30 | }, 31 | optimization: { 32 | minimize: true 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true, 4 | "dictionaries": [ 5 | "jsdoc", 6 | "closure" 7 | ] 8 | }, 9 | "sourceType": "module", 10 | "source": { 11 | "include": [ 12 | "./src/js", 13 | "package.json", 14 | "README.md" 15 | ], 16 | "includePattern": ".js$", 17 | "excludePattern": "(node_modules/|docs)" 18 | }, 19 | "plugins": [ 20 | "plugins/markdown" 21 | ], 22 | "templates": { 23 | "cleverLinks": true, 24 | "monospaceLinks": true, 25 | "systemName": "jf-avatars", 26 | "footer": "© 2025 Kalibrado", 27 | "navType": "vertical", 28 | "theme": "default", 29 | "linenums": true, 30 | "highlighting": "monochrome" 31 | }, 32 | "opts": { 33 | "destination": "./docs/", 34 | "encoding": "utf8", 35 | "private": true, 36 | "recurse": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jf-avatars", 3 | "version": "2.3.3", 4 | "main": "webpack.config.js", 5 | "scripts": { 6 | "build": "webpack", 7 | "docs": "./node_modules/jsdoc/jsdoc.js -c ./jsdoc.json -r ./src ", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "kalibrado", 11 | "license": "MIT", 12 | "repository": "https://github.com/kalibrado/jf-avatars", 13 | "description": "Le projet jf-avatars est une application JavaScript qui permet de sélectionner des avatars depuis une galerie d'images dans un environnement compatible avec Jellyfin. Cette fonctionnalité utilise une modal personnalisée pour offrir à l'utilisateur une interface visuelle permettant de parcourir, filtrer et choisir une image de profil.", 14 | "devDependencies": { 15 | "@babel/core": "^7.25.8", 16 | "@babel/preset-env": "^7.25.8", 17 | "@pixi/jsdoc-template": "^2.6.0", 18 | "babel-loader": "^9.2.1", 19 | "jsdoc": "^4.0.3", 20 | "webpack-cli": "^5.1.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 BlueDragon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | workflow_dispatch: 7 | concurrency: 8 | group: "pages" 9 | cancel-in-progress: false 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | # Ajout de l'environnement ici 14 | environment: 15 | name: github-pages 16 | url: ${{ steps.deployment.outputs.page_url }} 17 | permissions: 18 | contents: write 19 | pages: write 20 | id-token: write 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | - name: Install node modules 25 | run: npm install 26 | - name: Set tag name 27 | id: tag_name 28 | run: echo "TAG_NAME=${{ github.ref == 'refs/heads/main' && github.event_name == 'pull_request' && 'latest' || github.ref_name }}" >> $GITHUB_ENV 29 | - name: Update version in package.json 30 | run: | 31 | VERSION=${{ env.TAG_NAME }} 32 | echo "Updating version in package.json to $VERSION" 33 | npm version $VERSION --allow-same-version --no-git-tag-version 34 | - name: Build the project 35 | run: npm run build 36 | - name: Generate documentation 37 | run: npm run docs 38 | - name: Create GitHub Release 39 | uses: softprops/action-gh-release@v1 40 | with: 41 | tag_name: ${{ env.TAG_NAME }} 42 | generate_release_notes: true 43 | files: ./dist/main.js 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} 46 | - name: Upload documentation to GitHub Pages 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | path: "./docs/jf-avatars/${{ env.TAG_NAME }}" 50 | - name: Deploy to GitHub Pages 51 | id: deployment 52 | uses: actions/deploy-pages@v4 53 | 54 | - name: Commit changes 55 | run: | 56 | git config --global user.name 'GitHub Actions' 57 | git config --global user.email 'actions@github.com' 58 | git add package.json 59 | git commit -m "Update version to ${{ env.TAG_NAME }}" 60 | - name: Push changes 61 | uses: ad-m/github-push-action@v0.6.0 62 | with: 63 | github_token: ${{ secrets.PAT_TOKEN }} 64 | branch: refs/heads/main 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | yarn.lock 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional stylelint cache 59 | .stylelintcache 60 | 61 | # Microbundle cache 62 | .rpt2_cache/ 63 | .rts2_cache_cjs/ 64 | .rts2_cache_es/ 65 | .rts2_cache_umd/ 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variable files 77 | .env 78 | .env.development.local 79 | .env.test.local 80 | .env.production.local 81 | .env.local 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # vuepress v2.x temp and cache directory 105 | .temp 106 | .cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # TernJS port file 121 | .tern-port 122 | 123 | # Stores VSCode versions used for testing VSCode extensions 124 | .vscode-test 125 | 126 | # yarn v2 127 | .yarn/cache 128 | .yarn/unplugged 129 | .yarn/build-state.yml 130 | .yarn/install-state.gz 131 | .pnp.* 132 | 133 | docs/ 134 | package-lock.json 135 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import { loadLanguage, waitForElement, log } from "./functions.js"; 2 | import { injectStyles } from "./style.js"; 3 | import { props } from "./props.js"; 4 | import { createButton, createModal } from "./ui-elements.js"; 5 | 6 | /** 7 | * Observes DOM changes and injects UI components dynamically when necessary. 8 | * Specifically targets the "userprofile" page for integrating the avatar suggestion feature in Jellyfin. 9 | * 10 | * @module index 11 | */ 12 | 13 | const addProfileButton = () => { 14 | log("Attempting to add button"); 15 | // Reinject the button if it's missing from the DOM 16 | if (!document.getElementById(`${props.prefix}-btn-show-modal`)) { 17 | waitForElement(props.injectBtnModal(), () => { 18 | injectStyles(); 19 | document.querySelector(props.injectBtnModal()).before( 20 | createButton({ 21 | id: "show-modal", 22 | textContent: props.getBtnShowAvatarsLabel(), 23 | onClick: () => createModal(), 24 | }) 25 | ); 26 | 27 | // Disconnect the observer once the button is injected 28 | log("Button injected"); 29 | }); 30 | } else { 31 | // The button already exists, no need to continue observation 32 | log("Button already exists"); 33 | } 34 | }; 35 | 36 | /** 37 | * Observes DOM changes (useful for Single Page Applications - SPA). 38 | * When an element is added or recreated, this function dynamically reinjects the "show-modal" button. 39 | * 40 | * @function observeDOMChanges 41 | * @returns {void} 42 | */ 43 | const observeDOMChanges = () => { 44 | log("Attempting to observe dom changes"); 45 | const targetNode = document.body; // Observe the entire body to capture relevant changes. 46 | const config = { childList: true, subtree: true }; // Observe added/removed elements. 47 | 48 | /** 49 | * Callback function for handling DOM mutations. 50 | * 51 | * @callback MutationCallback 52 | * @param {MutationRecord[]} mutationsList - List of observed mutations. 53 | * @returns {void} 54 | */ 55 | const callback = (mutationsList) => { 56 | for (let mutation of mutationsList) { 57 | if (mutation.type === "childList") { 58 | if (window.location.hash.includes("#/userprofile")) { 59 | addProfileButton() 60 | observer.disconnect(); 61 | } 62 | } 63 | } 64 | }; 65 | 66 | log("Navigation to userprofile detected."); 67 | loadLanguage().then(() => { 68 | if (window.location.hash.includes("#/userprofile")) { // If we loaded directly into the profile page we may execute after all DOM mutations 69 | addProfileButton(); 70 | return; 71 | } 72 | 73 | // Create a MutationObserver instance 74 | const observer = new MutationObserver(callback); 75 | observer.observe(targetNode, config); // Start observing 76 | }); 77 | }; 78 | 79 | /** 80 | * MutationObserver to monitor DOM changes for page-specific elements. 81 | * Specifically checks for the addition of the branding CSS to initialize avatar modal functionality. 82 | * 83 | * @constant observer 84 | * @type {MutationObserver} 85 | */ 86 | const observer = new MutationObserver((mutations) => { 87 | mutations.forEach((mutation) => { 88 | mutation.addedNodes.forEach((node) => { 89 | if (node.id === "cssBranding" || node.textContent === "Profile") { 90 | observeDOMChanges(); 91 | observer.disconnect(); // Stop observing once the tag is found 92 | } 93 | }); 94 | }); 95 | }); 96 | 97 | observeDOMChanges(); // If we loaded directly into the profile page we may execute after all DOM mutations 98 | observer.observe(document.body, { childList: true, subtree: true }); 99 | -------------------------------------------------------------------------------- /src/js/events.js: -------------------------------------------------------------------------------- 1 | import { 2 | addImagesToGrid, 3 | log, 4 | loadSrcImages, 5 | isInViewport, 6 | } from "./functions.js"; 7 | import { loadImageWithPriority, preloadImportantImages } from "./preload.js"; 8 | import { props } from "./props.js"; 9 | import { adjustResponsive } from "./style.js"; 10 | import { showRippleLoader } from "./ui-elements.js"; 11 | 12 | /** 13 | * Lazy load images using IntersectionObserver. 14 | */ 15 | const observer = new IntersectionObserver((entries, observer) => { 16 | entries.forEach((entry) => { 17 | if (entry.isIntersecting) { 18 | const img = entry.target; 19 | const actualSrc = img.getAttribute("data-src"); 20 | loadImageWithPriority(img, actualSrc); 21 | observer.unobserve(img); 22 | } 23 | }); 24 | }); 25 | 26 | let lazyImages = () => document.querySelectorAll(".lazy-image"); 27 | 28 | /** 29 | * Filter and display images based on search input and category selection. 30 | * @param {Event} event - Input event. 31 | */ 32 | export const applySearchAndFilters = async (event) => { 33 | let searchTerm = event.target?.value?.toLowerCase(); 34 | let selectedCategory = event.target?.value; 35 | 36 | // Determine if this is a search input or dropdown change 37 | const isSearchInput = event.target?.id?.includes('search-input'); 38 | const isDropdown = event.target?.id?.includes('dropdown-select-filter'); 39 | 40 | // Get current values from both inputs 41 | const searchInput = document.querySelector(`#${props.prefix}-search-input`); 42 | const dropdownSelect = document.querySelector(`#${props.prefix}-dropdown-select-filter`); 43 | 44 | const currentSearchTerm = searchInput?.value?.toLowerCase() || ""; 45 | const currentCategory = dropdownSelect?.value || ""; 46 | 47 | showRippleLoader(); 48 | 49 | const allSrcImages = (await loadSrcImages()) || []; 50 | 51 | let filteredSrcImages = allSrcImages; 52 | 53 | // Apply category filter first 54 | if (currentCategory && currentCategory !== props.getDefaultOptionLabel()) { 55 | filteredSrcImages = filteredSrcImages.filter((img) => { 56 | const category = img.category || img.folder || ""; 57 | return category.toLowerCase().includes(currentCategory.toLowerCase()); 58 | }); 59 | } 60 | 61 | // Apply search filter 62 | if (currentSearchTerm && currentSearchTerm.trim() !== "") { 63 | filteredSrcImages = filteredSrcImages.filter((img) => { 64 | const url = img.url || ""; 65 | const name = img.name || ""; 66 | log("Search term:", currentSearchTerm); 67 | log("Image:", img); 68 | return url.toLowerCase().includes(currentSearchTerm) || 69 | name.toLowerCase().includes(currentSearchTerm); 70 | }); 71 | } 72 | 73 | const imgGrid = document.querySelector(`#${props.prefix}-grid-container`); 74 | 75 | if (filteredSrcImages.length > 0) { 76 | preloadImportantImages(filteredSrcImages); 77 | if (imgGrid) { 78 | addImagesToGrid(filteredSrcImages, imgGrid); 79 | lazyImages().forEach((img) => { 80 | if (isInViewport(img)) { 81 | const actualSrc = img.getAttribute("data-src"); 82 | loadImageWithPriority(img, actualSrc); 83 | } else { 84 | observer.observe(img); 85 | } 86 | }); 87 | } 88 | } else { 89 | addImagesToGrid(props.avatarUrls(currentSearchTerm), imgGrid); 90 | lazyImages().forEach((img) => { 91 | if (isInViewport(img)) { 92 | const actualSrc = img.getAttribute("data-src"); 93 | loadImageWithPriority(img, actualSrc); 94 | } else { 95 | observer.observe(img); 96 | } 97 | }); 98 | } 99 | }; 100 | 101 | /** 102 | * Setup event listeners for filtering and image responsiveness. 103 | */ 104 | export const eventListener = () => { 105 | const searchBar = document.querySelector(`#${props.prefix}-search-input`); 106 | const dropdown = document.querySelector( 107 | `#${props.prefix}-dropdown-select-filter` 108 | ); 109 | 110 | searchBar.addEventListener("input", applySearchAndFilters); 111 | dropdown.addEventListener("change", applySearchAndFilters); 112 | window.addEventListener("resize", adjustResponsive); 113 | 114 | lazyImages().forEach((img) => { 115 | if (isInViewport(img)) { 116 | const actualSrc = img.getAttribute("data-src"); 117 | loadImageWithPriority(img, actualSrc); 118 | } else { 119 | observer.observe(img); 120 | } 121 | }); 122 | }; 123 | -------------------------------------------------------------------------------- /src/js/style.js: -------------------------------------------------------------------------------- 1 | import { props } from "./props.js"; 2 | 3 | /** 4 | * Sets the CSS properties of a given element. 5 | * 6 | * @param {HTMLElement} element - The element to apply the styles to. 7 | * @param {Object} styles - An object containing key-value pairs of CSS properties. 8 | * @returns {void} 9 | * @description This function dynamically applies CSS styles to an element. 10 | */ 11 | export const setCssProperties = (element, styles) => { 12 | for (let property in styles) { 13 | if (styles.hasOwnProperty(property)) { 14 | element.style[property] = styles[property]; 15 | } 16 | } 17 | }; 18 | 19 | /** 20 | * @constant {string} rippleStyle - CSS styles for the "ripple" effect. 21 | * @description Defines the CSS styles used for the "ripple" effect applied to elements. 22 | */ 23 | export const rippleStyle = ` 24 | .lds-ripple, .lds-ripple div { 25 | box-sizing: border-box; 26 | } 27 | .lds-ripple { 28 | display: inline-block; 29 | position: relative; 30 | width: 80px; 31 | height: 80px; 32 | } 33 | .lds-ripple div { 34 | position: absolute; 35 | border: 4px solid currentColor; 36 | opacity: 1; 37 | border-radius: 50%; 38 | animation: lds-ripple 1s cubic-bezier(0, 0.2, 0.8, 1) infinite; 39 | } 40 | .lds-ripple div:nth-child(2) { 41 | animation-delay: -0.5s; 42 | } 43 | @keyframes lds-ripple { 44 | 0% { top: 36px; left: 36px; width: 8px; height: 8px; opacity: 0; } 45 | 5% { top: 36px; left: 36px; width: 8px; height: 8px; opacity: 1; } 46 | 100% { top: 0; left: 0; width: 80px; height: 80px; opacity: 0; } 47 | } 48 | @keyframes blink { 49 | from { transform: scale(0.1); opacity: 1;} 50 | to { transform: scale(1); opacity: 0;} 51 | } 52 | 53 | .blink { 54 | animation: blink 1s infinite; 55 | } 56 | `; 57 | 58 | /** 59 | * Adjusts the layout of UI elements based on the window size. 60 | * 61 | * @function 62 | * @description Applies specific styles for mobile, tablet, and desktop layouts. 63 | * It targets elements such as the footer, search input, and grid container. 64 | * @returns {void} 65 | */ 66 | export const adjustResponsive = () => { 67 | const footer = document.getElementById(`${props.prefix}-footer-container`); 68 | const searchInput = document.getElementById( 69 | `${props.prefix}-search-container` 70 | ); 71 | 72 | const gridContainer = document.getElementById( 73 | `${props.prefix}-grid-container` 74 | ); 75 | 76 | const footerLeft = document.getElementById(`${props.prefix}-footer-left`); 77 | const footerRight = document.getElementById(`${props.prefix}-footer-right`); 78 | 79 | const windowWidth = window.innerWidth; 80 | 81 | if (windowWidth <= 500) { 82 | // Mobile styles 83 | setCssProperties(footer, { 84 | flexDirection: "column", 85 | alignItems: "center", 86 | }); 87 | setCssProperties(gridContainer, { 88 | maxHeight: "45vh", 89 | }); 90 | setCssProperties(searchInput, { 91 | width: "100%", 92 | }); 93 | 94 | setCssProperties(footerLeft, { 95 | flexDirection: "column", 96 | }); 97 | setCssProperties(footerRight, { 98 | justifyContent: "center", 99 | }); 100 | } else if (windowWidth <= 1024) { 101 | // Tablet styles 102 | setCssProperties(footer, { 103 | flexDirection: "row", 104 | alignItems: "center", 105 | justifyContent: "space-between", 106 | }); 107 | setCssProperties(gridContainer, { 108 | maxHeight: "60vh", 109 | }); 110 | setCssProperties(searchInput, { 111 | width: "100%", 112 | }); 113 | setCssProperties(footerLeft, { 114 | width: "100%", 115 | display: "flex", 116 | justifyContent: "center", 117 | flexDirection: "column", 118 | alignItems: "flex-start", 119 | }); 120 | setCssProperties(footerRight, { 121 | display: "flex", 122 | justifyContent: "right", 123 | alignItems: "center", 124 | flexDirection: "column", 125 | }); 126 | } else { 127 | // Desktop styles 128 | setCssProperties(footer, { 129 | marginTop: "1em", 130 | display: "inline-flex", 131 | flexDirection: "row", 132 | gap: "10px", 133 | justifyContent: "space-between", 134 | width: "100%", 135 | }); 136 | 137 | setCssProperties(footerLeft, { 138 | width: "100%", 139 | display: "flex", 140 | justifyContent: "space-between", 141 | flexDirection: "row", 142 | alignItems: "center", 143 | }); 144 | 145 | setCssProperties(footerRight, { 146 | display: "flex", 147 | width: "fit-content", 148 | justifyContent: "space-between", 149 | alignItems: "center", 150 | flexDirection: "row", 151 | }); 152 | 153 | setCssProperties(gridContainer, { 154 | maxHeight: "60vh", 155 | }); 156 | setCssProperties(searchInput, { 157 | width: "45%", 158 | }); 159 | } 160 | }; 161 | 162 | /** 163 | * Injects the defined CSS styles into the document. 164 | * 165 | * @function 166 | * @description Creates a