├── packages ├── app │ ├── .gitignore │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── icon-32x32.png │ │ ├── apple-192x192.png │ │ ├── icon-192x192.png │ │ ├── icon-512x512.png │ │ ├── social-share.png │ │ ├── manifest.json │ │ └── index.html │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── index.css │ │ ├── icons │ │ │ ├── trash.svg │ │ │ ├── ban.svg │ │ │ ├── download.svg │ │ │ ├── random.svg │ │ │ └── byzenika.svg │ │ ├── index.tsx │ │ ├── components │ │ │ ├── UpdateApp.tsx │ │ │ ├── Notification.tsx │ │ │ ├── AssetButton.module.css │ │ │ ├── AssetButton.tsx │ │ │ └── Notification.module.css │ │ ├── duck.ts │ │ ├── useAssets.ts │ │ ├── useServiceWorker.tsx │ │ ├── App.module.css │ │ └── App.tsx │ ├── README.md │ ├── tsconfig.json │ └── package.json ├── functions │ ├── .gitignore │ ├── package.json │ ├── svg │ │ ├── svg.js │ │ ├── svgBuilder.js │ │ └── svgBuilder.test.js │ ├── __mocks__ │ │ └── fs.js │ └── README.md └── assets │ ├── .gitignore │ ├── design │ ├── hero.png │ ├── my-zenikanard.afdesign │ └── icons-my-zenikanard.afdesign │ ├── src │ ├── shapes │ │ ├── floor.svg │ │ ├── hair-4.svg │ │ ├── body-tatoo-1.svg │ │ ├── head.svg │ │ ├── hat-1.svg │ │ ├── eye-1.svg │ │ ├── eye-4.svg │ │ ├── hat-3.svg │ │ ├── mouth-3.svg │ │ ├── hat-6.svg │ │ ├── glasses-4.svg │ │ ├── eye-3.svg │ │ ├── mouth-2.svg │ │ ├── glasses-3.svg │ │ ├── hat-7.svg │ │ ├── face-4.svg │ │ ├── wear-3.svg │ │ ├── body.svg │ │ ├── wear-2.svg │ │ ├── glasses-5.svg │ │ ├── hair-3.svg │ │ ├── hait-3.svg │ │ ├── hair-1.svg │ │ ├── eye-2.svg │ │ ├── mouth-4.svg │ │ ├── hat-2.svg │ │ ├── hat-9.svg │ │ ├── hat-4.svg │ │ ├── wear-1.svg │ │ ├── hair-5.svg │ │ ├── hat-8.svg │ │ ├── wear-6.svg │ │ └── wear-5.svg │ ├── icons │ │ ├── floor.svg │ │ ├── hair-4.svg │ │ ├── body-tatoo-1.svg │ │ ├── head.svg │ │ ├── hat-1.svg │ │ ├── eye-1.svg │ │ ├── eye-4.svg │ │ ├── mouth-3.svg │ │ ├── hat-3.svg │ │ ├── hat-6.svg │ │ ├── glasses-4.svg │ │ ├── mouth-2.svg │ │ ├── eye-3.svg │ │ ├── hat-7.svg │ │ ├── glasses-3.svg │ │ ├── face-4.svg │ │ ├── wear-3.svg │ │ ├── body.svg │ │ ├── glasses-5.svg │ │ ├── wear-2.svg │ │ ├── hair-1.svg │ │ ├── eye-2.svg │ │ ├── hair-3.svg │ │ ├── mouth-4.svg │ │ ├── hat-2.svg │ │ ├── hat-9.svg │ │ ├── hat-4.svg │ │ ├── wear-1.svg │ │ ├── hair-5.svg │ │ ├── hat-8.svg │ │ ├── wear-6.svg │ │ ├── wear-5.svg │ │ └── hait-3.svg │ └── layers.json │ ├── package.json │ ├── README.md │ └── generate.js ├── .prettierrc ├── .gitignore ├── .gitpod └── automations.yaml ├── .github ├── workflows │ └── build.yml ├── dependabot.yml ├── CONTRIBUTING.md └── CODE_OF_CONDUCT.md ├── .devcontainer └── devcontainer.json ├── proposals └── README.md ├── package.json └── README.md /packages/app/.gitignore: -------------------------------------------------------------------------------- 1 | src/assets/ -------------------------------------------------------------------------------- /packages/functions/.gitignore: -------------------------------------------------------------------------------- 1 | svg/assets/ -------------------------------------------------------------------------------- /packages/assets/.gitignore: -------------------------------------------------------------------------------- 1 | components/ 2 | index.js -------------------------------------------------------------------------------- /packages/app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /packages/app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module 'save-svg-as-png'; -------------------------------------------------------------------------------- /packages/app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/favicon.ico -------------------------------------------------------------------------------- /packages/assets/design/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/assets/design/hero.png -------------------------------------------------------------------------------- /packages/app/public/icon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/icon-32x32.png -------------------------------------------------------------------------------- /packages/app/public/apple-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/apple-192x192.png -------------------------------------------------------------------------------- /packages/app/public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/icon-192x192.png -------------------------------------------------------------------------------- /packages/app/public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/icon-512x512.png -------------------------------------------------------------------------------- /packages/app/public/social-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/app/public/social-share.png -------------------------------------------------------------------------------- /packages/assets/design/my-zenikanard.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/assets/design/my-zenikanard.afdesign -------------------------------------------------------------------------------- /packages/assets/design/icons-my-zenikanard.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenika-open-source/my-zenikanard/HEAD/packages/assets/design/icons-my-zenikanard.afdesign -------------------------------------------------------------------------------- /packages/assets/src/shapes/floor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/floor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pimpmyduck/functions", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "test": "jest" 7 | }, 8 | "devDependencies": { 9 | "jest": "^30.2.0" 10 | }, 11 | "dependencies": { 12 | "react-scripts": "5.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/functions/svg/svg.js: -------------------------------------------------------------------------------- 1 | const svgBuilder = require('./svgBuilder') 2 | 3 | exports.handler = async (event) => { 4 | const params = event.queryStringParameters 5 | return { 6 | statusCode: 200, 7 | headers: { 8 | 'Content-disposition': 'attachment; filename=zenikanard.svg', 9 | 'Content-Type': 'image/svg+xml', 10 | }, 11 | body: svgBuilder.build(params), 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/app/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Paytone+One&display=swap'); 2 | 3 | body { 4 | margin: 0; 5 | font-family: 'Paytone One'; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | button { 11 | font-family: 'Paytone One'; 12 | } 13 | 14 | *, 15 | :before, 16 | :after { 17 | margin: 0; 18 | box-sizing: border-box; 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | .cache 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.gitpod/automations.yaml: -------------------------------------------------------------------------------- 1 | tasks: 2 | yarn-start: 3 | command: yarn run start 4 | dependsOn: 5 | - install-build 6 | description: yarn-start 7 | name: 'Task 2 : yarn-start' 8 | triggeredBy: 9 | - postDevcontainerStart 10 | install-build: 11 | command: yarn install && yarn run build 12 | description: install-build 13 | name: 'Task 1: install build' -------------------------------------------------------------------------------- /packages/app/README.md: -------------------------------------------------------------------------------- 1 | # App 2 | 3 | The main web app deployed on [Netlify](https://www.netlify.com/). 4 | 5 | - Bootstraped with [`create-react-app`](https://create-react-app.dev/) 6 | - Written with Typescript 7 | 8 | ## Usage 9 | 10 | From the root folder of the monorepo: 11 | 12 | ```sh 13 | yarn install 14 | yarn start 15 | ``` 16 | 17 | ## Build 18 | 19 | From the `packages/app` folder: 20 | 21 | ```sh 22 | yarn build 23 | ``` 24 | -------------------------------------------------------------------------------- /packages/functions/__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | // __mocks__/fs.js 2 | const path = require('path'); 3 | 4 | const fs = jest.createMockFromModule('fs'); 5 | 6 | fs.__mockFiles = {}; 7 | 8 | function readFileSync(filepath) { 9 | return fs.__mockFiles[filepath]; 10 | } 11 | 12 | function existsSync(filepath) { 13 | return !!fs.__mockFiles[filepath]; 14 | } 15 | 16 | fs.readFileSync = readFileSync; 17 | fs.existsSync = existsSync; 18 | 19 | module.exports = fs; -------------------------------------------------------------------------------- /packages/app/src/icons/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import './index.css' 5 | import { ServiceWorkerProvider } from './useServiceWorker' 6 | import * as serviceWorker from './sw' 7 | import App from './App' 8 | 9 | import 'pwacompat' 10 | 11 | serviceWorker.unregisterOnUncatchError() 12 | 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById('root') 18 | ) 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | app: 11 | name: Build and Test App 12 | runs-on: ubuntu-latest 13 | env: 14 | CI: true 15 | steps: 16 | - uses: actions/checkout@v5 17 | - uses: actions/setup-node@v5 18 | with: 19 | node-version: 22 20 | cache: "yarn" 21 | - run: yarn install --frozen-lockfile 22 | - run: yarn test 23 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "My Zenikanard", 3 | "image": "node:22.20-slim", 4 | "features": { 5 | "ghcr.io/devcontainers/features/node:1": { 6 | "version": "lts" 7 | } 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"], 12 | "settings": { 13 | "editor.formatOnSave": true 14 | } 15 | }, 16 | "jetbrains": { 17 | "plugins": ["com.wix.eslint", "intellij.prettierJS"] 18 | } 19 | }, 20 | "forwardPorts": [3000] 21 | } -------------------------------------------------------------------------------- /packages/app/src/icons/ban.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pimpmyduck/assets", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "add:asset": "yarn optimize && yarn generate && yarn copy:app && yarn copy:functions", 7 | "optimize": "npx svgo -r -f ./src", 8 | "generate": "node generate.js", 9 | "copy:app": "cp -R ./src/ ../app/src/assets/", 10 | "copy:functions": "cp -R ./src/ ../functions/svg/assets/", 11 | "postinstall": "yarn generate && yarn copy:app && yarn copy:functions" 12 | }, 13 | "dependencies": { 14 | "lodash": "^4.17.21" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hair-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hair-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/assets/src/layers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "id": "floor" }, 3 | { "id": "body" }, 4 | { "id": "body-tatoo", "name": "Body tatoo", "categoryOrder": 7 }, 5 | { "id": "wear", "name": "Wears", "categoryOrder": 6 }, 6 | { "id": "acc", "name": "Accessories", "categoryOrder": 8 }, 7 | { "id": "head" }, 8 | { "id": "face", "name": "Faces", "categoryOrder": 5 }, 9 | { "id": "eye", "name": "Eyes", "categoryOrder": 0 }, 10 | { "id": "hair", "name": "Hair", "categoryOrder": 2 }, 11 | { "id": "glasses", "name": "Glasses", "categoryOrder": 1 }, 12 | { "id": "mouth", "name": "Mouth", "categoryOrder": 4 }, 13 | { "id": "hat", "name": "Hats", "categoryOrder": 3 } 14 | ] 15 | -------------------------------------------------------------------------------- /packages/app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pimp My Duck", 3 | "short_name": "Pimp My Duck", 4 | "description": "Customize your own Zenikanard by Zenika", 5 | "start_url": ".", 6 | "theme_color": "#333333", 7 | "background_color": "#ffffff", 8 | "display": "standalone", 9 | "icons": [ 10 | { 11 | "src": "icon-32x32.png", 12 | "sizes": "32x32", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "icon-192x192.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "icon-512x512.png", 22 | "sizes": "512x512", 23 | "type": "image/png" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/app/src/components/UpdateApp.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { useServiceWorker } from '../useServiceWorker' 4 | import Notification from './Notification' 5 | 6 | const UpdateApp = () => { 7 | const { isRegisterSucceed, isUpdateAvailable, update } = useServiceWorker() 8 | 9 | return ( 10 | <> 11 | {isRegisterSucceed && ( 12 | "Pimp my duck" works offline! 13 | )} 14 | {isUpdateAvailable && ( 15 | 16 | A new version of "Pimp my duck" is available! 17 | 18 | )} 19 | 20 | ) 21 | } 22 | 23 | export default UpdateApp 24 | -------------------------------------------------------------------------------- /proposals/README.md: -------------------------------------------------------------------------------- 1 | # How to propose an asset to "Pimp My Duck" ? 2 | 3 | To propose a new asset you have to create a PR with : 4 | - The asset as SVG file into the `/proposals` folder 5 | - Give the asset name and category into the PR description 6 | 7 | Here is the category list: 8 | - Eyes 9 | - Glasses 10 | - Hair 11 | - Hats 12 | - Mouth 13 | - Faces 14 | - Wears 15 | - Body tatoo 16 | - Accessories 17 | 18 | The asset can be built using the [Zenikanard template](./zenikanard_template.svg) SVG file as reference. 19 | 20 | The final asset SVG file must NOT contain the Zenikanard, it's just a template to help the design. 21 | 22 | After the PR, reviewed and merged. A maintainer will integrate it into "Pimp My Duck". 23 | -------------------------------------------------------------------------------- /packages/functions/README.md: -------------------------------------------------------------------------------- 1 | # Functions 2 | 3 | Endpoints deployed as [Netlify functions](https://www.netlify.com/products/functions/). 4 | 5 | ## Generate SVG File 6 | 7 | ### Endpoint 8 | 9 | ``` 10 | GET /.netlify/functions/svg 11 | ``` 12 | 13 | Returns the SVG file with response: 14 | 15 | ``` 16 | statusCode: 200 17 | headers: { 18 | 'Content-disposition': 'attachment; filename=zenikanard.svg', 19 | 'Content-Type': 'image/svg+xml', 20 | } 21 | ``` 22 | 23 | ### Query parameters 24 | 25 | - Parameter `keys` are `layer ids`. 26 | - Parameter `values` are `asset ids`. 27 | 28 | **Example:** 29 | 30 | ``` 31 | curl https://pimpmyduck.zenika.com/.netlify/functions/svg?body-tatoo=body-tatoo-1&eye=eye-1&mouth=mouth-1&hair=hair-1 32 | ``` 33 | -------------------------------------------------------------------------------- /packages/app/src/icons/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/functions/svg/svgBuilder.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const layers = require('./assets/layers.json') 4 | 5 | function build(params) { 6 | let svg = '' 7 | layers.forEach((layer) => { 8 | // body and head layers 9 | if (layer.id === 'body' || layer.id === 'head') { 10 | const asset = fs.readFileSync(`./assets/shapes/${layer.id}.svg`, 'utf8') 11 | svg += asset 12 | } 13 | // user layers 14 | if (params) { 15 | const path = `./assets/shapes/${params[layer.id]}.svg` 16 | if (fs.existsSync(path)) { 17 | const asset = fs.readFileSync(path, 'utf8') 18 | svg += asset 19 | } 20 | } 21 | }) 22 | 23 | return ` 24 | 25 | ${svg} 26 | ` 27 | } 28 | 29 | module.exports = { build } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pimpmyduck", 3 | "version": "1.0.0", 4 | "description": "Customize your own Zenikanard by Zenika", 5 | "author": "Benjamin Petetot", 6 | "license": "Apache-2.0", 7 | "homepage": "https://pimpmyduck.zenika.com", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/zenika-open-source/my-zenikanard.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/zenika-open-source/my-zenikanard/issues" 14 | }, 15 | "private": true, 16 | "workspaces": [ 17 | "packages/*" 18 | ], 19 | "scripts": { 20 | "start": "yarn workspace @pimpmyduck/app start", 21 | "build": "yarn workspace @pimpmyduck/app build", 22 | "test": "yarn test:functions", 23 | "test:functions": "yarn workspace @pimpmyduck/functions test" 24 | }, 25 | "engines": { 26 | "yarn": ">=1.22.0", 27 | "node": ">=22.0.0" 28 | } 29 | } -------------------------------------------------------------------------------- /packages/assets/src/shapes/body-tatoo-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/icons/random.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/body-tatoo-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/head.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/head.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/components/Notification.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import cn from 'classnames' 3 | 4 | import styles from './Notification.module.css' 5 | 6 | type NotificationProps = { 7 | actionLabel?: string 8 | onActionClick?: () => void 9 | } 10 | 11 | const Notification: FC = ({ 12 | children, 13 | actionLabel = 'OK', 14 | onActionClick, 15 | }) => { 16 | const [open, setOpen] = useState(true) 17 | 18 | const handleAction = () => { 19 | if (onActionClick) onActionClick() 20 | setOpen(false) 21 | } 22 | 23 | if (!open) return null 24 | 25 | return ( 26 |
27 |

{children}

28 | 36 |
37 | ) 38 | } 39 | 40 | export default Notification 41 | -------------------------------------------------------------------------------- /packages/app/src/components/AssetButton.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | border: 3px solid gainsboro; 3 | background: gainsboro; 4 | border-radius: 5px; 5 | font-size: 1.4rem; 6 | font-weight: 400; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | width: 70px; 11 | height: 70px; 12 | cursor: pointer; 13 | transition: background-color 100ms ease; 14 | } 15 | 16 | .button:hover:not(.selected) { 17 | background-color: rgb(210, 210, 210); 18 | transition: none; 19 | } 20 | 21 | .button:focus { 22 | outline: none; 23 | } 24 | 25 | .button:active:not(.selected) { 26 | background-color: rgb(190, 190, 190); 27 | } 28 | 29 | .button > svg { 30 | width: 60px; 31 | height: 60px; 32 | } 33 | 34 | .icon { 35 | display: block; 36 | } 37 | 38 | .selected { 39 | border: 3px solid #b51432; 40 | } 41 | 42 | @media (min-width: 800px) { 43 | .button { 44 | margin-bottom: 10px; 45 | } 46 | } 47 | 48 | @media (max-width: 800px) { 49 | .button { 50 | font-size: 1.5rem; 51 | margin-right: 10px; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/app/src/duck.ts: -------------------------------------------------------------------------------- 1 | import { assetNames } from './assets' 2 | import layersData from './assets/layers.json' 3 | 4 | export type SelectedAssets = { 5 | [key: string]: string | undefined 6 | } 7 | 8 | export type Layer = { 9 | id: string 10 | name?: string 11 | categoryOrder?: number 12 | } 13 | 14 | export const DEFAULT_ASSETS: SelectedAssets = { 15 | 'body-tatoo': 'body-tatoo-1', 16 | eye: 'eye-1', 17 | mouth: 'mouth-1', 18 | hair: 'hair-1', 19 | } 20 | 21 | export const getCategoryLayers = () => { 22 | return layers 23 | .filter((layer) => !!layer.name) 24 | .sort((a, b) => { 25 | return (a?.categoryOrder ?? 0) - (b?.categoryOrder ?? 0) 26 | }) 27 | } 28 | 29 | export const getDefaultLayer = () => { 30 | return getCategoryLayers()[0] 31 | } 32 | 33 | export const getLayerAssets = (layerId: string) => { 34 | const layerAssets: string[] = [] 35 | assetNames.forEach((name) => { 36 | if (name.startsWith(layerId)) { 37 | layerAssets.push(name) 38 | } 39 | }) 40 | return layerAssets 41 | } 42 | 43 | export const layers = layersData as Layer[] 44 | -------------------------------------------------------------------------------- /packages/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pimpmyduck/app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "start": "react-scripts start", 8 | "build": "react-scripts build" 9 | }, 10 | "dependencies": { 11 | "classnames": "^2.2.6", 12 | "pwacompat": "^2.0.17", 13 | "query-string": "^6.13.6", 14 | "react": "^17.0.1", 15 | "react-dom": "^17.0.1", 16 | "save-svg-as-png": "^1.4.17" 17 | }, 18 | "devDependencies": { 19 | "@types/classnames": "^2.2.10", 20 | "@types/jest": "^26.0.15", 21 | "@types/node": "^14.14.2", 22 | "@types/react": "^16.9.53", 23 | "@types/react-dom": "^16.9.8", 24 | "react-scripts": "^5.0.1", 25 | "typescript": "^4.0.3" 26 | }, 27 | "eslintConfig": { 28 | "extends": "react-app" 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/app" 5 | target-branch: "master" 6 | schedule: 7 | interval: "monthly" 8 | labels: 9 | - "dependencies" 10 | - "automated pr" 11 | groups: 12 | app-packages: 13 | patterns: 14 | - "*" 15 | update-types: 16 | - "minor" 17 | - "patch" 18 | - package-ecosystem: "npm" 19 | directory: "/assets" 20 | target-branch: "master" 21 | schedule: 22 | interval: "monthly" 23 | labels: 24 | - "dependencies" 25 | - "automated pr" 26 | groups: 27 | assets-packages: 28 | patterns: 29 | - "*" 30 | update-types: 31 | - "minor" 32 | - "patch" 33 | - package-ecosystem: "npm" 34 | directory: "/functions" 35 | target-branch: "master" 36 | schedule: 37 | interval: "monthly" 38 | labels: 39 | - "dependencies" 40 | - "automated pr" 41 | groups: 42 | functions-packages: 43 | patterns: 44 | - "*" 45 | update-types: 46 | - "minor" 47 | - "patch" -------------------------------------------------------------------------------- /packages/app/src/components/AssetButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Suspense } from 'react' 2 | import cn from 'classnames' 3 | import { Layer } from '../duck' 4 | import { getIcon } from '../assets' 5 | 6 | import { ReactComponent as Ban } from '../icons/ban.svg' 7 | import styles from './AssetButton.module.css' 8 | 9 | type AssetButtonProps = { 10 | assetName?: string 11 | layer: Layer 12 | onClick: (layer: Layer, assetName: string | undefined) => void 13 | selected: boolean 14 | } 15 | 16 | const AssetButton: FC = ({ 17 | layer, 18 | assetName, 19 | onClick, 20 | selected, 21 | }) => { 22 | const Icon = getIcon(assetName) 23 | return ( 24 | 37 | ) 38 | } 39 | 40 | export default AssetButton 41 | -------------------------------------------------------------------------------- /packages/assets/src/icons/eye-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/eye-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/eye-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/eye-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/components/Notification.module.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | background: #fff; 3 | max-width: 500px; 4 | min-width: 300px; 5 | padding: 1rem; 6 | color: #333333; 7 | border-radius: 5px; 8 | box-shadow: 0 0 14px rgba(0, 0, 0, 0.24); 9 | position: absolute; 10 | bottom: 1rem; 11 | right: 1rem; 12 | transition: 0.3s; 13 | display: flex; 14 | align-items: center; 15 | } 16 | 17 | @media (max-width: 800px) { 18 | .notification { 19 | width: 100%; 20 | right: 0; 21 | bottom: 110px; 22 | border-radius: 0px; 23 | flex-wrap: nowrap; 24 | flex-direction: row; 25 | } 26 | 27 | .notification > p { 28 | flex-grow: 1; 29 | } 30 | } 31 | 32 | .button { 33 | background: transparent; 34 | padding: 0.4rem 1rem; 35 | border: none; 36 | border-radius: 2px; 37 | outline: none; 38 | text-transform: uppercase; 39 | font-size: 0.8rem; 40 | white-space: nowrap; 41 | cursor: pointer; 42 | transition: 0.2s; 43 | } 44 | 45 | .primary { 46 | background: #b51432; 47 | color: #fff; 48 | box-shadow: 0 0 2px rgba(#b51432, 0.12), 0 2px 4px rgba(0, 0, 0, 0.24); 49 | margin-left: 1rem; 50 | } 51 | 52 | .primary:hover { 53 | background: #b51432; 54 | box-shadow: 0 0 4px rgba(#b51432, 0.14), 0 4px 8px rgba(0, 0, 0, 0.28); 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Pimp my duck](./packages/assets/design/hero.png) 2 | 3 | [![License: Apache-2.0](https://img.shields.io/github/license/zenika-open-source/my-zenikanard)](https://github.com/zenika-open-source/my-zenikanard/blob/master/LICENSE) [![Netlify Status](https://api.netlify.com/api/v1/badges/90c60f41-f3ec-46f6-88c2-26cc6257b6aa/deploy-status)](https://app.netlify.com/sites/zenikanard/deploys) 4 | 5 | ## Official app 6 | 7 | Play on **[https://pimpmyduck.zenika.com](https://pimpmyduck.zenika.com)**. 8 | 9 | ## Contributing 10 | 11 | Contributions, issues and feature requests are welcome! Feel free to check [issues page](https://github.com/zenika-open-source/my-zenikanard/issues). 12 | 13 | See the [Asset proposal documentation](./proposals/README.md), if you want to add a new asset to Pimp My Duck. 14 | 15 | All development instructions are explained in the [contributing guide](./.github/CONTRIBUTING.md). 16 | 17 | ⚠️ For maintainers and changes on icons and shapes, see [this README](packages/assets/README.md). 18 | 19 | ## License 20 | 21 | This project is [Apache-2.0](https://github.com/zenika-open-source/my-zenikanard/blob/master/LICENSE) licensed.
22 | Copyright © 2020 [Zenika](https://oss.zenika.com). 23 | ![with love by zenika](https://img.shields.io/badge/With%20%E2%9D%A4%EF%B8%8F%20by-Zenika-b51432.svg?link=https://oss.zenika.com) 24 | -------------------------------------------------------------------------------- /packages/assets/src/icons/mouth-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/mouth-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/glasses-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/README.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | Assets files are copied to `app` and `functions` packages by the npm `postinstall` hook. 4 | 5 | ## Commands 6 | 7 | | Command | Description | 8 | | -------------- | ----------------------------------------------------------------------------------------------- | 9 | | optimize | Optimize all SVG file in `icons` and `shapes` folder with [`svgo`](https://github.com/svg/svgo) | 10 | | generate | Generate a javascript file exporting of all SVG files as React components (used in webapp) | 11 | | copy:app | Copy all assets and generated js files in the `app` package | 12 | | copy:functions | Copy all assets and generated js files in the `functions` package | 13 | | add:asset | Must be executed when new assets are added in `shapes` and `icons` folders | 14 | 15 | ## Package strucure 16 | 17 | ``` 18 | packages/assets 19 | ├── design # Contains all designs as `Affinity Designer` format 20 | ├── generate.js # Node script used to generate `src/index.js` 21 | └── src 22 | ├── icons # Contains all icons in svg format 23 | ├── shapes # Contains all shapes in svg format 24 | ├── index.js # Generated file exporting of all SVG files as React components 25 | └── layers.json # Order and name of the different `Duck` layers 26 | ``` 27 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/glasses-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/eye-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/functions/svg/svgBuilder.test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const svgBuilder = require('./svgBuilder') 3 | 4 | jest.mock('fs') 5 | 6 | describe('listFilesInDirectorySync', () => { 7 | beforeAll(() => { 8 | fs.__mockFiles = { 9 | './assets/shapes/body.svg': '', 10 | './assets/shapes/head.svg': '', 11 | './assets/shapes/eye-1.svg': '', 12 | './assets/shapes/body-tatoo-1.svg': '', 13 | } 14 | }) 15 | 16 | it('generate the minimal svg with body and head if no params given', () => { 17 | const svg = svgBuilder.build() 18 | expect(svg).toBe(` 19 | 20 | 21 | `) 22 | }) 23 | 24 | it('generate the minimal svg with given params', () => { 25 | const svg = svgBuilder.build({ 26 | 'body-tatoo': 'body-tatoo-1', 27 | eye: 'eye-1', 28 | }) 29 | expect(svg).toBe(` 30 | 31 | 32 | `) 33 | }) 34 | 35 | it('generate the minimal svg with given params even if some params does not exist', () => { 36 | const svg = svgBuilder.build({ 37 | 'body-tatoo': 'body-tatoo-1', 38 | eye: 'eye-3', 39 | foo: 'foo-1', 40 | mouth: ['bar-1', 'bar-2'], 41 | }) 42 | expect(svg).toBe(` 43 | 44 | 45 | `) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /packages/assets/src/icons/mouth-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/mouth-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/eye-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/glasses-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you want to contribute, you must read and accept our [Code of Conduct](./CODE_OF_CONDUCT.md). 4 | 5 | ## Monorepo structure 6 | 7 | | package | Description | Documentation | 8 | | ----------- | --------------------------------------------------- | ------------------------------------------------ | 9 | | `app` | The web application built with React and Typescript | [app docs](../packages/app/README.md) | 10 | | `assets` | All the SVG assets and design files | [assets docs](../packages/assets/README.md) | 11 | | `functions` | Endpoints deployed as Netlify functions | [functions docs](../packages/functions/README.md) | 12 | 13 | ## Getting started 14 | 15 | ### Prerequisites 16 | 17 | - yarn >=1.17.0 18 | 19 | ### Install 20 | 21 | ```sh 22 | yarn install 23 | ``` 24 | 25 | **It will execute a `postinstall` command copying all SVG files from `assets` package to the `app` and `functions` packages.** 26 | 27 | ### Usage 28 | 29 | ```sh 30 | yarn start 31 | ``` 32 | 33 | ## Guidelines 34 | 35 | ### General guidelines 36 | 37 | - The master branch is the `production` branch. 38 | - All new features / bugs should be done in a dedicated branch from the `master` branch. 39 | - On each PR you will have a preview app deployed on Netlify 40 | 41 | ### Commit messages 42 | 43 | This project uses [gitmoji](https://gitmoji.carloscuesta.me/) as commit convention, because it's fun and provides an easy way of identifying the purpose or intention of a commit with only looking at the emojis used. All you just have to do is to find the emoji corresponding to the purpose of your commit in [this list](https://gitmoji.carloscuesta.me/) following by a short message explaining the update. You can also add a description if it's needed. That's all ;) 44 | -------------------------------------------------------------------------------- /packages/assets/src/icons/glasses-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/face-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/wear-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/face-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/wear-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/body.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/body.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/useAssets.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import queryString from 'query-string' 3 | import { DEFAULT_ASSETS, SelectedAssets, getLayerAssets, Layer, getCategoryLayers } from './duck' 4 | 5 | const getRandomInt = (max: number) => 6 | Math.floor(Math.random() * Math.floor(max)) 7 | 8 | // eslint-disable-next-line import/no-anonymous-default-export 9 | export default () => { 10 | const [selectedAssets, setSelectedAssets] = useState(DEFAULT_ASSETS) 11 | 12 | useEffect(() => { 13 | const defaultAssets = queryParamsToSelectedAssets() 14 | setSelectedAssets(defaultAssets) 15 | }, []) 16 | 17 | const addAsset = (layer: Layer, asset: string | undefined) => { 18 | const newAssets = { ...selectedAssets, [layer.id]: asset } 19 | pushQueryParams(newAssets) 20 | setSelectedAssets(newAssets) 21 | } 22 | 23 | const randomize = () => { 24 | const randomAssets: SelectedAssets = {} 25 | getCategoryLayers().forEach(layer => { 26 | const assets = getLayerAssets(layer.id) 27 | const index = getRandomInt(assets.length + 1) 28 | if (index === assets.length) { 29 | randomAssets[layer.id] = undefined 30 | } else { 31 | randomAssets[layer.id] = assets[index] 32 | } 33 | }) 34 | pushQueryParams(randomAssets) 35 | setSelectedAssets(randomAssets) 36 | } 37 | 38 | const reset = () => { 39 | window.history.pushState({}, '', '/') 40 | setSelectedAssets(DEFAULT_ASSETS) 41 | } 42 | 43 | return { selectedAssets, addAsset, randomize, reset } 44 | } 45 | 46 | const pushQueryParams = (assets: SelectedAssets) => { 47 | const queryParams = encodeURI( 48 | Object.entries(assets) 49 | .filter(([_, asset]) => asset) 50 | .map(([layerId, asset]) => `${layerId}=${asset}`) 51 | .join('&') 52 | ) 53 | window.history.pushState({}, '', `?${queryParams}`) 54 | } 55 | 56 | const queryParamsToSelectedAssets = () => { 57 | if (!window.location.search) return DEFAULT_ASSETS 58 | const params = queryString.parse(window.location.search) 59 | 60 | const selectedAssets: SelectedAssets = {} 61 | Object.entries(params).forEach(([key, value]) => { 62 | if (!value) return 63 | selectedAssets[key] = String(value) 64 | }) 65 | return selectedAssets 66 | } 67 | -------------------------------------------------------------------------------- /packages/assets/generate.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const {capitalize, camelCase} = require('lodash'); 4 | 5 | const assetsFile = path.join(__dirname, 'src/index.js') 6 | const shapePath = path.join(__dirname, 'src/shapes') 7 | const shapeFiles = fs.readdirSync(shapePath) 8 | 9 | const componentsPath = path.join(__dirname, 'src/components') 10 | if (!fs.existsSync(componentsPath)) { 11 | fs.mkdirSync(componentsPath) 12 | } 13 | 14 | function componentTemplate(filepath) { 15 | return `export { ReactComponent as default } from '${filepath}'` 16 | } 17 | 18 | function importTemplate(componentName, filepath) { 19 | return `const ${componentName} = React.lazy(() => import('${filepath}'))` 20 | } 21 | 22 | function getterTemplate(componentName, name) { 23 | return `if (name === '${name}') return ${componentName}` 24 | } 25 | 26 | let names = [] 27 | let importAssets = [] 28 | let getterAssets = [] 29 | let importIcons = [] 30 | let getterIcons = [] 31 | shapeFiles.forEach((file) => { 32 | if (file.endsWith('.svg')) { 33 | const [name] = file.split('.') 34 | names.push(`'${name}'`) 35 | 36 | const componentName = capitalize(camelCase(name)) 37 | const componentPath = path.join(__dirname, `src/components/${componentName}.js`) 38 | fs.writeFileSync(componentPath, componentTemplate(`../shapes/${name}.svg`), 'utf8') 39 | importAssets.push(importTemplate(componentName, `./components/${componentName}.js`)) 40 | getterAssets.push(getterTemplate(componentName, name)) 41 | 42 | const iconComponentName = `${componentName}Icon` 43 | const iconComponentPath = path.join(__dirname, `src/components/${iconComponentName}.js`) 44 | fs.writeFileSync(iconComponentPath, componentTemplate(`../icons/${name}.svg`), 'utf8') 45 | importIcons.push(importTemplate(iconComponentName, `./components/${iconComponentName}.js`)) 46 | getterIcons.push(getterTemplate(iconComponentName, name)) 47 | } 48 | }) 49 | 50 | const generatedContent = ` 51 | import React from 'react' 52 | 53 | ${importAssets.join('\n')} 54 | 55 | ${importIcons.join('\n')} 56 | 57 | export const getAsset = (name) => { 58 | ${getterAssets.join('\n')} 59 | } 60 | 61 | export const getIcon = (name) => { 62 | ${getterIcons.join('\n')} 63 | } 64 | 65 | export const assetNames = [${names.join(',')}] 66 | ` 67 | 68 | fs.writeFileSync(assetsFile, generatedContent, 'utf8') 69 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/wear-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/useServiceWorker.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import * as serviceWorker from './sw' 3 | 4 | type ServiceWorkerContextProps = { 5 | isRegisterSucceed: boolean 6 | isUpdateAvailable: boolean 7 | update: () => void 8 | } 9 | 10 | const ServiceWorkerContext = React.createContext({ 11 | isRegisterSucceed: false, 12 | isUpdateAvailable: false, 13 | update: () => {}, 14 | }) 15 | 16 | export const ServiceWorkerProvider: FC = ({ children }) => { 17 | const [ 18 | waitingServiceWorker, 19 | setWaitingServiceWorker, 20 | ] = useState(null) 21 | const [isRegisterSucceed, setRegisterSucceed] = useState(false) 22 | const [isUpdateAvailable, setUpdateAvailable] = useState(false) 23 | 24 | React.useEffect(() => { 25 | serviceWorker.register({ 26 | onSuccess: () => setRegisterSucceed(true), 27 | onUpdate: (registration: ServiceWorkerRegistration) => { 28 | setWaitingServiceWorker(registration.waiting) 29 | setUpdateAvailable(true) 30 | }, 31 | onWaiting: (waiting: ServiceWorker) => { 32 | setWaitingServiceWorker(waiting) 33 | setUpdateAvailable(true) 34 | }, 35 | }) 36 | }, []) 37 | 38 | React.useEffect(() => { 39 | // We setup an event listener to automatically reload the page 40 | // after the Service Worker has been updated, this will trigger 41 | // on all the open tabs of our application, so that we don't leave 42 | // any tab in an incosistent state 43 | if (!waitingServiceWorker) return 44 | 45 | waitingServiceWorker.addEventListener('statechange', (event: any) => { 46 | if (event.target.state === 'activated') { 47 | window.location.reload() 48 | } 49 | }) 50 | }, [waitingServiceWorker]) 51 | 52 | const value = React.useMemo( 53 | () => ({ 54 | isRegisterSucceed, 55 | isUpdateAvailable, 56 | update: () => { 57 | if (waitingServiceWorker) { 58 | // We send the SKIP_WAITING message to tell the Service Worker 59 | // to update its cache and flush the old one 60 | waitingServiceWorker.postMessage({ type: 'SKIP_WAITING' }) 61 | } 62 | }, 63 | }), 64 | [isRegisterSucceed, isUpdateAvailable, waitingServiceWorker] 65 | ) 66 | 67 | return ( 68 | 69 | {children} 70 | 71 | ) 72 | } 73 | 74 | export const useServiceWorker = () => { 75 | return React.useContext(ServiceWorkerContext) 76 | } 77 | -------------------------------------------------------------------------------- /packages/assets/src/icons/glasses-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/glasses-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/wear-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hair-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/icons/byzenika.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hair-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hait-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/eye-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hair-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/eye-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hair-3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/mouth-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/mouth-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-9.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-9.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pimp My Duck 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at benjamin.petetot@zenika.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /packages/assets/src/icons/wear-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/wear-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hair-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hair-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/hat-8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/App.module.css: -------------------------------------------------------------------------------- 1 | .app { 2 | width: 100vw; 3 | height: 100vh; 4 | overflow: hidden; 5 | } 6 | 7 | .background { 8 | width: 100%; 9 | height: 60vh; 10 | display: block; 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | background-image: linear-gradient(-180deg, #fff 50%, #f4f6f8 100%); 15 | } 16 | 17 | .header { 18 | position: absolute; 19 | width: 100%; 20 | display: flex; 21 | justify-content: center; 22 | } 23 | 24 | .title { 25 | position: absolute; 26 | font-size: 4rem; 27 | font-weight: 700; 28 | color: #333; 29 | } 30 | 31 | .titleInner { 32 | color: #b51432; 33 | } 34 | 35 | .byZenika { 36 | position: absolute; 37 | right: 0; 38 | top: 5rem; 39 | width: 150px; 40 | } 41 | 42 | .main { 43 | position: relative; 44 | height: 100%; 45 | width: 100%; 46 | } 47 | 48 | .canvas { 49 | height: 100%; 50 | flex-grow: 1; 51 | width: 100%; 52 | margin: 0 auto; 53 | overflow: hidden; 54 | position: absolute; 55 | display: flex; 56 | flex-direction: column; 57 | } 58 | 59 | .canvas > * { 60 | display: block; 61 | width: auto; 62 | margin: 0 auto; 63 | height: 100%; 64 | max-width: 100%; 65 | margin: 50px; 66 | } 67 | 68 | .loading { 69 | color: #828282; 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | } 74 | 75 | .categories { 76 | position: absolute; 77 | } 78 | 79 | .categoriesInner { 80 | display: flex; 81 | padding: 20px; 82 | } 83 | 84 | .categoriesInner > .categoryButton { 85 | border-radius: 3px; 86 | height: 60px; 87 | font-size: 1.4rem; 88 | font-weight: 400; 89 | cursor: pointer; 90 | border: 1px solid gainsboro; 91 | background: gainsboro; 92 | transition: background-color 100ms ease; 93 | } 94 | 95 | .categoriesInner > button.selected { 96 | border: 3px solid #b51432; 97 | } 98 | 99 | .actions { 100 | position: absolute; 101 | text-align: center; 102 | display: flex; 103 | } 104 | 105 | .actions > button.circle { 106 | width: 60px; 107 | height: 60px; 108 | border-radius: 30px; 109 | display: flex; 110 | align-items: center; 111 | justify-content: center; 112 | border: 1px solid gainsboro; 113 | background: gainsboro; 114 | cursor: pointer; 115 | transition: background-color 100ms ease; 116 | } 117 | 118 | .categoriesInner > .categoryButton:hover:not(.selected), 119 | .actions > button.circle:hover { 120 | background-color: rgb(210, 210, 210); 121 | transition: none; 122 | } 123 | 124 | .categoriesInner > .categoryButton:focus, 125 | .actions > button.circle:focus { 126 | outline: none; 127 | } 128 | 129 | .categoriesInner > .categoryButton:active:not(.selected), 130 | .actions > button.circle:active { 131 | background-color: rgb(190, 190, 190); 132 | } 133 | 134 | @media (min-width: 800px) { 135 | .categories { 136 | max-height: 100%; 137 | } 138 | 139 | .categoriesInner { 140 | flex-direction: column; 141 | overflow-y: scroll; 142 | overflow-x: hidden; 143 | white-space: nowrap; 144 | max-height: calc(100vh - 30px); 145 | } 146 | 147 | .categoriesInner > button { 148 | margin-bottom: 10px; 149 | } 150 | 151 | .categoriesLeft { 152 | width: 200px; 153 | top: 50%; 154 | left: 3%; 155 | transform: translate3d(0, -50%, 0); 156 | } 157 | 158 | .categoriesRight { 159 | top: 50%; 160 | right: 3%; 161 | transform: translate3d(0, -50%, 0); 162 | } 163 | 164 | .actions { 165 | width: 50%; 166 | bottom: 20px; 167 | left: 25%; 168 | justify-content: center; 169 | } 170 | 171 | .actions > button { 172 | margin: 0 10px; 173 | } 174 | 175 | .netlify { 176 | position: absolute; 177 | display: block; 178 | bottom: 10px; 179 | left: 10px; 180 | } 181 | } 182 | 183 | @media (max-width: 800px) { 184 | .title { 185 | font-size: 2rem; 186 | } 187 | 188 | .byZenika { 189 | top: 3rem; 190 | width: 100px; 191 | } 192 | 193 | button { 194 | font-size: 1.5rem; 195 | } 196 | 197 | .categories { 198 | max-width: 100%; 199 | } 200 | 201 | .categoriesInner { 202 | overflow-y: hidden; 203 | overflow-x: scroll; 204 | white-space: nowrap; 205 | width: 100%; 206 | } 207 | 208 | .categoriesInner > button { 209 | margin-right: 10px; 210 | } 211 | 212 | .categoriesLeft { 213 | top: 5rem; 214 | width: 100%; 215 | overflow: auto; 216 | } 217 | 218 | .categoriesRight { 219 | bottom: 0; 220 | width: 100%; 221 | overflow: auto; 222 | } 223 | 224 | .actions { 225 | top: 50%; 226 | right: 3%; 227 | transform: translate3d(0, -50%, 0); 228 | flex-direction: column; 229 | } 230 | 231 | .actions > button { 232 | margin: 10px 0; 233 | } 234 | 235 | .netlify { 236 | display: none; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hat-8.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/wear-6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, Suspense } from 'react' 2 | import cn from 'classnames' 3 | import svgExport from 'save-svg-as-png' 4 | import { getAsset } from './assets' 5 | import { 6 | layers, 7 | Layer, 8 | getLayerAssets, 9 | getCategoryLayers, 10 | getDefaultLayer, 11 | } from './duck' 12 | 13 | import { ReactComponent as Random } from './icons/random.svg' 14 | import { ReactComponent as Trash } from './icons/trash.svg' 15 | import { ReactComponent as Download } from './icons/download.svg' 16 | import { ReactComponent as ByZenika } from './icons/byzenika.svg' 17 | import { ReactComponent as Netlify } from './icons/netlify.svg' 18 | 19 | import AssetButton from './components/AssetButton' 20 | import UpdateApp from './components/UpdateApp' 21 | import useAssets from './useAssets' 22 | import styles from './App.module.css' 23 | 24 | function App() { 25 | const svgElement = useRef(null) 26 | const [selectedLayer, setSelectedLayer] = useState(getDefaultLayer()) 27 | const { selectedAssets, addAsset, randomize, reset } = useAssets() 28 | 29 | const isAssetsSelected = (assetName?: string) => { 30 | if (!selectedLayer) return false 31 | const selectedAsset = selectedAssets[selectedLayer.id] 32 | if (!assetName) return !selectedAsset 33 | return selectedAsset === assetName 34 | } 35 | 36 | const download = () => { 37 | svgExport.saveSvgAsPng(svgElement?.current, 'zenikanard.png') 38 | } 39 | 40 | return ( 41 |
42 |
43 |
44 |
45 | Pimp My Duck 46 | 47 |
48 |
49 |
50 |
51 | 58 | {layers.map((layer: Layer) => { 59 | let Asset 60 | if (!layer.name) { 61 | Asset = getAsset(layer.id) 62 | } else { 63 | Asset = getAsset(selectedAssets[layer.id]) 64 | } 65 | return ( 66 | Asset && ( 67 | 68 | 69 | 70 | ) 71 | ) 72 | })} 73 | 74 |
75 |
76 |
77 | {getCategoryLayers().map((layer) => { 78 | if (!selectedLayer) return undefined 79 | return ( 80 | 89 | ) 90 | })} 91 |
92 |
93 |
94 |
95 | 100 | {getLayerAssets(selectedLayer?.id).map((assetName, index) => ( 101 | 108 | ))} 109 |
110 |
111 |
112 | 119 | 127 | 135 |
136 | 137 |
138 | 139 | 140 | 141 |
142 | ) 143 | } 144 | 145 | export default App 146 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/wear-6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/shapes/wear-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/wear-5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/assets/src/icons/hait-3.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------